diff --git a/plugins/member_context/config.toml b/plugins/member_context/config.toml index de9bb0b..c0254cb 100644 --- a/plugins/member_context/config.toml +++ b/plugins/member_context/config.toml @@ -38,6 +38,8 @@ stable_ready_days = 180 [schedule] refresh_times = ["04:20"] +weekly_refresh_time = "04:40" +monthly_refresh_time = "04:50" only_recent_active_groups = true active_hours = 72 min_group_messages = 20 diff --git a/plugins/member_context/digest_service.py b/plugins/member_context/digest_service.py index b9d436b..7396316 100644 --- a/plugins/member_context/digest_service.py +++ b/plugins/member_context/digest_service.py @@ -11,7 +11,6 @@ from db.contacts_db import ContactsDBOperator from db.member_digest_db import MemberDigestDBOperator from db.message_storage import MessageStorageDB from plugins.member_context.dify_client import DifyClient -from plugins.member_context.prompt_builder import MemberContextPromptBuilder from utils.compress_chat_data import compress_chat_data @@ -107,43 +106,94 @@ class MemberDigestService: self.LOG.warning(f"[成员交互摘要] 检查群初始化状态失败,按增量处理: group={chatroom_id}, error={e}") return False - def ensure_member_digest_pipeline(self, chatroom_id: str, wxid: str, force: bool = False) -> Dict: + def ensure_member_digest_pipeline(self, chatroom_id: str, wxid: str, force: bool = False, + enable_weekly: bool = True, enable_monthly: bool = True) -> Dict: member = self.contacts_db.get_chatroom_member_info(chatroom_id, wxid) or {} display_name = member.get("display_name") or member.get("nick_name") or wxid - daily_digests = self.digest_db.list_digests(chatroom_id, wxid, "daily", limit=400) - if not daily_digests: + all_daily_digests = self.digest_db.list_digests(chatroom_id, wxid, "daily", limit=400) + if not all_daily_digests: return { "display_name": display_name, "daily_digests": [], "weekly_digests": [], "monthly_digests": [], + "all_daily_digests": [], + "all_weekly_digests": [], + "all_monthly_digests": [], "stats": {"daily": 0, "weekly": 0, "monthly": 0, "active_days": 0, "built_daily": 0}, } - built_weekly = self._ensure_weekly_digests(chatroom_id, wxid, display_name, force=force) - built_monthly = self._ensure_monthly_digests(chatroom_id, wxid, display_name, force=force) + latest_daily_date = self._extract_latest_daily_date(all_daily_digests) + built_weekly = 0 + built_monthly = 0 + if enable_weekly and (force or self._should_run_weekly(latest_daily_date)): + built_weekly = self._ensure_weekly_digests(chatroom_id, wxid, display_name, force=force) + elif enable_weekly: + self.LOG.debug( + f"[成员交互摘要][周摘要] 本次跳过(未到周处理窗口): " + f"group={chatroom_id}, wxid={wxid}, latest_daily_date={latest_daily_date}" + ) - daily_digests = self.digest_db.list_digests(chatroom_id, wxid, "daily", limit=self.final_daily_limit) - weekly_digests = self.digest_db.list_digests(chatroom_id, wxid, "weekly", limit=self.final_weekly_limit) - monthly_digests = self.digest_db.list_digests(chatroom_id, wxid, "monthly", limit=self.final_monthly_limit) + if enable_monthly and (force or self._should_run_monthly(latest_daily_date)): + built_monthly = self._ensure_monthly_digests(chatroom_id, wxid, display_name, force=force) + elif enable_monthly: + self.LOG.debug( + f"[成员交互摘要][月摘要] 本次跳过(未到月处理窗口): " + f"group={chatroom_id}, wxid={wxid}, latest_daily_date={latest_daily_date}" + ) + + all_weekly_digests = self.digest_db.list_digests(chatroom_id, wxid, "weekly", limit=200) + all_monthly_digests = self.digest_db.list_digests(chatroom_id, wxid, "monthly", limit=120) + + daily_digests = all_daily_digests[:self.final_daily_limit] + weekly_digests = all_weekly_digests[:self.final_weekly_limit] + monthly_digests = all_monthly_digests[:self.final_monthly_limit] return { "display_name": display_name, "daily_digests": daily_digests, "weekly_digests": weekly_digests, "monthly_digests": monthly_digests, + "all_daily_digests": all_daily_digests, + "all_weekly_digests": all_weekly_digests, + "all_monthly_digests": all_monthly_digests, "stats": { - "daily": len(daily_digests), - "weekly": len(weekly_digests), - "monthly": len(monthly_digests), - "active_days": len(self.digest_db.list_digest_keys(chatroom_id, wxid, "daily")), + "daily": len(all_daily_digests), + "weekly": len(all_weekly_digests), + "monthly": len(all_monthly_digests), + "active_days": len(all_daily_digests), "built_daily": 0, "built_weekly": built_weekly, "built_monthly": built_monthly, }, } + def _extract_latest_daily_date(self, daily_digests: List[Dict]) -> Optional[datetime]: + if not daily_digests: + return None + latest_key = daily_digests[0].get("period_key") or daily_digests[0].get("period_end") + return self._parse_period_date(latest_key) + + @staticmethod + def _parse_period_date(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.strptime(str(value)[:10], "%Y-%m-%d") + except Exception: + return None + + def _should_run_weekly(self, latest_daily_date: Optional[datetime]) -> bool: + if not latest_daily_date: + return False + return latest_daily_date.weekday() == 6 + + def _should_run_monthly(self, latest_daily_date: Optional[datetime]) -> bool: + if not latest_daily_date: + return False + return (latest_daily_date + timedelta(days=1)).day == 1 + @staticmethod def _normalize_profile_item(item: Dict) -> Dict: normalized = {} @@ -312,17 +362,24 @@ class MemberDigestService: def _build_period_digest(self, digest_type: str, chatroom_id: str, wxid: str, display_name: str, period_key: str, period_start: str, period_end: str, items: List[Dict]) -> Optional[Dict]: - prompt = MemberContextPromptBuilder.build_period_digest_prompt( - digest_type, chatroom_id, wxid, display_name, period_key, items + parsed = self._request_period_json( + digest_type=digest_type, + chatroom_id=chatroom_id, + wxid=wxid, + display_name=display_name, + period_key=period_key, + items=items, ) - parsed = self._request_ai_json(prompt, tag=f"{digest_type}:{period_key}", chatroom_id=chatroom_id, wxid=wxid) if not parsed: self.LOG.warning( f"[成员交互摘要][{digest_type}] 跳过周期摘要(未提取到有效结果): " - f"group={chatroom_id}, wxid={wxid}, period={period_key}, source_count={len(items)}" + f"group={chatroom_id}, wxid={wxid}, period={period_key}, source_count={len(items)}, " + f"last_error={self.dify_client.last_error}" ) return None + parsed = self._normalize_profile_item(parsed) + return { "chatroom_id": chatroom_id, "wxid": wxid, @@ -355,18 +412,42 @@ class MemberDigestService: parsed["ai_usage"] = response.get("usage", {}) or {} return parsed + def _request_period_json(self, digest_type: str, chatroom_id: str, wxid: str, + display_name: str, period_key: str, items: List[Dict]) -> Optional[Dict]: + if not self.dify_client.is_available(): + return None + + inputs = { + "digest_type": digest_type, + "chatroom_id": chatroom_id, + "wxid": wxid, + "display_name": display_name, + "period_key": period_key, + "source_items_json": json.dumps(self._build_period_source_items(items), ensure_ascii=False), + "source_item_count": str(len(items)), + } + response = self.dify_client.run( + prompt="", + user=f"member-digest:{chatroom_id}:{wxid}:{digest_type}:{period_key}", + inputs=inputs, + tag=f"{digest_type}:{period_key}", + ) + if not response: + return None + parsed = self._parse_ai_answer(response.get("text", "")) + if parsed: + parsed["ai_usage"] = response.get("usage", {}) or {} + return parsed + def _request_group_daily_json(self, chatroom_id: str, digest_date: str, member_labels: List[str], compressed_chat: str) -> List[Dict]: if not self.dify_client.is_available(): return [] - prompt = MemberContextPromptBuilder.build_group_daily_digest_prompt( - chatroom_id, digest_date, member_labels, compressed_chat - ) response = self.dify_client.run( - prompt=prompt, + prompt="", user=f"member-digest:{chatroom_id}:group-daily:{digest_date}", inputs={ - "query": prompt, + "digest_type": "daily", "chatroom_id": chatroom_id, "digest_date": digest_date, "member_labels": "\n".join(member_labels), @@ -379,6 +460,34 @@ class MemberDigestService: parsed = self._parse_group_daily_answer(response.get("text", "")) return parsed + @staticmethod + def _build_period_source_items(items: List[Dict]) -> List[Dict]: + source_items = [] + for item in items: + structured = item.get("structured", {}) or {} + source_items.append({ + "period_key": item.get("period_key"), + "summary_text": item.get("summary_text", ""), + "topics": structured.get("topics") or structured.get("stable_topics") or structured.get("long_term_topics") or [], + "discussion_scenarios": structured.get("discussion_scenarios") or structured.get("common_scenarios") or [], + "identity_clues": structured.get("identity_clues") or structured.get("identity_traits") or [], + "skill_signals": structured.get("skill_signals") or structured.get("skill_profile") or [], + "problem_solving_signals": structured.get("problem_solving_signals") or structured.get("problem_solving_profile") or [], + "family_signals": structured.get("family_signals") or structured.get("family_profile") or [], + "life_stage_signals": structured.get("life_stage_signals") or structured.get("life_stage_profile") or [], + "value_preferences": structured.get("value_preferences") or structured.get("value_profile") or [], + "habit_signals": structured.get("habit_signals") or structured.get("habit_patterns") or [], + "expression_markers": structured.get("expression_markers") or structured.get("expression_profile") or [], + "engagement_traits": structured.get("engagement_traits") or structured.get("stable_traits") or [], + "reply_entry_points": structured.get("reply_entry_points") or structured.get("reply_entry_profile") or [], + "reply_preferences": structured.get("reply_preferences") or structured.get("long_term_reply_preferences") or [], + "social_role": structured.get("social_role") or structured.get("group_role") or "", + "decision_style": structured.get("decision_style") or structured.get("decision_profile") or "", + "temperament_signal": structured.get("temperament_signal") or structured.get("temperament_tendency") or "", + "recent_state": structured.get("recent_state") or structured.get("phase_state") or [], + }) + return source_items + def _parse_ai_answer(self, answer: str) -> Optional[Dict]: if not answer: return None diff --git a/plugins/member_context/main.py b/plugins/member_context/main.py index ee91019..4ccfb39 100644 --- a/plugins/member_context/main.py +++ b/plugins/member_context/main.py @@ -50,13 +50,32 @@ class MemberContextPlugin(MessagePluginInterface): self.LOG.debug(f"正在初始化 {self.name} 插件...") self.service = MemberContextService(context["db_manager"], self._config) refresh_times = self._config.get("schedule", {}).get("refresh_times", []) - if refresh_times and not self._job_registered: - @async_job.at_times(refresh_times) - async def refresh_member_context_job(): - if self.service: - self.LOG.info("开始刷新成员交互摘要") - self.service.refresh_all_chatrooms() - self.LOG.info("成员交互摘要刷新完成") + weekly_refresh_time = self._config.get("schedule", {}).get("weekly_refresh_time", "") + monthly_refresh_time = self._config.get("schedule", {}).get("monthly_refresh_time", "") + if not self._job_registered: + if refresh_times: + @async_job.at_times(refresh_times) + async def refresh_member_context_job(): + if self.service: + self.LOG.info("开始刷新成员交互摘要(日任务)") + self.service.refresh_all_chatrooms(enable_weekly_digest=False, enable_monthly_digest=False) + self.LOG.info("成员交互摘要刷新完成(日任务)") + + if weekly_refresh_time: + @async_job.every_week_time(weekday=6, time_str=weekly_refresh_time) + async def refresh_member_context_weekly_job(): + if self.service: + self.LOG.info("开始刷新成员交互摘要(周任务)") + self.service.refresh_all_chatrooms(enable_weekly_digest=True, enable_monthly_digest=False) + self.LOG.info("成员交互摘要刷新完成(周任务)") + + if monthly_refresh_time: + @async_job.every_month_last_day_time(monthly_refresh_time) + async def refresh_member_context_monthly_job(): + if self.service: + self.LOG.info("开始刷新成员交互摘要(月任务)") + self.service.refresh_all_chatrooms(enable_weekly_digest=False, enable_monthly_digest=True) + self.LOG.info("成员交互摘要刷新完成(月任务)") self._job_registered = True self.LOG.debug(f"{self.name} 插件初始化完成") return True diff --git a/plugins/member_context/member_context.yml b/plugins/member_context/member_context.yml index 0cc64fb..f52657a 100644 --- a/plugins/member_context/member_context.yml +++ b/plugins/member_context/member_context.yml @@ -1,5 +1,5 @@ app: - description: 按群和日期提取群成员日画像,输出严格 JSON,供 member_context 插件直接消费 + description: 支持日/周/月成员摘要生成的统一 workflow,按 digest_type 分支输出严格 JSON icon: 🤖 icon_background: '#E0F2FE' mode: workflow @@ -12,7 +12,7 @@ dependencies: marketplace_plugin_unique_identifier: langgenius/openai_api_compatible:0.0.27@f9ce3ff5e28f09931a3a7fca59add2d09590408f7e9a3d701b10c77a60249719 version: null kind: app -version: 0.5.0 +version: 0.6.0 workflow: conversation_variables: [] environment_variables: [] @@ -62,48 +62,116 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: start-source-llm-target + targetType: if-else + id: start-source-router-target selected: false source: start_node sourceHandle: source - target: llm_node + target: digest_router targetHandle: target type: custom zIndex: 0 - data: isInIteration: false isInLoop: false - sourceType: llm - targetType: end - id: llm-source-end-target - selected: false - source: llm_node - sourceHandle: source - target: end_node - targetHandle: target - type: custom - zIndex: 0 - - data: - isInIteration: false - isInLoop: false - sourceType: llm + sourceType: if-else targetType: llm - id: llm_node-fail-branch-1775115372864-target - source: llm_node - sourceHandle: fail-branch - target: '1775115372864' + id: digest_router-daily-daily_llm-target + selected: false + source: digest_router + sourceHandle: daily_case + target: daily_llm targetHandle: target type: custom zIndex: 0 - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-weekly-weekly_llm-target + selected: false + source: digest_router + sourceHandle: weekly_case + target: weekly_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-monthly-monthly_llm-target + selected: false + source: digest_router + sourceHandle: monthly_case + target: monthly_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-false-invalid_llm-target + selected: false + source: digest_router + sourceHandle: 'false' + target: invalid_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false isInLoop: false sourceType: llm targetType: end - id: 1775115372864-source-end_node-target - source: '1775115372864' + id: daily_llm-source-end_daily-target + selected: false + source: daily_llm sourceHandle: source - target: end_node + target: end_daily + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: weekly_llm-source-end_weekly-target + selected: false + source: weekly_llm + sourceHandle: source + target: end_weekly + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: monthly_llm-source-end_monthly-target + selected: false + source: monthly_llm + sourceHandle: source + target: end_monthly + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: invalid_llm-source-end_invalid-target + selected: false + source: invalid_llm + sourceHandle: source + target: end_invalid targetHandle: target type: custom zIndex: 0 @@ -114,6 +182,12 @@ workflow: title: 开始 type: start variables: + - label: digest_type + max_length: 32 + options: [] + required: true + type: text-input + variable: digest_type - label: query max_length: 120000 options: [] @@ -126,32 +200,114 @@ workflow: required: true type: text-input variable: chatroom_id + - label: wxid + max_length: 128 + options: [] + required: false + type: text-input + variable: wxid + - label: display_name + max_length: 128 + options: [] + required: false + type: text-input + variable: display_name - label: digest_date max_length: 32 options: [] - required: true + required: false type: text-input variable: digest_date + - label: period_key + max_length: 64 + options: [] + required: false + type: text-input + variable: period_key - label: member_labels max_length: 50000 options: [] - required: true + required: false type: paragraph variable: member_labels - label: compressed_chat max_length: 200000 options: [] - required: true + required: false type: paragraph variable: compressed_chat - height: 213 + - label: source_items_json + max_length: 200000 + options: [] + required: false + type: paragraph + variable: source_items_json + - label: source_item_count + max_length: 32 + options: [] + required: false + type: text-input + variable: source_item_count + height: 388 id: start_node position: x: 0 - y: 0 + y: 110 positionAbsolute: x: 0 - y: 0 + y: 110 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + cases: + - case_id: daily_case + conditions: + - comparison_operator: contains + id: daily_case_cond + value: daily + varType: string + variable_selector: + - start_node + - digest_type + id: daily_case + logical_operator: and + - case_id: weekly_case + conditions: + - comparison_operator: contains + id: weekly_case_cond + value: weekly + varType: string + variable_selector: + - start_node + - digest_type + id: weekly_case + logical_operator: and + - case_id: monthly_case + conditions: + - comparison_operator: contains + id: monthly_case_cond + value: monthly + varType: string + variable_selector: + - start_node + - digest_type + id: monthly_case + logical_operator: and + desc: '' + selected: false + title: 摘要类型分支 + type: if-else + height: 222 + id: digest_router + position: + x: 312 + y: 162 + positionAbsolute: + x: 312 + y: 162 selected: false sourcePosition: right targetPosition: left @@ -162,7 +318,7 @@ workflow: enabled: false variable_selector: [] desc: '' - error_strategy: fail-branch + error_strategy: default-value model: completion_params: temperature: 0.2 @@ -170,94 +326,289 @@ workflow: name: gpt-5.4-mini provider: langgenius/openai_api_compatible/openai_api_compatible prompt_template: - - id: system_prompt_member_context + - id: daily_system_prompt role: system - text: "你是微信群后台的成员日行为证据提取器。\n\n任务:\n根据给定的一天群聊记录,只按 wxid 识别成员,输出每个成员当天的结构化行为观察。\n\ - \n关键规则:\n1. wxid 是唯一标识。display_name 仅用于展示,不用于身份判定。\n2. 每个 wxid 最终只能输出一条记录,严禁重复输出同一个\ - \ wxid。\n3. 请先按 wxid 汇总该成员全天发言,再提取结果。\n4. 即使成员发言以短句为主,只要样本量足够,也必须尽量提炼:\n\ - \ - topics\n - discussion_scenarios\n - skill_signals\n - problem_solving_signals\n\ - \ - value_preferences\n - habit_signals\n - expression_markers\n\ - \ - engagement_traits\n - reply_entry_points\n - social_role\n\ - \ - temperament_signal\n - summary_text\n5. identity_clues、family_signals、life_stage_signals\ - \ 没有明确公开证据时允许为空。\n6. 不允许因为“短句较多”就统一输出空数组和通用摘要。\n7. 不做心理诊断、不做隐私猜测、不把玩笑当事实。\n\ - 8. 只能输出候选成员列表中的 wxid。\n9. topics 更偏向反复出现的关注方向;discussion_scenarios 更偏向什么情境下会发言;skill_signals\ - \ 更偏向能力表现;problem_solving_signals 更偏向怎么处理问题;value_preferences 更偏向判断偏好;social_role 更偏向当天在群里的实际作用。\n\ - 10. expression_markers 记录可观察的表达标记,如常用句式、口头禅、是否爱列步骤/贴日志;reply_entry_points 记录什么样的接话方式最容易接住他。\n\ - 11. 输出前自行去重,同一个 wxid 只保留一条最完整结果。\n\n输出要求:\n- 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。\n\ - - 输出格式:\n{\n \"members\": [\n {\n \"wxid\": \"成员wxid\",\n \ - \ \"display_name\": \"显示名\",\n \"topics\": [\"主题1\"],\n \"discussion_scenarios\"\ - : [\"场景1\"],\n \"identity_clues\": [\"身份线索1\"],\n \"skill_signals\"\ - : [\"技能信号1\"],\n \"problem_solving_signals\": [\"处理方式1\"],\n \ - \ \"family_signals\": [\"家庭线索1\"],\n \"life_stage_signals\": [\"\ - 阶段线索1\"],\n \"value_preferences\": [\"价值偏好1\"],\n \"interaction_style\"\ - : \"一句中文\",\n \"message_pattern\": \"一句中文\",\n \"response_style_hint\"\ - : \"一句中文\",\n \"habit_signals\": [\"习惯1\"],\n \"expression_markers\"\ - : [\"表达标记1\"],\n \"engagement_traits\": [\"参与特征1\"],\n \"reply_entry_points\"\ - : [\"接话点1\"],\n \"decision_style\": \"一句中文\",\n \"social_role\"\ - : \"一句中文\",\n \"reply_taboos\": [\"避坑1\"],\n \"temperament_signal\"\ - : \"一句中文\",\n \"summary_text\": \"不超过100字\",\n \"representative_messages\"\ - : [\"原话1\", \"原话2\"],\n \"confidence\": 0.95\n }\n ]\n}\n\n字段约束:\n\ - - topics、discussion_scenarios、skill_signals、problem_solving_signals、value_preferences、habit_signals、expression_markers、engagement_traits、reply_entry_points\ - \ 最多 4 个\n- identity_clues、family_signals、life_stage_signals 最多 3 个\n\ - - reply_taboos 最多 3 个\n- representative_messages 最多 3 条\n- 如果某成员样本明显不足,可以不输出该成员\n" - - id: user_prompt_member_context - role: user - text: '群ID: {{#start_node.chatroom_id#}} + text: | + 你是微信群后台的成员日行为证据提取器。 + 任务: + 根据给定的一天群聊记录,只按 wxid 识别成员,输出每个成员当天的结构化行为观察。 + + 关键规则: + 1. wxid 是唯一标识。display_name 仅用于展示,不用于身份判定。 + 2. 每个 wxid 最终只能输出一条记录,严禁重复输出同一个 wxid。 + 3. 请先按 wxid 汇总该成员全天发言,再提取结果。 + 4. 即使成员发言以短句为主,只要样本量足够,也必须尽量提炼: + - topics + - discussion_scenarios + - skill_signals + - problem_solving_signals + - value_preferences + - habit_signals + - expression_markers + - engagement_traits + - reply_entry_points + - social_role + - temperament_signal + - summary_text + 5. identity_clues、family_signals、life_stage_signals 没有明确公开证据时允许为空。 + 6. 不允许因为“短句较多”就统一输出空数组和通用摘要。 + 7. 不做心理诊断、不做隐私猜测、不把玩笑当事实。 + 8. 只能输出候选成员列表中的 wxid。 + 9. topics 更偏向反复出现的关注方向;discussion_scenarios 更偏向什么情境下会发言;skill_signals 更偏向能力表现;problem_solving_signals 更偏向怎么处理问题;value_preferences 更偏向判断偏好;social_role 更偏向当天在群里的实际作用。 + 10. expression_markers 记录可观察的表达标记,如常用句式、口头禅、是否爱列步骤/贴日志;reply_entry_points 记录什么样的接话方式最容易接住他。 + + 输出要求: + - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 + - 输出格式: + { + "members": [ + { + "wxid": "成员wxid", + "display_name": "显示名", + "topics": ["主题1"], + "discussion_scenarios": ["场景1"], + "identity_clues": ["身份线索1"], + "skill_signals": ["技能信号1"], + "problem_solving_signals": ["处理方式1"], + "family_signals": ["家庭线索1"], + "life_stage_signals": ["阶段线索1"], + "value_preferences": ["价值偏好1"], + "interaction_style": "一句中文", + "message_pattern": "一句中文", + "response_style_hint": "一句中文", + "habit_signals": ["习惯1"], + "expression_markers": ["表达标记1"], + "engagement_traits": ["参与特征1"], + "reply_entry_points": ["接话点1"], + "decision_style": "一句中文", + "social_role": "一句中文", + "reply_taboos": ["避坑1"], + "temperament_signal": "一句中文", + "summary_text": "不超过100字", + "representative_messages": ["原话1", "原话2"], + "confidence": 0.95 + } + ] + } + + 字段约束: + - topics、discussion_scenarios、skill_signals、problem_solving_signals、value_preferences、habit_signals、expression_markers、engagement_traits、reply_entry_points 最多 4 个 + - identity_clues、family_signals、life_stage_signals 最多 3 个 + - reply_taboos 最多 3 个 + - representative_messages 最多 3 条 + - 如果某成员样本明显不足,可以不输出该成员 + - id: daily_user_prompt + role: user + text: | + 摘要类型: daily + 群ID: {{#start_node.chatroom_id#}} 日期: {{#start_node.digest_date#}} - 候选成员: - {{#start_node.member_labels#}} - 压缩后的群聊记录: - {{#start_node.compressed_chat#}} - ' + 补充上下文: + {{#start_node.query#}} retry_config: max_retries: 3 retry_enabled: true retry_interval: 1000 selected: false - title: 成员画像提取 + title: 日摘要生成 type: llm variables: [] vision: enabled: false height: 154 - id: llm_node + id: daily_llm position: - x: 342 - y: 30 + x: 652 + y: 18 positionAbsolute: - x: 342 - y: 30 + x: 652 + y: 18 selected: false sourcePosition: right targetPosition: left type: custom width: 242 - data: + context: + enabled: false + variable_selector: [] desc: '' - outputs: - - value_selector: - - llm_node - - text - variable: text + error_strategy: default-value + model: + completion_params: + temperature: 0.2 + mode: chat + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: weekly_system_prompt + role: system + text: | + 你是微信群后台的成员周摘要生成器。 + + 任务: + 根据同一成员的多个日摘要,提炼本周重复出现的模式,过滤单日噪音,输出严格 JSON。 + + 关键规则: + 1. 只根据输入中的下级摘要生成,不可编造新增事实。 + 2. 只有多个日摘要反复出现的特征,才允许进入 stable_topics、stable_traits、habit_patterns、reply_preferences。 + 3. recent_state 只描述当前阶段状态,不要把短期现象写成长期人格。 + 4. identity_traits、family_profile、life_stage_profile 只能保留反复出现的公开线索。 + 5. common_scenarios、problem_solving_profile、expression_profile、reply_entry_profile 要尽量写成具体行为模式。 + + 输出要求: + - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 + - 输出格式: + { + "stable_topics": ["主题1"], + "common_scenarios": ["场景1"], + "identity_traits": ["身份特征1"], + "skill_profile": ["技能画像1"], + "problem_solving_profile": ["处理方式1"], + "family_profile": ["家庭线索1"], + "life_stage_profile": ["阶段线索1"], + "value_profile": ["价值偏好1"], + "stable_traits": ["特征1"], + "habit_patterns": ["习惯1"], + "expression_profile": ["表达标记1"], + "reply_entry_profile": ["接话点1"], + "reply_preferences": ["偏好1"], + "group_role": "一句中文", + "decision_profile": "一句中文", + "recent_state": ["状态1"], + "temperament_tendency": "一句中文", + "summary_text": "一段不超过120字的周摘要", + "confidence": 0.0 + } + - id: weekly_user_prompt + role: user + text: | + 摘要类型: weekly + 群ID: {{#start_node.chatroom_id#}} + 成员: {{#start_node.display_name#}} ({{#start_node.wxid#}}) + 周期: {{#start_node.period_key#}} + 下级摘要数量: {{#start_node.source_item_count#}} + + 下级摘要 JSON: + {{#start_node.source_items_json#}} + + 补充上下文: + {{#start_node.query#}} + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 selected: false - title: 结束 - type: end - height: 88 - id: end_node + title: 周摘要生成 + type: llm + variables: [] + vision: + enabled: false + height: 154 + id: weekly_llm position: - x: 1066 - y: 52 + x: 652 + y: 196 positionAbsolute: - x: 1066 - y: 52 + x: 652 + y: 196 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + error_strategy: default-value + model: + completion_params: + temperature: 0.2 + mode: chat + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: monthly_system_prompt + role: system + text: | + 你是微信群后台的成员月摘要生成器。 + + 任务: + 根据同一成员的多个周摘要,提炼阶段性稳定特征,只有反复出现的模式才能进入长期层,输出严格 JSON。 + + 关键规则: + 1. 只根据输入中的下级摘要生成,不可编造新增事实。 + 2. 只有多个周摘要反复出现的特征,才允许进入 long_term_topics、stable_traits、habit_patterns、long_term_reply_preferences。 + 3. phase_state 只描述当前阶段状态,不要冒充长期人格。 + 4. identity_traits、family_profile、life_stage_profile 只能保留反复出现的公开线索。 + 5. group_role、decision_profile、problem_solving_profile、expression_profile 要总结阶段性稳定模式。 + + 输出要求: + - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 + - 输出格式: + { + "long_term_topics": ["主题1"], + "common_scenarios": ["场景1"], + "identity_traits": ["身份特征1"], + "skill_profile": ["技能画像1"], + "problem_solving_profile": ["处理方式1"], + "family_profile": ["家庭线索1"], + "life_stage_profile": ["阶段线索1"], + "value_profile": ["价值偏好1"], + "stable_traits": ["特征1"], + "habit_patterns": ["习惯1"], + "expression_profile": ["表达标记1"], + "reply_entry_profile": ["接话点1"], + "long_term_reply_preferences": ["偏好1"], + "group_role": "一句中文", + "decision_profile": "一句中文", + "phase_state": ["状态1"], + "temperament_tendency": "一句中文", + "summary_text": "一段不超过140字的月摘要", + "confidence": 0.0 + } + - id: monthly_user_prompt + role: user + text: | + 摘要类型: monthly + 群ID: {{#start_node.chatroom_id#}} + 成员: {{#start_node.display_name#}} ({{#start_node.wxid#}}) + 周期: {{#start_node.period_key#}} + 下级摘要数量: {{#start_node.source_item_count#}} + + 下级摘要 JSON: + {{#start_node.source_items_json#}} + + 补充上下文: + {{#start_node.query#}} + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 月摘要生成 + type: llm + variables: [] + vision: + enabled: false + height: 154 + id: monthly_llm + position: + x: 652 + y: 374 + positionAbsolute: + x: 652 + y: 374 selected: false sourcePosition: right targetPosition: left @@ -270,82 +621,139 @@ workflow: default_value: - key: text type: string - value: '{"members": []}' + value: '{"error":"unsupported_digest_type"}' + desc: '' error_strategy: default-value model: completion_params: - temperature: 0.7 + temperature: 0.1 mode: chat - name: grok-4-fast + name: gpt-5.4-mini provider: langgenius/openai_api_compatible/openai_api_compatible prompt_template: - - id: c5ee983e-d6e0-4790-ac3b-f4e097013b70 + - id: invalid_system_prompt role: system - text: "你是微信群后台的成员日行为证据提取器。\n\n任务:\n根据给定的一天群聊记录,只按 wxid 识别成员,输出每个成员当天的结构化行为观察。\n\ - \n关键规则:\n1. wxid 是唯一标识。display_name 仅用于展示,不用于身份判定。\n2. 每个 wxid 最终只能输出一条记录,严禁重复输出同一个\ - \ wxid。\n3. 请先按 wxid 汇总该成员全天发言,再提取结果。\n4. 即使成员发言以短句为主,只要样本量足够,也必须尽量提炼:\n\ - \ - topics\n - discussion_scenarios\n - skill_signals\n - problem_solving_signals\n\ - \ - value_preferences\n - habit_signals\n - expression_markers\n\ - \ - engagement_traits\n - reply_entry_points\n - social_role\n\ - \ - temperament_signal\n - summary_text\n5. identity_clues、family_signals、life_stage_signals\ - \ 没有明确公开证据时允许为空。\n6. 不允许因为“短句较多”就统一输出空数组和通用摘要。\n7. 不做心理诊断、不做隐私猜测、不把玩笑当事实。\n\ - 8. 只能输出候选成员列表中的 wxid。\n9. topics 更偏向反复出现的关注方向;discussion_scenarios 更偏向什么情境下会发言;skill_signals\ - \ 更偏向能力表现;problem_solving_signals 更偏向怎么处理问题;value_preferences 更偏向判断偏好;social_role 更偏向当天在群里的实际作用。\n\ - 10. expression_markers 记录可观察的表达标记,如常用句式、口头禅、是否爱列步骤/贴日志;reply_entry_points 记录什么样的接话方式最容易接住他。\n\ - 11. 输出前自行去重,同一个 wxid 只保留一条最完整结果。\n\n输出要求:\n- 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。\n\ - - 输出格式:\n{\n \"members\": [\n {\n \"wxid\": \"成员wxid\",\n \ - \ \"display_name\": \"显示名\",\n \"topics\": [\"主题1\"],\n \"discussion_scenarios\"\ - : [\"场景1\"],\n \"identity_clues\": [\"身份线索1\"],\n \"skill_signals\"\ - : [\"技能信号1\"],\n \"problem_solving_signals\": [\"处理方式1\"],\n \ - \ \"family_signals\": [\"家庭线索1\"],\n \"life_stage_signals\": [\"\ - 阶段线索1\"],\n \"value_preferences\": [\"价值偏好1\"],\n \"interaction_style\"\ - : \"一句中文\",\n \"message_pattern\": \"一句中文\",\n \"response_style_hint\"\ - : \"一句中文\",\n \"habit_signals\": [\"习惯1\"],\n \"expression_markers\"\ - : [\"表达标记1\"],\n \"engagement_traits\": [\"参与特征1\"],\n \"reply_entry_points\"\ - : [\"接话点1\"],\n \"decision_style\": \"一句中文\",\n \"social_role\"\ - : \"一句中文\",\n \"reply_taboos\": [\"避坑1\"],\n \"temperament_signal\"\ - : \"一句中文\",\n \"summary_text\": \"不超过100字\",\n \"representative_messages\"\ - : [\"原话1\", \"原话2\"],\n \"confidence\": 0.95\n }\n ]\n}\n\n字段约束:\n\ - - topics、discussion_scenarios、skill_signals、problem_solving_signals、value_preferences、habit_signals、expression_markers、engagement_traits、reply_entry_points\ - \ 最多 4 个\n- identity_clues、family_signals、life_stage_signals 最多 3 个\n\ - - reply_taboos 最多 3 个\n- representative_messages 最多 3 条\n- 如果某成员样本明显不足,可以不输出该成员\n" - - id: 2df6d4ce-4e40-42a9-85f3-184075da24c7 + text: | + 你是一个严格返回 JSON 的错误响应器。 + 当摘要类型不受支持时,只返回一个紧凑 JSON。 + - id: invalid_user_prompt role: user - text: '群ID: {{#start_node.chatroom_id#}} - - 日期: {{#start_node.digest_date#}} - - - 候选成员: - - {{#start_node.member_labels#}} - - - 压缩后的群聊记录: - - {{#start_node.compressed_chat#}} - - ' - selected: true - title: 异常分支 + text: | + 输入的 digest_type 是: {{#start_node.digest_type#}} + 请只返回: + {"error":"unsupported_digest_type","digest_type":"原值"} + selected: false + title: 非法类型处理 type: llm vision: enabled: false height: 124 - id: '1775115372864' + id: invalid_llm position: - x: 704 - y: 132 + x: 652 + y: 552 positionAbsolute: - x: 704 - y: 132 - selected: true + x: 652 + y: 552 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - daily_llm + - text + variable: text + selected: false + title: 结束-日 + type: end + height: 88 + id: end_daily + position: + x: 1016 + y: 54 + positionAbsolute: + x: 1016 + y: 54 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - weekly_llm + - text + variable: text + selected: false + title: 结束-周 + type: end + height: 88 + id: end_weekly + position: + x: 1016 + y: 232 + positionAbsolute: + x: 1016 + y: 232 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - monthly_llm + - text + variable: text + selected: false + title: 结束-月 + type: end + height: 88 + id: end_monthly + position: + x: 1016 + y: 410 + positionAbsolute: + x: 1016 + y: 410 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - invalid_llm + - text + variable: text + selected: false + title: 结束-异常 + type: end + height: 88 + id: end_invalid + position: + x: 1016 + y: 588 + positionAbsolute: + x: 1016 + y: 588 + selected: false sourcePosition: right targetPosition: left type: custom width: 242 viewport: - x: 210 - y: 340 + x: 60 + y: 120 zoom: 0.7 rag_pipeline_variables: [] diff --git a/plugins/member_context/member_context_workflow.yml b/plugins/member_context/member_context_workflow.yml index d704512..f52657a 100644 --- a/plugins/member_context/member_context_workflow.yml +++ b/plugins/member_context/member_context_workflow.yml @@ -1,6 +1,6 @@ app: - description: 按群和日期提取群成员日画像,输出严格 JSON,供 member_context 插件直接消费 - icon: 🧠 + description: 支持日/周/月成员摘要生成的统一 workflow,按 digest_type 分支输出严格 JSON + icon: 🤖 icon_background: '#E0F2FE' mode: workflow name: member_context @@ -9,9 +9,10 @@ dependencies: - current_identifier: null type: marketplace value: - marketplace_plugin_unique_identifier: langgenius/volcengine_maas:0.0.13@d402dc32a505b1b4f27588f10e729209bf413ec263467635774d96c4345bd197 + marketplace_plugin_unique_identifier: langgenius/openai_api_compatible:0.0.27@f9ce3ff5e28f09931a3a7fca59add2d09590408f7e9a3d701b10c77a60249719 + version: null kind: app -version: 0.3.0 +version: 0.6.0 workflow: conversation_variables: [] environment_variables: [] @@ -29,7 +30,9 @@ workflow: audio_file_size_limit: 50 batch_count_limit: 5 file_size_limit: 15 + image_file_batch_limit: 10 image_file_size_limit: 10 + single_chunk_attachment_limit: 10 video_file_size_limit: 100 workflow_file_upload_limit: 10 image: @@ -59,12 +62,64 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: start-source-llm-target + targetType: if-else + id: start-source-router-target selected: false - source: 'start_node' + source: start_node sourceHandle: source - target: 'llm_node' + target: digest_router + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-daily-daily_llm-target + selected: false + source: digest_router + sourceHandle: daily_case + target: daily_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-weekly-weekly_llm-target + selected: false + source: digest_router + sourceHandle: weekly_case + target: weekly_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-monthly-monthly_llm-target + selected: false + source: digest_router + sourceHandle: monthly_case + target: monthly_llm + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: llm + id: digest_router-false-invalid_llm-target + selected: false + source: digest_router + sourceHandle: 'false' + target: invalid_llm targetHandle: target type: custom zIndex: 0 @@ -73,11 +128,50 @@ workflow: isInLoop: false sourceType: llm targetType: end - id: llm-source-end-target + id: daily_llm-source-end_daily-target selected: false - source: 'llm_node' + source: daily_llm sourceHandle: source - target: 'end_node' + target: end_daily + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: weekly_llm-source-end_weekly-target + selected: false + source: weekly_llm + sourceHandle: source + target: end_weekly + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: monthly_llm-source-end_monthly-target + selected: false + source: monthly_llm + sourceHandle: source + target: end_monthly + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: invalid_llm-source-end_invalid-target + selected: false + source: invalid_llm + sourceHandle: source + target: end_invalid targetHandle: target type: custom zIndex: 0 @@ -88,6 +182,12 @@ workflow: title: 开始 type: start variables: + - label: digest_type + max_length: 32 + options: [] + required: true + type: text-input + variable: digest_type - label: query max_length: 120000 options: [] @@ -100,55 +200,133 @@ workflow: required: true type: text-input variable: chatroom_id + - label: wxid + max_length: 128 + options: [] + required: false + type: text-input + variable: wxid + - label: display_name + max_length: 128 + options: [] + required: false + type: text-input + variable: display_name - label: digest_date max_length: 32 options: [] - required: true + required: false type: text-input variable: digest_date + - label: period_key + max_length: 64 + options: [] + required: false + type: text-input + variable: period_key - label: member_labels max_length: 50000 options: [] - required: true + required: false type: paragraph variable: member_labels - label: compressed_chat max_length: 200000 options: [] - required: true + required: false type: paragraph variable: compressed_chat - height: 194 - id: 'start_node' + - label: source_items_json + max_length: 200000 + options: [] + required: false + type: paragraph + variable: source_items_json + - label: source_item_count + max_length: 32 + options: [] + required: false + type: text-input + variable: source_item_count + height: 388 + id: start_node position: - x: -420 - y: 120 + x: 0 + y: 110 positionAbsolute: - x: -420 - y: 120 + x: 0 + y: 110 selected: false sourcePosition: right targetPosition: left type: custom - width: 244 + width: 242 + - data: + cases: + - case_id: daily_case + conditions: + - comparison_operator: contains + id: daily_case_cond + value: daily + varType: string + variable_selector: + - start_node + - digest_type + id: daily_case + logical_operator: and + - case_id: weekly_case + conditions: + - comparison_operator: contains + id: weekly_case_cond + value: weekly + varType: string + variable_selector: + - start_node + - digest_type + id: weekly_case + logical_operator: and + - case_id: monthly_case + conditions: + - comparison_operator: contains + id: monthly_case_cond + value: monthly + varType: string + variable_selector: + - start_node + - digest_type + id: monthly_case + logical_operator: and + desc: '' + selected: false + title: 摘要类型分支 + type: if-else + height: 222 + id: digest_router + position: + x: 312 + y: 162 + positionAbsolute: + x: 312 + y: 162 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 - data: context: enabled: false variable_selector: [] - default_value: - - key: text - type: string - value: '{"members":[]}' desc: '' error_strategy: default-value model: completion_params: temperature: 0.2 mode: chat - name: Doubao-1.5-pro-256k - provider: langgenius/volcengine_maas/volcengine_maas + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible prompt_template: - - id: system_prompt_member_context + - id: daily_system_prompt role: system text: | 你是微信群后台的成员日行为证据提取器。 @@ -179,7 +357,6 @@ workflow: 8. 只能输出候选成员列表中的 wxid。 9. topics 更偏向反复出现的关注方向;discussion_scenarios 更偏向什么情境下会发言;skill_signals 更偏向能力表现;problem_solving_signals 更偏向怎么处理问题;value_preferences 更偏向判断偏好;social_role 更偏向当天在群里的实际作用。 10. expression_markers 记录可观察的表达标记,如常用句式、口头禅、是否爱列步骤/贴日志;reply_entry_points 记录什么样的接话方式最容易接住他。 - 11. 输出前自行去重,同一个 wxid 只保留一条最完整结果。 输出要求: - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 @@ -221,9 +398,10 @@ workflow: - reply_taboos 最多 3 个 - representative_messages 最多 3 条 - 如果某成员样本明显不足,可以不输出该成员 - - id: user_prompt_member_context + - id: daily_user_prompt role: user text: | + 摘要类型: daily 群ID: {{#start_node.chatroom_id#}} 日期: {{#start_node.digest_date#}} @@ -232,49 +410,350 @@ workflow: 压缩后的群聊记录: {{#start_node.compressed_chat#}} + + 补充上下文: + {{#start_node.query#}} + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 selected: false - title: 成员画像提取 + title: 日摘要生成 type: llm variables: [] vision: enabled: false - height: 98 - id: 'llm_node' + height: 154 + id: daily_llm position: - x: 10 - y: 140 + x: 652 + y: 18 positionAbsolute: - x: 10 - y: 140 + x: 652 + y: 18 selected: false sourcePosition: right targetPosition: left type: custom - width: 244 + width: 242 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + error_strategy: default-value + model: + completion_params: + temperature: 0.2 + mode: chat + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: weekly_system_prompt + role: system + text: | + 你是微信群后台的成员周摘要生成器。 + + 任务: + 根据同一成员的多个日摘要,提炼本周重复出现的模式,过滤单日噪音,输出严格 JSON。 + + 关键规则: + 1. 只根据输入中的下级摘要生成,不可编造新增事实。 + 2. 只有多个日摘要反复出现的特征,才允许进入 stable_topics、stable_traits、habit_patterns、reply_preferences。 + 3. recent_state 只描述当前阶段状态,不要把短期现象写成长期人格。 + 4. identity_traits、family_profile、life_stage_profile 只能保留反复出现的公开线索。 + 5. common_scenarios、problem_solving_profile、expression_profile、reply_entry_profile 要尽量写成具体行为模式。 + + 输出要求: + - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 + - 输出格式: + { + "stable_topics": ["主题1"], + "common_scenarios": ["场景1"], + "identity_traits": ["身份特征1"], + "skill_profile": ["技能画像1"], + "problem_solving_profile": ["处理方式1"], + "family_profile": ["家庭线索1"], + "life_stage_profile": ["阶段线索1"], + "value_profile": ["价值偏好1"], + "stable_traits": ["特征1"], + "habit_patterns": ["习惯1"], + "expression_profile": ["表达标记1"], + "reply_entry_profile": ["接话点1"], + "reply_preferences": ["偏好1"], + "group_role": "一句中文", + "decision_profile": "一句中文", + "recent_state": ["状态1"], + "temperament_tendency": "一句中文", + "summary_text": "一段不超过120字的周摘要", + "confidence": 0.0 + } + - id: weekly_user_prompt + role: user + text: | + 摘要类型: weekly + 群ID: {{#start_node.chatroom_id#}} + 成员: {{#start_node.display_name#}} ({{#start_node.wxid#}}) + 周期: {{#start_node.period_key#}} + 下级摘要数量: {{#start_node.source_item_count#}} + + 下级摘要 JSON: + {{#start_node.source_items_json#}} + + 补充上下文: + {{#start_node.query#}} + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 周摘要生成 + type: llm + variables: [] + vision: + enabled: false + height: 154 + id: weekly_llm + position: + x: 652 + y: 196 + positionAbsolute: + x: 652 + y: 196 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + error_strategy: default-value + model: + completion_params: + temperature: 0.2 + mode: chat + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: monthly_system_prompt + role: system + text: | + 你是微信群后台的成员月摘要生成器。 + + 任务: + 根据同一成员的多个周摘要,提炼阶段性稳定特征,只有反复出现的模式才能进入长期层,输出严格 JSON。 + + 关键规则: + 1. 只根据输入中的下级摘要生成,不可编造新增事实。 + 2. 只有多个周摘要反复出现的特征,才允许进入 long_term_topics、stable_traits、habit_patterns、long_term_reply_preferences。 + 3. phase_state 只描述当前阶段状态,不要冒充长期人格。 + 4. identity_traits、family_profile、life_stage_profile 只能保留反复出现的公开线索。 + 5. group_role、decision_profile、problem_solving_profile、expression_profile 要总结阶段性稳定模式。 + + 输出要求: + - 只输出严格 JSON,不要 markdown,不要解释,不要前后缀。 + - 输出格式: + { + "long_term_topics": ["主题1"], + "common_scenarios": ["场景1"], + "identity_traits": ["身份特征1"], + "skill_profile": ["技能画像1"], + "problem_solving_profile": ["处理方式1"], + "family_profile": ["家庭线索1"], + "life_stage_profile": ["阶段线索1"], + "value_profile": ["价值偏好1"], + "stable_traits": ["特征1"], + "habit_patterns": ["习惯1"], + "expression_profile": ["表达标记1"], + "reply_entry_profile": ["接话点1"], + "long_term_reply_preferences": ["偏好1"], + "group_role": "一句中文", + "decision_profile": "一句中文", + "phase_state": ["状态1"], + "temperament_tendency": "一句中文", + "summary_text": "一段不超过140字的月摘要", + "confidence": 0.0 + } + - id: monthly_user_prompt + role: user + text: | + 摘要类型: monthly + 群ID: {{#start_node.chatroom_id#}} + 成员: {{#start_node.display_name#}} ({{#start_node.wxid#}}) + 周期: {{#start_node.period_key#}} + 下级摘要数量: {{#start_node.source_item_count#}} + + 下级摘要 JSON: + {{#start_node.source_items_json#}} + + 补充上下文: + {{#start_node.query#}} + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 月摘要生成 + type: llm + variables: [] + vision: + enabled: false + height: 154 + id: monthly_llm + position: + x: 652 + y: 374 + positionAbsolute: + x: 652 + y: 374 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + default_value: + - key: text + type: string + value: '{"error":"unsupported_digest_type"}' + desc: '' + error_strategy: default-value + model: + completion_params: + temperature: 0.1 + mode: chat + name: gpt-5.4-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: invalid_system_prompt + role: system + text: | + 你是一个严格返回 JSON 的错误响应器。 + 当摘要类型不受支持时,只返回一个紧凑 JSON。 + - id: invalid_user_prompt + role: user + text: | + 输入的 digest_type 是: {{#start_node.digest_type#}} + 请只返回: + {"error":"unsupported_digest_type","digest_type":"原值"} + selected: false + title: 非法类型处理 + type: llm + vision: + enabled: false + height: 124 + id: invalid_llm + position: + x: 652 + y: 552 + positionAbsolute: + x: 652 + y: 552 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 - data: desc: '' outputs: - value_selector: - - 'llm_node' + - daily_llm - text variable: text selected: false - title: 结束 + title: 结束-日 type: end - height: 90 - id: 'end_node' + height: 88 + id: end_daily position: - x: 430 - y: 140 + x: 1016 + y: 54 positionAbsolute: - x: 430 - y: 140 + x: 1016 + y: 54 selected: false sourcePosition: right targetPosition: left type: custom - width: 244 + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - weekly_llm + - text + variable: text + selected: false + title: 结束-周 + type: end + height: 88 + id: end_weekly + position: + x: 1016 + y: 232 + positionAbsolute: + x: 1016 + y: 232 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - monthly_llm + - text + variable: text + selected: false + title: 结束-月 + type: end + height: 88 + id: end_monthly + position: + x: 1016 + y: 410 + positionAbsolute: + x: 1016 + y: 410 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - invalid_llm + - text + variable: text + selected: false + title: 结束-异常 + type: end + height: 88 + id: end_invalid + position: + x: 1016 + y: 588 + positionAbsolute: + x: 1016 + y: 588 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 viewport: - x: 120 - y: 180 - zoom: 0.9 + x: 60 + y: 120 + zoom: 0.7 + rag_pipeline_variables: [] diff --git a/plugins/member_context/service.py b/plugins/member_context/service.py index 181bf59..589b2d5 100644 --- a/plugins/member_context/service.py +++ b/plugins/member_context/service.py @@ -69,7 +69,8 @@ class MemberContextService: def build_member_context(self, chatroom_id: str, wxid: str, days: Optional[int] = None, limit: Optional[int] = None, force_digest_rebuild: bool = False, - ensure_group_daily: bool = True) -> Dict: + ensure_group_daily: bool = True, enable_weekly_digest: bool = True, + enable_monthly_digest: bool = True) -> Dict: days = days or self.sample_days limit = limit or self.refresh_limit_per_member @@ -83,11 +84,15 @@ class MemberContextService: chatroom_id, force=force_digest_rebuild ) digest_snapshot = self.digest_service.ensure_member_digest_pipeline( - chatroom_id, wxid, force=force_digest_rebuild + chatroom_id, wxid, force=force_digest_rebuild, + enable_weekly=enable_weekly_digest, enable_monthly=enable_monthly_digest ) daily_digests = digest_snapshot.get("daily_digests", []) weekly_digests = digest_snapshot.get("weekly_digests", []) monthly_digests = digest_snapshot.get("monthly_digests", []) + all_daily_digests = digest_snapshot.get("all_daily_digests", daily_digests) + all_weekly_digests = digest_snapshot.get("all_weekly_digests", weekly_digests) + all_monthly_digests = digest_snapshot.get("all_monthly_digests", monthly_digests) recent_messages = self.message_db.get_member_recent_messages( chatroom_id, @@ -100,7 +105,7 @@ class MemberContextService: weekly_structured = [item.get("structured", {}) or {} for item in weekly_digests] daily_structured = [item.get("structured", {}) or {} for item in daily_digests] - observation_days = self._calc_observation_days(daily_digests) + observation_days = self._calc_observation_days(all_daily_digests) activity_level = self._calc_activity_level(len(recent_messages), max(min(days, 7), 1)) context = { "chatroom_id": chatroom_id, @@ -121,7 +126,7 @@ class MemberContextService: ), "recent_focus": self._extract_scored_items(daily_structured, ["topics"], limit=4), "summary_text": "", - "confidence": self._calc_digest_confidence(monthly_digests, weekly_digests, daily_digests), + "confidence": self._calc_digest_confidence(all_monthly_digests, all_weekly_digests, all_daily_digests), "source_message_count": len(recent_messages), "source_days": days, "last_profiled_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -194,10 +199,10 @@ class MemberContextService: "observation_days": observation_days, "stable_ready": observation_days >= self.stable_ready_days, "profile_iterations": int(((existing_context or {}).get("meta", {}) or {}).get("profile_iterations", 0)) + 1, - "history_message_count": self._sum_digest_source_count(daily_digests), - "digest_daily_count": len(daily_digests), - "digest_weekly_count": len(weekly_digests), - "digest_monthly_count": len(monthly_digests), + "history_message_count": self._sum_digest_source_count(all_daily_digests), + "digest_daily_count": len(all_daily_digests), + "digest_weekly_count": len(all_weekly_digests), + "digest_monthly_count": len(all_monthly_digests), "last_daily_digest_at": daily_digests[0].get("last_generated_at") if daily_digests else "", "last_weekly_digest_at": weekly_digests[0].get("last_generated_at") if weekly_digests else "", "last_monthly_digest_at": monthly_digests[0].get("last_generated_at") if monthly_digests else "", @@ -226,11 +231,15 @@ class MemberContextService: return context def refresh_member_context(self, chatroom_id: str, wxid: str, days: Optional[int] = None, - limit: Optional[int] = None) -> Dict: + limit: Optional[int] = None, enable_weekly_digest: bool = True, + enable_monthly_digest: bool = True) -> Dict: if not self.is_group_enabled(chatroom_id): raise ValueError(f"群 {chatroom_id} 未启用成员交互摘要功能") self.LOG.info(f"[成员交互摘要] 开始刷新单个成员: group={chatroom_id}, wxid={wxid}") - context = self.build_member_context(chatroom_id, wxid, days=days, limit=limit) + context = self.build_member_context( + chatroom_id, wxid, days=days, limit=limit, + enable_weekly_digest=enable_weekly_digest, enable_monthly_digest=enable_monthly_digest + ) self.member_context_db.save_member_context(context) self.LOG.info( f"[成员交互摘要] 单个成员刷新完成: group={chatroom_id}, wxid={wxid}, " @@ -244,7 +253,8 @@ class MemberContextService: return context def refresh_group_contexts(self, chatroom_id: str, days: Optional[int] = None, - limit_per_member: Optional[int] = None) -> Dict: + limit_per_member: Optional[int] = None, enable_weekly_digest: bool = True, + enable_monthly_digest: bool = True) -> Dict: days = days or self.sample_days limit_per_member = limit_per_member or self.refresh_limit_per_member @@ -321,7 +331,8 @@ class MemberContextService: ) continue context = self.build_member_context( - chatroom_id, wxid, days=days, limit=limit_per_member, ensure_group_daily=False + chatroom_id, wxid, days=days, limit=limit_per_member, ensure_group_daily=False, + enable_weekly_digest=enable_weekly_digest, enable_monthly_digest=enable_monthly_digest ) if context["source_message_count"] <= 0 and context.get("meta", {}).get("digest_daily_count", 0) <= 0: skipped += 1 @@ -350,7 +361,8 @@ class MemberContextService: ) return {"refreshed": refreshed, "skipped": skipped, "active_candidates": len(active_members)} - def refresh_all_chatrooms(self, days: Optional[int] = None, limit_per_member: Optional[int] = None) -> Dict: + def refresh_all_chatrooms(self, days: Optional[int] = None, limit_per_member: Optional[int] = None, + enable_weekly_digest: bool = True, enable_monthly_digest: bool = True) -> Dict: days = days or self.sample_days limit_per_member = limit_per_member or self.refresh_limit_per_member @@ -386,7 +398,13 @@ class MemberContextService: self.LOG.info( f"[成员交互摘要] 批量刷新进度: group_index={processed_groups}/{total_groups}, group={chatroom_id}" ) - result = self.refresh_group_contexts(chatroom_id, days=days, limit_per_member=limit_per_member) + result = self.refresh_group_contexts( + chatroom_id, + days=days, + limit_per_member=limit_per_member, + enable_weekly_digest=enable_weekly_digest, + enable_monthly_digest=enable_monthly_digest, + ) if result.get("disabled"): disabled += 1 continue diff --git a/utils/decorator/async_job.py b/utils/decorator/async_job.py index 4ef679a..fab315f 100644 --- a/utils/decorator/async_job.py +++ b/utils/decorator/async_job.py @@ -71,6 +71,70 @@ class AsyncJob: return decorator + def every_week_time(self, weekday: int, time_str: str): + """ + 每周 weekday(0=周一,6=周日) 的 time_str 时间执行 + """ + + def decorator(func: Callable): + async def wrapper(): + while True: + now = datetime.now() + target_time = datetime.strptime(time_str, "%H:%M").time() + + days_ahead = (weekday - now.weekday() + 7) % 7 + target_date = now.date() + timedelta(days=days_ahead) + target_dt = datetime.combine(target_date, target_time) + + if target_dt <= now: + target_dt += timedelta(days=7) + + sleep_secs = (target_dt - now).total_seconds() + await asyncio.sleep(sleep_secs) + await func() + + self.tasks.append(wrapper) + return func + + return decorator + + def every_month_last_day_time(self, time_str: str): + """ + 每月最后一天的 time_str 时间执行 + """ + + def decorator(func: Callable): + async def wrapper(): + while True: + now = datetime.now() + target_time = datetime.strptime(time_str, "%H:%M").time() + + if now.month == 12: + next_month = datetime(now.year + 1, 1, 1) + else: + next_month = datetime(now.year, now.month + 1, 1) + last_day = next_month - timedelta(days=1) + target_dt = datetime.combine(last_day.date(), target_time) + + if target_dt <= now: + if now.month == 12: + next_month = datetime(now.year + 1, 2, 1) + elif now.month == 11: + next_month = datetime(now.year + 1, 1, 1) + else: + next_month = datetime(now.year, now.month + 2, 1) + last_day = next_month - timedelta(days=1) + target_dt = datetime.combine(last_day.date(), target_time) + + sleep_secs = (target_dt - now).total_seconds() + await asyncio.sleep(sleep_secs) + await func() + + self.tasks.append(wrapper) + return func + + return decorator + async def run_all(self): await asyncio.gather(*(task() for task in self.tasks))