diff --git a/.openclaw/pmflow-routing-rules.json b/.openclaw/pmflow-routing-rules.json new file mode 100644 index 00000000..c509e59f --- /dev/null +++ b/.openclaw/pmflow-routing-rules.json @@ -0,0 +1,74 @@ +{ + "version": "v2", + "updated_at": "2026-03-05T00:00:00Z", + "source_of_truth": "openclaw", + "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "rules": [ + { + "name": "engine_low_execute_glm_flash", + "enabled": true, + "priority": 300, + "tier": "low", + "provider": "openai", + "model": "zai/glm-4-flash", + "match_any": [ + "feishu_level_classification", + "feishu_signal_extraction", + "rag_intent_classification", + "classify_intent", + "feishu_humanized_reply", + "extract_context_insights" + ] + }, + { + "name": "engine_high_reasoning_minimax", + "enabled": true, + "priority": 220, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "adaptive_rag_answer", + "feishu_project_prefill", + "feishu_task_orchestration", + "generate_task_breakdown", + "generate_charter_from_signal", + "decompose_tasks_from_charter", + "recommend_assignees_for_task", + "evaluate_model_requirement", + "calculate_alignment_score", + "detect_cognitive_conflicts", + "rag_db_project_semantic_retrieval", + "workload_optimize_prompt", + "analyze_cost_roi" + ] + }, + { + "name": "engine_high_extended_reasoning", + "enabled": true, + "priority": 200, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "analyze_food_calories", + "feishu_new_project_brief", + "feishu_project_signal_classification", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", + "skill_score_update", + "test_engine_connection" + ] + }, + { + "name": "engine_safety_fallback", + "enabled": true, + "priority": 50, + "tier": "safety", + "provider": "openai", + "model": "deepseek/deepseek-chat", + "match_all": true + } + ] +} diff --git a/ai.rtf b/ai.rtf new file mode 100644 index 00000000..c58974db --- /dev/null +++ b/ai.rtf @@ -0,0 +1,1237 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2867 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;\f1\fnil\fcharset134 PingFangSC-Regular;\f2\fnil\fcharset0 HelveticaNeue-Bold; +\f3\fnil\fcharset134 PingFangSC-Bold;\f4\fnil\fcharset0 .SFNS-Regular_wdth_opsz110000_GRAD_wght1F40000;\f5\fnil\fcharset134 PingFangSC-Medium; +} +{\colortbl;\red255\green255\blue255;\red26\green26\blue26;\red255\green255\blue255;\red0\green0\blue0; +\red0\green0\blue0;\red0\green0\blue0;} +{\*\expandedcolortbl;;\cssrgb\c13333\c13333\c13333;\cssrgb\c100000\c100000\c100000;\cssrgb\c0\c0\c0\c50196; +\cssrgb\c0\c0\c0;\cssrgb\c0\c0\c0\c30196;} +{\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid1\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid1} +{\list\listtemplateid2\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid101\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid102\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid2} +{\list\listtemplateid3\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid201\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid202\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid3} +{\list\listtemplateid4\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid301\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid302\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid4} +{\list\listtemplateid5\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid401\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid402\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid5} +{\list\listtemplateid6\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid501\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid502\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid6} +{\list\listtemplateid7\listhybrid{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{disc\}}{\leveltext\leveltemplateid601\'01\uc0\u8226 ;}{\levelnumbers;}\fi-360\li720\lin720 }{\listname ;}\listid7} +{\list\listtemplateid8\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid701\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid8} +{\list\listtemplateid9\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid801\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid9}} +{\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}{\listoverride\listid3\listoverridecount0\ls3}{\listoverride\listid4\listoverridecount0\ls4}{\listoverride\listid5\listoverridecount0\ls5}{\listoverride\listid6\listoverridecount0\ls6}{\listoverride\listid7\listoverridecount0\ls7}{\listoverride\listid8\listoverridecount0\ls8}{\listoverride\listid9\listoverridecount0\ls9}} +\paperw11900\paperh16840\margl1440\margr1440\vieww11520\viewh8400\viewkind0 +\deftab720 +\pard\pardeftab720\partightenfactor0 + +\f0\fs32 \cf2 \cb3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f1 \'ca\'b1\'b4\'fa +\f0 10 +\f1 \'c8\'cb\'bc\'bc\'ca\'f5\'cd\'c5\'b6\'d3\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'c6\'bd\'cc\'a8\'a3\'ba +\f0 AI +\f1 \'d4\'ad\'c9\'fa\'bc\'ab\'cb\'d9\'cf\'ec\'d3\'a6\'d0\'cd\'cd\'ea\'d5\'fb\'c2\'e4\'b5\'d8\'b7\'bd\'b0\'b8 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d5\'eb\'b6\'d4 +\f0 10 +\f1 \'c8\'cb\'b1\'e2\'c6\'bd\'bb\'af\'bc\'bc\'ca\'f5\'cd\'c5\'b6\'d3\'a3\'ac +\f0 AI +\f1 \'ca\'b1\'b4\'fa\'b5\'c4\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'ba\'cb\'d0\'c4\'d2\'aa\'b3\'b9\'b5\'d7\'cc\'f8\'b3\'f6\'b4\'ab\'cd\'b3\'a1\'b8\'b9\'dc\'bf\'d8\'d0\'cd\'b9\'a4\'be\'df\'a1\'b9\'b5\'c4\'c2\'df\'bc\'ad\'a3\'ac\'b4\'f2\'d4\'ec +\f0 ** +\f1 \'a1\'b8\'cd\'c5\'b6\'d3\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'d6\'d0\'ca\'e0 +\f0 + AI +\f1 \'c8\'ab\'c1\'b4\'c2\'b7\'cc\'e1\'d0\'a7\'d2\'fd\'c7\'e6 +\f0 + +\f1 \'ca\'d0\'b3\'a1 +\f0 - +\f1 \'bc\'bc\'ca\'f5\'bc\'ab\'cb\'d9\'b1\'d5\'bb\'b7\'d4\'d8\'cc\'e5\'a1\'b9 +\f0 **\'97\'97 +\f1 \'ba\'cb\'d0\'c4\'c4\'bf\'b1\'ea\'b2\'bb\'ca\'c7\'b9\'dc\'c8\'cb\'a1\'a2\'cc\'ee\'b1\'a8\'b1\'ed\'a3\'ac\'b6\'f8\'ca\'c7\'b3\'b9\'b5\'d7\'cf\'fb\'c3\'f0\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a2\'c1\'e3\'b6\'ee\'cd\'e2\'b8\'ba\'b5\'a3\'ca\'ca\'c5\'e4\'cd\'c5\'b6\'d3\'a1\'a2\'bc\'ab\'cb\'d9\'cf\'ec\'d3\'a6\'ca\'d0\'b3\'a1\'d3\'eb\'c4\'a3\'d0\'cd\'b5\'fc\'b4\'fa\'a1\'a2\'d3\'c3 +\f0 AI +\f1 \'cc\'e6\'b4\'fa +\f0 80% +\f1 \'d6\'d8\'b8\'b4\'b9\'dc\'c0\'ed\'b6\'af\'d7\'f7\'a3\'ac\'c8\'c3 +\f0 10 +\f1 \'c8\'cb\'d0\'a1\'cd\'c5\'b6\'d3\'ca\'cd\'b7\'c5\'b3\'f6\'d4\'b6\'b3\'ac\'b4\'ab\'cd\'b3\'c4\'a3\'ca\'bd\'b5\'c4\'bd\'bb\'b8\'b6\'d0\'a7\'c2\'ca\'d3\'eb\'ca\'d0\'b3\'a1\'d3\'a6\'b1\'e4\'c4\'dc\'c1\'a6\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d2\'bb\'a1\'a2\'cf\'c8\'b6\'a8\'b5\'d7\'b2\'e3\'ba\'cb\'d0\'c4\'d4\'ad\'d4\'f2\'a3\'a8 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'ca\'ca\'c5\'e4 +\f0 AI +\f1 \'ca\'b1\'b4\'fa\'b5\'c4\'b8\'f9\'b1\'be\'c7\'b0\'cc\'e1\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'ba\'cb\'d0\'c4\'d3\'c5\'ca\'c6\'ca\'c7\'b1\'e2\'c6\'bd\'bb\'af\'a1\'a2\'be\'f6\'b2\'df\'c1\'b4\'c2\'b7\'b6\'cc\'a1\'a2\'ca\'d4\'b4\'ed\'c1\'e9\'bb\'ee\'a3\'ac\'be\'f8\'b6\'d4\'b2\'bb\'c4\'dc\'d5\'d5\'b0\'e1\'b4\'f3\'b3\'a7\'d6\'d8\'c1\'f7\'b3\'cc\'a1\'a2\'d6\'d8\'c9\'f3\'c5\'fa\'b5\'c4\'c4\'a3\'ca\'bd\'a3\'ac\'cb\'f9\'d3\'d0\'c6\'bd\'cc\'a8\'c9\'e8\'bc\'c6\'b1\'d8\'d0\'eb\'d1\'cf\'b8\'f1\'d7\'f1\'d1\'ad +\f0 5 +\f1 \'b8\'f6\'cc\'fa\'d4\'f2\'a3\'ba +\f0 \cb1 \ +\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls1\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b5\'a5\'d4\'b4\'d5\'e6\'c0\'ed\'d4\'ad\'d4\'f2 +\f1\b0 \'a3\'ba\'cb\'f9\'d3\'d0\'cf\'ee\'c4\'bf\'cf\'e0\'b9\'d8\'d0\'c5\'cf\'a2\'a3\'a8\'d0\'e8\'c7\'f3\'a1\'a2\'c4\'a3\'d0\'cd\'ca\'fd\'be\'dd\'a1\'a2\'be\'f6\'b2\'df\'a1\'a2\'bd\'f8\'b6\'c8\'a1\'a2\'ce\'c4\'b5\'b5\'a1\'a2\'b8\'b4\'c5\'cc\'a3\'a9\'ce\'a8\'d2\'bb\'c8\'eb\'bf\'da\'a1\'a2\'ce\'a8\'d2\'bb\'ca\'fd\'be\'dd\'d4\'b4\'a3\'ac\'b3\'b9\'b5\'d7\'b6\'c5\'be\'f8\'d0\'c5\'cf\'a2\'c9\'a2\'c2\'e4\'d4\'da\'ce\'a2\'d0\'c5\'a1\'a2\'b7\'c9\'ca\'e9\'a1\'a2\'c0\'eb\'cf\'df\'ce\'c4\'b5\'b5\'a1\'a2\'b4\'fa\'c2\'eb\'b2\'d6\'bf\'e2\'a1\'a2\'bb\'e1\'d2\'e9\'c2\'bc\'d2\'f4\'c0\'ef\'a3\'ac\'b4\'d3\'b8\'f9\'d4\'b4\'cf\'fb\'c3\'f0\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\ls1\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f3 \'d4\'ad\'c9\'fa\'b6\'f8\'b7\'c7 +\f2 AI +\f3 \'b8\'bd\'bc\'d3\'d4\'ad\'d4\'f2 +\f1\b0 \'a3\'ba\'b2\'bb\'ca\'c7\'d4\'da\'b4\'ab\'cd\'b3\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'c0\'ef\'bc\'d3\'b8\'f6\'c1\'c4\'cc\'ec\'bf\'f2\'a3\'ac\'b6\'f8\'ca\'c7 +\f0 AI +\f1 \'b9\'e1\'b4\'a9\'a1\'b8\'d0\'e8\'c7\'f3\'ca\'e4\'c8\'eb +\f0 - +\f1 \'c8\'ce\'ce\'f1\'b2\'f0\'bd\'e2 +\f0 - +\f1 \'d6\'b4\'d0\'d0\'b6\'d4\'c6\'eb +\f0 - +\f1 \'b7\'e7\'cf\'d5\'d4\'a4\'be\'af +\f0 - +\f1 \'b8\'b4\'c5\'cc\'b3\'c1\'b5\'ed\'a1\'b9\'c8\'ab\'c1\'b4\'c2\'b7\'a3\'ac\'ba\'cb\'d0\'c4\'ca\'c7\'bc\'f5\'c9\'d9\'cd\'c5\'b6\'d3\'b9\'a4\'d7\'f7\'c1\'bf\'a3\'ac\'b6\'f8\'b7\'c7\'d4\'f6\'bc\'d3\'cc\'ee\'b1\'a8\'b8\'ba\'b5\'a3\'a1\'a3 +\f0 \cb1 \ +\ls1\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bc\'ab\'cb\'d9\'b1\'d5\'bb\'b7\'d4\'ad\'d4\'f2 +\f1\b0 \'a3\'ba\'b4\'f2\'cd\'a8\'a1\'b8\'ca\'d0\'b3\'a1\'b1\'e4\'bb\'af +\f0 / +\f1 \'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2 +\f0 \uc0\u8594 +\f1 \'d0\'e8\'c7\'f3\'c6\'c0\'b9\'c0 +\f0 \uc0\u8594 +\f1 \'be\'f6\'b2\'df\'c2\'e4\'b5\'d8 +\f0 \uc0\u8594 +\f1 \'d0\'a7\'b9\'fb\'b7\'b4\'c0\'a1\'a1\'b9\'c8\'ab\'c1\'b4\'c2\'b7\'a3\'ac\'c5\'d7\'c6\'fa\'b9\'cc\'b6\'a8\'b5\'fc\'b4\'fa\'b5\'c4\'ca\'f8\'b8\'bf\'a3\'ac\'ca\'b5\'cf\'d6\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'b4\'d3\'c2\'bc\'c8\'eb\'b5\'bd\'c2\'e4\'b5\'d8\'b5\'c4\'d7\'ee\'b6\'cc\'c1\'b4\'c2\'b7\'a3\'ac\'ca\'ca\'c5\'e4 +\f0 AI +\f1 \'d0\'d0\'d2\'b5\'d6\'dc\'bc\'b6\'c9\'f5\'d6\'c1\'c8\'d5\'bc\'b6\'b5\'c4\'b5\'fc\'b4\'fa\'bd\'da\'d7\'e0\'a1\'a3 +\f0 \cb1 \ +\ls1\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b6\'d4\'c6\'eb\'d3\'c5\'cf\'c8\'a1\'a2\'b9\'dc\'bf\'d8\'ba\'f3\'d6\'c3\'d4\'ad\'d4\'f2 +\f1\b0 \'a3\'ba\'cb\'f9\'d3\'d0\'c1\'f7\'b3\'cc\'c9\'e8\'bc\'c6\'b5\'c4\'ba\'cb\'d0\'c4\'ca\'c7\'a1\'b8\'c8\'ab\'d4\'b1\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'a1\'b9\'a3\'ac\'b6\'f8\'b7\'c7\'a1\'b8\'bd\'f8\'b6\'c8\'b9\'dc\'bf\'d8\'a1\'b9\'a1\'a3 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b2\'bb\'c5\'c2\'bd\'f8\'b6\'c8\'c2\'fd\'a3\'ac\'d7\'ee\'c5\'c2\'c8\'ab\'d4\'b1\'b6\'d4\'c4\'bf\'b1\'ea\'a1\'a2\'d0\'e8\'c7\'f3\'a1\'a2\'c4\'a3\'d0\'cd\'c4\'dc\'c1\'a6\'a1\'a2\'ca\'d0\'b3\'a1\'b1\'e4\'bb\'af\'b5\'c4\'c8\'cf\'d6\'aa\'b2\'bb\'d2\'bb\'d6\'c2\'a3\'ac\'b5\'bc\'d6\'c2\'b7\'b5\'b9\'a4\'a1\'a2\'c4\'da\'ba\'c4\'a1\'a2\'b7\'bd\'cf\'f2\'c5\'dc\'c6\'ab\'a1\'a3 +\f0 \cb1 \ +\ls1\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c1\'e3\'b8\'ba\'b5\'a3\'d4\'ad\'d4\'f2 +\f1\b0 \'a3\'ba\'c4\'dc\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'b5\'c4\'be\'f8\'b2\'bb\'ca\'d6\'b6\'af\'cc\'ee\'d0\'b4\'a3\'ac\'c4\'dc\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'c4\'be\'f8\'b2\'bb\'d6\'d8\'b8\'b4\'c2\'bc\'c8\'eb\'a3\'ac\'c4\'dc\'d2\'ec\'b2\'bd\'cd\'ea\'b3\'c9\'b5\'c4\'be\'f8\'b2\'bb\'d5\'bc\'d3\'c3\'bf\'aa\'bb\'e1\'ca\'b1\'bc\'e4\'a1\'a3\'c6\'bd\'cc\'a8\'b1\'d8\'d0\'eb\'ca\'c7\'b8\'f8\'cd\'c5\'b6\'d3\'bc\'f5\'b8\'ba\'a3\'ac\'b6\'f8\'b7\'c7\'b3\'c9\'ce\'aa\'d0\'c2\'b5\'c4\'b9\'dc\'c0\'ed +\f0 KPI +\f1 \'a1\'a3 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b6\'fe\'a1\'a2\'c6\'bd\'cc\'a8\'ba\'cb\'d0\'c4\'bc\'dc\'b9\'b9\'d3\'eb\'c8\'ab\'c1\'b4\'c2\'b7\'b9\'a6\'c4\'dc\'c9\'e8\'bc\'c6 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d5\'fb\'cc\'e5\'bc\'dc\'b9\'b9\'b2\'c9\'d3\'c3\'a1\'b8\'d2\'bb\'b2\'e3\'c8\'eb\'bf\'da\'a1\'a2\'c1\'bd\'b4\'f3\'d2\'fd\'c7\'e6\'a1\'a2\'c8\'fd\'c0\'e0\'ca\'fd\'be\'dd\'a1\'a2 +\f0 N +\f1 \'b8\'f6\'bc\'af\'b3\'c9\'a1\'b9\'b5\'c4\'bc\'ab\'bc\'f2\'c9\'e8\'bc\'c6\'a3\'ac\'b1\'dc\'c3\'e2\'b8\'b4\'d4\'d3\'bc\'dc\'b9\'b9\'b4\'f8\'c0\'b4\'b5\'c4\'d4\'cb\'ce\'ac\'d3\'eb\'ca\'b9\'d3\'c3\'b8\'ba\'b5\'a3\'a3\'ac\'cd\'ea\'c3\'c0\'ca\'ca\'c5\'e4 +\f0 2 +\f1 \'c8\'cb\'bf\'aa\'b7\'a2\'ce\'ac\'bb\'a4\'b5\'c4\'c8\'cb\'c1\'a6\'c5\'e4\'d6\'c3\'a1\'a3 +\f0 \cb1 \ +\ + +\f1 \cb3 \'d2\'d1\'c9\'fa\'b3\'c9\'b4\'fa\'c2\'eb +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c4\'a3\'bf\'e9\'d2\'bb\'a3\'ba\'ca\'d0\'b3\'a1 +\f0 - +\f1 \'c4\'a3\'d0\'cd\'b1\'e4\'bb\'af\'bc\'ab\'cb\'d9\'cf\'ec\'d3\'a6\'d6\'d0\'ca\'e0\'a3\'a8\'bd\'e2\'be\'f6\'a1\'b8\'b8\'fa\'b2\'bb\'c9\'cf\'b1\'e4\'bb\'af\'a1\'a2\'cf\'ec\'d3\'a6\'c2\'fd\'a1\'b9\'b5\'c4\'ba\'cb\'d0\'c4\'cd\'b4\'b5\'e3\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \cb3 AI +\f1 \'ca\'b1\'b4\'fa\'cf\'ee\'c4\'bf\'b5\'c4\'d7\'ee\'b4\'f3\'b1\'e4\'c1\'bf\'a3\'ac\'d2\'bb\'ca\'c7\'ca\'d0\'b3\'a1\'d0\'e8\'c7\'f3 +\f0 / +\f1 \'be\'ba\'c6\'b7\'b6\'af\'d7\'f7\'b5\'c4\'bf\'ec\'cb\'d9\'b1\'e4\'bb\'af\'a3\'ac\'b6\'fe\'ca\'c7\'b4\'f3\'c4\'a3\'d0\'cd\'d6\'dc\'bc\'b6\'b5\'c4\'b0\'e6\'b1\'be\'b8\'fc\'d0\'c2\'b4\'f8\'c0\'b4\'b5\'c4\'c4\'dc\'c1\'a6\'d6\'d8\'b9\'b9\'a1\'a3\'b4\'ab\'cd\'b3\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'b4\'d3\'d0\'e8\'c7\'f3\'b7\'a2\'c6\'f0\'be\'cd\'c2\'fd\'c1\'cb\'a3\'ac\'d5\'e2\'b8\'f6\'c4\'a3\'bf\'e9\'d2\'aa\'d7\'f6\'b5\'c4\'ca\'c7\'b0\'d1\'cb\'f9\'d3\'d0\'d3\'b0\'cf\'ec\'cf\'ee\'c4\'bf\'b5\'c4\'a1\'b8\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'a1\'b9\'a3\'ac\'b1\'e4\'b3\'c9\'bf\'c9\'c2\'e4\'b5\'d8\'b5\'c4\'d6\'b4\'d0\'d0\'b6\'af\'d7\'f7\'a3\'ac\'ca\'b5\'cf\'d6\'d7\'ee\'bf\'ec\'cf\'ec\'d3\'a6\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'c9\'e8\'bc\'c6 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'ca\'b5\'ca\'b1\'b9\'e9\'bc\'af\'b3\'d8\'a3\'a8\'c8\'ab\'d7\'d4\'b6\'af\'bb\'af\'a3\'ac\'ce\'de\'d0\'e8\'ca\'d6\'b6\'af\'ce\'ac\'bb\'a4\'a3\'a9 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b4\'ee\'bd\'a8\'ce\'a8\'d2\'bb\'b5\'c4\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'c8\'eb\'bf\'da\'a3\'ac\'d7\'d4\'b6\'af\'d7\'a5\'c8\'a1 +\f0 + +\f1 \'c7\'e1\'c1\'bf\'bb\'af\'ca\'d6\'b6\'af\'c2\'bc\'c8\'eb\'a3\'ac\'b7\'d6\'c0\'e0\'b9\'dc\'c0\'ed\'cb\'f9\'d3\'d0\'d3\'b0\'cf\'ec\'cf\'ee\'c4\'bf\'b5\'c4\'b1\'e4\'c1\'bf\'a3\'ac\'c8\'ab\'d4\'b1\'bf\'c9\'bc\'fb\'a3\'ac\'b6\'c5\'be\'f8\'a1\'b8\'d6\'bb\'d3\'d0\'b2\'fa\'c6\'b7 +\f0 / +\f1 \'b8\'ba\'d4\'f0\'c8\'cb\'d6\'aa\'b5\'c0\'b1\'e4\'bb\'af\'a3\'ac\'d6\'b4\'d0\'d0\'b2\'e3\'d2\'bb\'ce\'de\'cb\'f9\'d6\'aa\'a1\'b9\'b5\'c4\'c8\'cf\'d6\'aa\'b2\'ee\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls2\ilvl1 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'d0\'c5\'ba\'c5 +\f1\b0 \'a3\'ba\'d7\'d4\'b6\'af\'bc\'e0\'bf\'d8\'d6\'f7\'c1\'f7\'bf\'aa\'d4\'b4 +\f0 / +\f1 \'b1\'d5\'d4\'b4\'b4\'f3\'c4\'a3\'d0\'cd\'a3\'a8 +\f0 Llama3 +\f1 \'a1\'a2 +\f0 Qwen2 +\f1 \'a1\'a2\'cd\'a8\'d2\'e5\'c7\'a7\'ce\'ca\'a1\'a2 +\f0 GPT +\f1 \'cf\'b5\'c1\'d0\'b5\'c8\'a3\'a9\'b5\'c4\'b0\'e6\'b1\'be\'b8\'fc\'d0\'c2\'a1\'a2 +\f0 API +\f1 \'b1\'e4\'b8\'fc\'a1\'a2\'c4\'dc\'c1\'a6\'c9\'fd\'bc\'b6 +\f0 / +\f1 \'bd\'b5\'bc\'b6\'a1\'a2\'bf\'aa\'d4\'b4\'d0\'c2\'c4\'a3\'d0\'cd\'b7\'a2\'b2\'bc\'a3\'ac\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'bd\'c6\'bd\'cc\'a8\'a3\'ac +\f0 AI +\f1 \'cd\'ac\'b2\'bd\'c9\'fa\'b3\'c9\'a1\'b8\'b6\'d4\'cf\'d6\'d3\'d0\'cf\'ee\'c4\'bf\'b5\'c4\'d3\'b0\'cf\'ec\'b7\'d6\'ce\'f6\'a1\'b9\'a3\'a8\'c0\'fd\'a3\'ba +\f0 Qwen2-7B +\f1 \'d0\'c2\'b0\'e6\'b1\'be\'d6\'a7\'b3\'d6 +\f0 128K +\f1 \'ce\'de\'cb\'f0\'c9\'cf\'cf\'c2\'ce\'c4\'a3\'ac\'ce\'d2\'c3\'c7\'b5\'c4\'b3\'a4\'ce\'c4\'b5\'b5\'b4\'a6\'c0\'ed\'b9\'a6\'c4\'dc\'bf\'c9\'d6\'d8\'b9\'b9\'a3\'ac\'bf\'aa\'b7\'a2\'d6\'dc\'c6\'da\'b4\'d3 +\f0 2 +\f1 \'d6\'dc\'cb\'f5\'b6\'cc\'d6\'c1 +\f0 3 +\f1 \'cc\'ec\'a3\'a9\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ca\'d0\'b3\'a1 +\f2 & +\f3 \'be\'ba\'c6\'b7\'d0\'c5\'ba\'c5 +\f1\b0 \'a3\'ba\'c7\'e1\'c1\'bf\'bb\'af\'c5\'c0\'b3\'e6\'d7\'a5\'c8\'a1 +\f0 + +\f1 \'c8\'ab\'d4\'b1\'ca\'d6\'b6\'af\'c2\'bc\'c8\'eb\'a3\'ac\'b8\'b2\'b8\'c7\'be\'ba\'c6\'b7\'d0\'c2\'b9\'a6\'c4\'dc\'a1\'a2\'bf\'cd\'bb\'a7\'ba\'cb\'d0\'c4\'b7\'b4\'c0\'a1\'a1\'a2\'d0\'d0\'d2\'b5\'d5\'fe\'b2\'df\'b1\'e4\'bb\'af\'a1\'a2\'ca\'d0\'b3\'a1\'d0\'c2\'bb\'fa\'bb\'e1\'a3\'ac\'c3\'bf\'cc\'f5\'d0\'c5\'ba\'c5\'bf\'c9\'b1\'ea\'bc\'c7\'d3\'c5\'cf\'c8\'bc\'b6\'a1\'a2\'b9\'d8\'c1\'aa\'cf\'ee\'c4\'bf\'a1\'a2\'b8\'ba\'d4\'f0\'c8\'cb\'a3\'ac\'c8\'ab\'d4\'b1\'bf\'c9\'c6\'c0\'c2\'db\'b2\'b9\'b3\'e4\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c4\'da\'b2\'bf\'bc\'bc\'ca\'f5\'d0\'c5\'ba\'c5 +\f1\b0 \'a3\'ba\'ba\'cd\'cb\'e3\'b7\'a8\'d4\'a4\'d1\'d0\'a1\'a2\'c4\'a3\'d0\'cd\'ce\'a2\'b5\'f7\'c6\'bd\'cc\'a8\'b4\'f2\'cd\'a8\'a3\'ac\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'cd\'c5\'b6\'d3\'c4\'da\'b2\'bf\'b5\'c4\'bc\'bc\'ca\'f5\'cd\'bb\'c6\'c6\'a1\'a2\'c4\'a3\'d0\'cd\'d3\'c5\'bb\'af\'b3\'c9\'b9\'fb\'a1\'a2\'b2\'c8\'bf\'d3\'be\'ad\'d1\'e9\'a3\'a8\'c0\'fd\'a3\'ba\'ce\'d2\'c3\'c7\'ce\'a2\'b5\'f7\'b5\'c4\'b4\'b9\'d6\'b1\'c4\'a3\'d0\'cd\'d7\'bc\'c8\'b7\'c2\'ca\'cc\'e1\'c9\'fd +\f0 15% +\f1 \'a3\'ac\'bf\'c9\'c2\'e4\'b5\'d8\'b5\'bd +\f0 XX +\f1 \'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'a3\'a9\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 15 +\f3 \'b7\'d6\'d6\'d3\'bc\'ab\'cb\'d9\'be\'f6\'b2\'df\'c1\'f7\'a3\'a8\'ca\'ca\'c5\'e4 +\f2 10 +\f3 \'c8\'cb\'b1\'e2\'c6\'bd\'bb\'af\'cd\'c5\'b6\'d3\'a3\'ac\'ce\'de\'c8\'df\'d3\'e0\'c9\'f3\'c5\'fa\'a3\'a9 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b3\'b9\'b5\'d7\'c5\'d7\'c6\'fa\'a1\'b8\'d0\'e8\'c7\'f3\'c6\'c0\'c9\'f3 +\f0 \uc0\u8594 +\f1 \'b2\'e3\'b2\'e3\'c9\'f3\'c5\'fa +\f0 \uc0\u8594 +\f1 \'c5\'c5\'c6\'da\'c2\'e4\'b5\'d8\'a1\'b9\'b5\'c4\'b3\'a4\'c1\'b4\'c2\'b7\'a3\'ac\'c9\'e8\'bc\'c6\'bc\'ab\'bc\'f2\'be\'f6\'b2\'df\'bb\'fa\'d6\'c6\'a3\'ac\'c8\'b7\'b1\'a3\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5 +\f0 24 +\f1 \'d0\'a1\'ca\'b1\'c4\'da\'cd\'ea\'b3\'c9\'c6\'c0\'b9\'c0\'d3\'eb\'be\'f6\'b2\'df\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls2\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'d0\'c5\'ba\'c5\'c2\'bc\'c8\'eb\'ba\'f3\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'cd\'ea\'b3\'c9\'b3\'f5\'c6\'c0\'a3\'ba\'b7\'d6\'ce\'f6\'d3\'b0\'cf\'ec\'b7\'b6\'ce\'a7\'a1\'a2\'c2\'e4\'b5\'d8\'ca\'d5\'d2\'e6\'a1\'a2\'bf\'aa\'b7\'a2\'b3\'c9\'b1\'be\'a1\'a2\'c7\'b1\'d4\'da\'b7\'e7\'cf\'d5\'a3\'ac\'c9\'fa\'b3\'c9 +\f0 3 +\f1 \'be\'e4\'bb\'b0\'c6\'c0\'b9\'c0\'bd\'e1\'c2\'db\'a3\'ac\'b8\'f8\'b3\'f6\'d3\'c5\'cf\'c8\'bc\'b6\'bd\'a8\'d2\'e9\'a3\'ac\'ce\'de\'d0\'e8\'b2\'fa\'c6\'b7\'be\'ad\'c0\'ed\'ca\'d6\'b6\'af\'d0\'b4\'b3\'a4\'c6\'aa\'d0\'e8\'c7\'f3\'ce\'c4\'b5\'b5\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b8\'ba\'d4\'f0\'c8\'cb\'d2\'bb\'bc\'fc\'b7\'a2\'c6\'f0\'a1\'b8\'bc\'ab\'cb\'d9\'b6\'d4\'c6\'eb\'a1\'b9\'a3\'ac\'cf\'e0\'b9\'d8\'c8\'cb\'a3\'a8\'b2\'fa\'c6\'b7\'a1\'a2\'bc\'bc\'ca\'f5\'a1\'a2\'cb\'e3\'b7\'a8\'ba\'cb\'d0\'c4\'c8\'cb\'d4\'b1\'a3\'a9\'d6\'b1\'bd\'d3\'d4\'da\'c6\'bd\'cc\'a8\'c4\'da\'d2\'ec\'b2\'bd\'c6\'c0\'c2\'db\'a1\'a2\'cd\'b6\'c6\'b1\'a1\'a2\'c8\'b7\'c8\'cf\'be\'f6\'b2\'df\'a3\'ac\'ce\'de\'d0\'e8\'b4\'d5\'ca\'b1\'bc\'e4\'bf\'aa\'bb\'e1\'a3\'bb\'d6\'d8\'b4\'f3\'b1\'e4\'bb\'af\'d6\'b1\'bd\'d3\'b7\'a2\'c6\'f0 +\f0 15 +\f1 \'b7\'d6\'d6\'d3\'cc\'da\'d1\'b6\'bb\'e1\'d2\'e9\'a3\'ac\'bb\'e1\'d2\'e9\'bc\'cd\'d2\'aa +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'a1\'a2\'cd\'ac\'b2\'bd\'b5\'bd\'c6\'bd\'cc\'a8\'a3\'ac\'be\'f6\'b2\'df\'bd\'e1\'b9\'fb\'d6\'b1\'bd\'d3\'d7\'aa\'ce\'aa\'d0\'e8\'c7\'f3 +\f0 / +\f1 \'c8\'ce\'ce\'f1\'a3\'ac\'d7\'d4\'b6\'af\'bd\'f8\'c8\'eb\'b5\'fc\'b4\'fa\'b3\'d8\'a3\'ac\'c8\'ab\'b3\'cc\'ce\'de\'bf\'e7\'b9\'a4\'be\'df\'b8\'b4\'d6\'c6\'d5\'b3\'cc\'f9\'a3\'ac\'ce\'de\'d0\'c5\'cf\'a2\'cb\'f0\'ba\'c4\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f6\'b2\'df\'c8\'ab\'c1\'b4\'c2\'b7\'bf\'c9\'d7\'b7\'cb\'dd\'a3\'ba\'cb\'ad\'cc\'e1\'b5\'c4\'d0\'c5\'ba\'c5\'a1\'a2\'cb\'ad\'d7\'f6\'b5\'c4\'c6\'c0\'b9\'c0\'a1\'a2\'be\'f6\'b2\'df\'c4\'da\'c8\'dd\'a1\'a2\'be\'f6\'b2\'df\'d4\'ad\'d2\'f2\'a1\'a2\'c2\'e4\'b5\'d8\'ca\'b1\'bc\'e4\'a3\'ac\'c8\'ab\'b2\'bf\'c1\'f4\'b4\'e6\'a3\'ac\'c8\'ab\'d4\'b1\'bf\'c9\'b2\'e9\'a3\'ac\'b3\'b9\'b5\'d7\'b6\'c5\'be\'f8\'a1\'b8\'b5\'b1\'ca\'b1\'cb\'ad\'cb\'b5\'b5\'c4\'a3\'bf\'a1\'b9\'a1\'b8\'ce\'aa\'ca\'b2\'c3\'b4\'d2\'aa\'d7\'f6\'d5\'e2\'b8\'f6\'d0\'e8\'c7\'f3\'a3\'bf\'a1\'b9\'b5\'c4\'b3\'b6\'c6\'a4\'d3\'eb\'c8\'cf\'d6\'aa\'c6\'ab\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b9\'f6\'b6\'af\'ca\'bd\'b6\'af\'cc\'ac\'b5\'fc\'b4\'fa\'b3\'d8\'a3\'a8\'c5\'d7\'c6\'fa\'b9\'cc\'b6\'a8\'b5\'fc\'b4\'fa\'a3\'ac\'ca\'ca\'c5\'e4\'bc\'ab\'cb\'d9\'b1\'e4\'bb\'af\'a3\'a9 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b4\'ab\'cd\'b3 +\f0 2 +\f1 \'d6\'dc\'b9\'cc\'b6\'a8\'b5\'fc\'b4\'fa\'a3\'ac\'cd\'ea\'c8\'ab\'b8\'fa\'b2\'bb\'c9\'cf +\f0 AI +\f1 \'d0\'d0\'d2\'b5\'b5\'c4\'b1\'e4\'bb\'af\'bd\'da\'d7\'e0\'a3\'ac\'b8\'c4\'ce\'aa\'a1\'b8\'d3\'c5\'cf\'c8\'bc\'b6\'c7\'fd\'b6\'af\'b5\'c4\'b9\'f6\'b6\'af\'ca\'bd\'b5\'fc\'b4\'fa\'a1\'b9\'a3\'ac\'bc\'c8\'b1\'a3\'d6\'a4\'ba\'cb\'d0\'c4\'c4\'bf\'b1\'ea\'b2\'bb\'c5\'dc\'c6\'ab\'a3\'ac\'d3\'d6\'c4\'dc\'c1\'e9\'bb\'ee\'cf\'ec\'d3\'a6\'b1\'e4\'bb\'af\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls2\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b5\'fc\'b4\'fa\'b3\'d8\'bd\'f6\'c9\'e8 +\f0 4 +\f1 \'b8\'f6\'d3\'c5\'cf\'c8\'bc\'b6\'a3\'ac\'bc\'ab\'bc\'f2\'ce\'de\'b8\'ba\'b5\'a3\'a3\'ba +\f0 P0 +\f1 \'a3\'a8\'b1\'be\'d6\'dc\'b1\'d8\'d0\'eb\'c9\'cf\'cf\'df\'a3\'a9\'a1\'a2 +\f0 P1 +\f1 \'a3\'a8\'b1\'be\'d6\'dc +\f0 - +\f1 \'cf\'c2\'d6\'dc\'c9\'cf\'cf\'df\'a3\'a9\'a1\'a2 +\f0 P2 +\f1 \'a3\'a8 +\f0 2 +\f1 \'d6\'dc\'c4\'da\'c2\'e4\'b5\'d8\'a3\'a9\'a1\'a2 +\f0 P3 +\f1 \'a3\'a8\'b4\'fd\'c6\'c0\'b9\'c0\'a3\'a9\'a3\'ac\'cb\'f9\'d3\'d0\'d0\'e8\'c7\'f3 +\f0 / +\f1 \'c8\'ce\'ce\'f1\'b1\'d8\'d0\'eb\'b9\'d8\'c1\'aa\'d3\'c5\'cf\'c8\'bc\'b6\'d3\'eb\'b1\'b1\'bc\'ab\'d0\'c7\'c4\'bf\'b1\'ea\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b6\'af\'cc\'ac\'c5\'c5\'c6\'da\'d7\'d4\'b6\'af\'b5\'f7\'d5\'fb\'a3\'ba +\f0 AI +\f1 \'b8\'f9\'be\'dd\'d0\'e8\'c7\'f3\'d3\'c5\'cf\'c8\'bc\'b6\'a1\'a2\'cd\'c5\'b6\'d3\'c8\'cb\'c1\'a6\'b8\'ba\'d4\'d8\'a1\'a2\'c8\'ce\'ce\'f1\'d2\'c0\'c0\'b5\'b9\'d8\'cf\'b5\'a3\'ac\'d7\'d4\'b6\'af\'b5\'f7\'d5\'fb\'c5\'c5\'c6\'da\'a3\'ac\'d0\'c2\'d4\'f6\'b8\'df\'d3\'c5\'cf\'c8\'bc\'b6\'d0\'e8\'c7\'f3\'ca\'b1\'a3\'ac\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'cd\'d3\'c5\'cf\'c8\'bc\'b6\'d0\'e8\'c7\'f3\'b5\'c4\'c5\'c5\'c6\'da\'b1\'e4\'bb\'af\'a3\'ac\'c8\'ab\'d4\'b1\'ca\'b5\'ca\'b1\'bf\'c9\'bc\'fb\'a3\'ac\'ce\'de\'d0\'e8\'cf\'ee\'c4\'bf\'be\'ad\'c0\'ed\'b0\'a4\'b8\'f6\'cd\'a8\'d6\'aa\'a1\'a2\'ca\'d6\'b6\'af\'b8\'c4\'c5\'c5\'c6\'da\'a1\'a3 +\f0 \cb1 \ +\ls2\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c8\'ab\'be\'d6\'b5\'fc\'b4\'fa\'bf\'b4\'b0\'e5\'a3\'ba\'d2\'bb\'d2\'b3\'bf\'b4\'c7\'e5\'cb\'f9\'d3\'d0\'d0\'e8\'c7\'f3\'b5\'c4\'d7\'b4\'cc\'ac\'a1\'a2\'b8\'ba\'d4\'f0\'c8\'cb\'a1\'a2\'bd\'d8\'d6\'b9\'ca\'b1\'bc\'e4\'a1\'a2\'b7\'e7\'cf\'d5\'c7\'e9\'bf\'f6\'a3\'ac\'b9\'dc\'c0\'ed\'d5\'df\'d2\'bb\'d1\'db\'d5\'c6\'ce\'d5\'c8\'ab\'c3\'b2\'a3\'ac\'cd\'c5\'b6\'d3\'b3\'c9\'d4\'b1\'d6\'bb\'bf\'b4\'d7\'d4\'bc\'ba\'b8\'ba\'d4\'f0\'b5\'c4\'c4\'da\'c8\'dd\'a3\'ac\'ce\'de\'d0\'c5\'cf\'a2\'b8\'c9\'c8\'c5\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 6 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c4\'a3\'bf\'e9\'b6\'fe\'a3\'ba +\f0 AI +\f1 \'c7\'fd\'b6\'af\'b5\'c4\'c8\'ab\'c1\'b4\'c2\'b7\'d6\'b4\'d0\'d0\'d3\'eb\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'cc\'e5\'cf\'b5\'a3\'a8\'bd\'e2\'be\'f6\'a1\'b8\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a2\'b8\'ba\'b5\'a3\'d6\'d8\'a1\'a2\'b9\'dc\'c0\'ed\'c4\'d1\'a1\'b9\'b5\'c4\'cd\'b4\'b5\'e3\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d5\'e2\'b8\'f6\'c4\'a3\'bf\'e9\'ca\'c7\'c6\'bd\'cc\'a8\'b5\'c4\'ba\'cb\'d0\'c4\'a3\'ac\'b3\'b9\'b5\'d7\'b5\'df\'b8\'b2\'b4\'ab\'cd\'b3\'a1\'b8\'d7\'d4\'c9\'cf\'b6\'f8\'cf\'c2\'b2\'f0\'bd\'e2\'c8\'ce\'ce\'f1\'a1\'a2\'d7\'d4\'cf\'c2\'b6\'f8\'c9\'cf\'bb\'e3\'b1\'a8\'bd\'f8\'b6\'c8\'a1\'b9\'b5\'c4\'c4\'a3\'ca\'bd\'a3\'ac\'d3\'c3 +\f0 AI +\f1 \'ca\'b5\'cf\'d6\'c8\'ab\'c1\'b4\'c2\'b7\'d7\'d4\'b6\'af\'bb\'af\'a3\'ac\'d3\'c3\'b5\'a5\'d4\'b4\'d0\'c5\'cf\'a2\'ca\'b5\'cf\'d6\'c8\'ab\'d4\'b1\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'b0\'d1\'be\'ab\'c1\'a6\'b4\'d3\'a1\'b8\'cc\'ee\'b1\'a8\'b1\'ed\'a1\'a2\'bf\'aa\'cd\'ac\'b2\'bd\'bb\'e1\'a1\'a2\'d5\'d2\'d0\'c5\'cf\'a2\'a1\'b9\'d7\'aa\'d2\'c6\'b5\'bd\'a1\'b8\'d0\'b4\'b4\'fa\'c2\'eb\'a1\'a2\'d7\'f6\'bd\'bb\'b8\'b6\'a1\'b9\'c9\'cf\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'c9\'e8\'bc\'c6 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b1\'b1\'bc\'ab\'d0\'c7\'c4\'bf\'b1\'ea\'b6\'d4\'c6\'eb\'c4\'a3\'bf\'e9\'a3\'a8\'b4\'d3\'b8\'f9\'d4\'b4\'cd\'b3\'d2\'bb\'c8\'ab\'d4\'b1\'c8\'cf\'d6\'aa\'a3\'a9 +\f0\b0 \cb1 \uc0\u8232 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'d7\'ee\'bc\'c9\'bb\'e4\'c4\'bf\'b1\'ea\'b7\'d6\'c9\'a2\'a1\'a2\'b8\'f7\'d7\'f6\'b8\'f7\'b5\'c4\'a3\'ac\'d3\'c3\'bc\'ab\'bc\'f2 +\f0 OKR +\f1 \'bb\'fa\'d6\'c6\'a3\'ac\'b0\'d1\'c4\'bf\'b1\'ea\'b6\'a4\'d4\'da\'c6\'bd\'cc\'a8\'d7\'ee\'cf\'d4\'d1\'db\'b5\'c4\'ce\'bb\'d6\'c3\'a3\'ac\'cb\'f9\'d3\'d0\'b6\'af\'d7\'f7\'b6\'bc\'ce\'a7\'c8\'c6\'c4\'bf\'b1\'ea\'d5\'b9\'bf\'aa\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls3\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c3\'bf\'b8\'f6\'cf\'ee\'c4\'bf\'bd\'f6\'c9\'e8 +\f0 1 +\f1 \'b8\'f6\'b1\'b1\'bc\'ab\'d0\'c7\'d6\'b8\'b1\'ea\'a3\'ac +\f0 3-5 +\f1 \'b8\'f6\'bf\'c9\'c1\'bf\'bb\'af\'b5\'c4\'b9\'d8\'bc\'fc\'bd\'e1\'b9\'fb\'a3\'a8 +\f0 KR +\f1 \'a3\'a9\'a3\'ac\'c8\'ab\'d4\'b1\'bf\'c9\'bc\'fb\'a1\'a2\'c8\'ab\'d4\'b1\'bf\'c9\'c6\'c0\'c2\'db\'a3\'ac\'c3\'bf\'b8\'f6\'c8\'ce\'ce\'f1 +\f0 / +\f1 \'d0\'e8\'c7\'f3\'b1\'d8\'d0\'eb\'b9\'d8\'c1\'aa\'b5\'bd\'b6\'d4\'d3\'a6\'b5\'c4 +\f0 KR +\f1 \'a3\'ac\'b4\'d3\'b8\'f9\'d4\'b4\'b6\'c5\'be\'f8\'a1\'b8\'d7\'f6\'ba\'cd\'c4\'bf\'b1\'ea\'ce\'de\'b9\'d8\'b5\'c4\'ca\'c2\'a1\'b9\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c4\'bf\'b1\'ea\'b6\'af\'cc\'ac\'bf\'c9\'b5\'f7\'a3\'ba\'ca\'d0\'b3\'a1 +\f0 / +\f1 \'c4\'a3\'d0\'cd\'b7\'a2\'c9\'fa\'d6\'d8\'b4\'f3\'b1\'e4\'bb\'af\'ca\'b1\'a3\'ac\'bf\'c9\'bf\'ec\'cb\'d9\'b5\'f7\'d5\'fb\'c4\'bf\'b1\'ea\'d3\'eb +\f0 KR +\f1 \'a3\'ac\'b5\'f7\'d5\'fb\'ba\'f3 +\f0 AI +\f1 \'d7\'d4\'b6\'af\'cd\'a8\'d6\'aa\'c8\'ab\'d4\'b1\'a3\'ac\'cd\'ac\'b2\'bd\'d6\'d8\'d0\'c2\'c6\'c0\'b9\'c0\'cb\'f9\'d3\'d0\'b9\'d8\'c1\'aa\'c8\'ce\'ce\'f1\'b5\'c4\'d3\'c5\'cf\'c8\'bc\'b6\'a3\'ac\'c8\'b7\'b1\'a3\'c8\'ab\'d4\'b1\'ca\'bc\'d6\'d5\'b3\'af\'d7\'c5\'cd\'ac\'d2\'bb\'b8\'f6\'b7\'bd\'cf\'f2\'d7\'df\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c3\'bf\'d6\'dc +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'c4\'bf\'b1\'ea\'bd\'f8\'b6\'c8\'b1\'a8\'b8\'e6\'a3\'ba\'cd\'ac\'b2\'bd\'b1\'b1\'bc\'ab\'d0\'c7\'c4\'bf\'b1\'ea\'cd\'ea\'b3\'c9\'c7\'e9\'bf\'f6\'a1\'a2\'c4\'c4\'d0\'a9\'c8\'ce\'ce\'f1\'b9\'b1\'cf\'d7\'c1\'cb\'bd\'f8\'b6\'c8\'a1\'a2\'c4\'c4\'d0\'a9\'cd\'cf\'c1\'cb\'ba\'f3\'cd\'c8\'a1\'a2\'c4\'c4\'d0\'a9\'cd\'e2\'b2\'bf\'b1\'e4\'bb\'af\'bf\'c9\'c4\'dc\'d3\'b0\'cf\'ec\'c4\'bf\'b1\'ea\'a3\'ac\'c8\'ab\'d4\'b1\'cd\'ac\'b2\'bd\'a3\'ac\'b3\'b9\'b5\'d7\'cf\'fb\'c3\'f0\'a1\'b8\'b2\'bb\'d6\'aa\'b5\'c0\'cf\'ee\'c4\'bf\'d7\'f6\'b5\'bd\'c4\'c4\'c1\'cb\'a1\'a2\'b2\'bb\'d6\'aa\'b5\'c0\'ce\'aa\'ca\'b2\'c3\'b4\'d7\'f6\'d5\'e2\'b8\'f6\'c8\'ce\'ce\'f1\'a1\'b9\'b5\'c4\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f3 \'d6\'c7\'c4\'dc\'c8\'ce\'ce\'f1\'b9\'dc\'c0\'ed\'a3\'a8\'c1\'e3\'ca\'d6\'b6\'af\'cc\'ee\'b1\'a8\'a3\'ac\'c8\'ab\'c1\'b4\'c2\'b7\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'a3\'a9 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b4\'ab\'cd\'b3\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'d7\'ee\'b4\'f3\'b5\'c4\'b8\'ba\'b5\'a3\'a3\'ac\'be\'cd\'ca\'c7\'c8\'c3\'bf\'aa\'b7\'a2\'c8\'cb\'d4\'b1\'ca\'d6\'b6\'af\'cc\'ee\'bd\'f8\'b6\'c8\'a1\'a2\'d0\'b4\'c8\'d5\'d6\'be\'a1\'a2\'bd\'a8\'c8\'ce\'ce\'f1\'a3\'ac\'d5\'e2\'b8\'f6\'c4\'a3\'bf\'e9\'b5\'c4\'ba\'cb\'d0\'c4\'ca\'c7\'a1\'b8 +\f0 AI +\f1 \'cc\'e6\'c4\'e3\'d7\'f6\'a3\'ac\'cf\'b5\'cd\'b3\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'a1\'b9\'a3\'ac\'cd\'ea\'c8\'ab\'b2\'bb\'d4\'f6\'bc\'d3\'cd\'c5\'b6\'d3\'b6\'ee\'cd\'e2\'b9\'a4\'d7\'f7\'c1\'bf\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls3\ilvl1 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f3 \'d2\'bb\'bc\'fc\'b2\'f0\'bd\'e2\'c8\'ce\'ce\'f1 +\f1\b0 \'a3\'ba\'d0\'e8\'c7\'f3 +\f0 / +\f1 \'be\'f6\'b2\'df\'c8\'b7\'c8\'cf\'ba\'f3\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'bb\'f9\'d3\'da\'d6\'aa\'ca\'b6\'bf\'e2\'d6\'d0\'b5\'c4\'c0\'fa\'ca\'b7\'cf\'ee\'c4\'bf\'ca\'fd\'be\'dd\'a1\'a2\'cd\'c5\'b6\'d3\'c4\'dc\'c1\'a6\'cd\'bc\'c6\'d7\'a1\'a2\'c4\'a3\'d0\'cd\'c4\'dc\'c1\'a6\'b1\'df\'bd\'e7\'a3\'ac\'b2\'f0\'bd\'e2\'ce\'aa\'bf\'c9\'d6\'b4\'d0\'d0\'b5\'c4\'d7\'ee\'d0\'a1\'c8\'ce\'ce\'f1\'b5\'a5\'d4\'aa\'a3\'ac\'d7\'d4\'b6\'af\'b7\'d6\'c5\'e4\'b8\'ba\'d4\'f0\'c8\'cb\'a1\'a2\'d4\'a4\'b9\'c0\'b9\'a4\'ca\'b1\'a1\'a2\'c9\'e8\'d6\'c3\'bd\'d8\'d6\'b9\'ca\'b1\'bc\'e4\'a1\'a2\'b1\'ea\'bc\'c7\'d2\'c0\'c0\'b5\'b9\'d8\'cf\'b5\'a3\'ac\'b8\'ba\'d4\'f0\'c8\'cb\'bd\'f6\'d0\'e8\'d2\'bb\'bc\'fc\'c8\'b7\'c8\'cf +\f0 / +\f1 \'ce\'a2\'b5\'f7\'a3\'ac\'ce\'de\'d0\'e8\'ca\'d6\'b6\'af\'bd\'a8\'c8\'ce\'ce\'f1\'a1\'a3 +\f0 \cb1 \uc0\u8232 +\f1 \cb3 \'c0\'fd\'a3\'ba\'b2\'fa\'c6\'b7\'cc\'e1\'a1\'b8\'bb\'f9\'d3\'da\'d0\'c2\'c4\'a3\'d0\'cd\'d6\'d8\'b9\'b9\'b3\'a4\'ce\'c4\'b5\'b5\'d7\'dc\'bd\'e1\'b9\'a6\'c4\'dc\'a1\'b9\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'b2\'f0\'bd\'e2\'ce\'aa\'a3\'ba\'cb\'e3\'b7\'a8\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'d3\'eb\'bc\'e6\'c8\'dd\'d0\'d4\'b2\'e2\'ca\'d4\'a1\'a2\'ba\'f3\'b6\'cb\'bd\'d3\'bf\'da\'bf\'aa\'b7\'a2\'a1\'a2\'c7\'b0\'b6\'cb\'bd\'bb\'bb\'a5\'d6\'d8\'b9\'b9\'a1\'a2\'b2\'e2\'ca\'d4\'d3\'c3\'c0\'fd\'b1\'e0\'d0\'b4\'a1\'a2\'bb\'d2\'b6\'c8\'c9\'cf\'cf\'df\'d1\'e9\'d6\'a4\'a3\'ac\'d7\'d4\'b6\'af\'b7\'d6\'c5\'e4\'b8\'f8\'b6\'d4\'d3\'a6\'bd\'c7\'c9\'ab\'c8\'cb\'d4\'b1\'a3\'ac\'cd\'ac\'ca\'b1\'b9\'d8\'c1\'aa\'d6\'aa\'ca\'b6\'bf\'e2\'d6\'d0\'b9\'fd\'cd\'f9\'b5\'c4\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'b2\'c8\'bf\'d3\'bc\'c7\'c2\'bc\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c8\'ce\'ce\'f1\'bd\'f8\'b6\'c8\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'a3\'ac\'c1\'e3\'ca\'d6\'b6\'af\'cc\'ee\'b1\'a8 +\f1\b0 \'a3\'ba\'ba\'cd +\f0 GitLab/Gitee +\f1 \'b4\'fa\'c2\'eb\'b2\'d6\'bf\'e2\'a1\'a2 +\f0 CI/CD +\f1 \'c6\'bd\'cc\'a8\'a1\'a2\'b2\'e2\'ca\'d4\'c6\'bd\'cc\'a8\'c9\'ee\'b6\'c8\'b4\'f2\'cd\'a8\'a3\'ac\'b4\'fa\'c2\'eb\'cc\'e1\'bd\'bb\'a1\'a2 +\f0 PR +\f1 \'ba\'cf\'b2\'a2\'a1\'a2\'b2\'e2\'ca\'d4\'cd\'a8\'b9\'fd\'a1\'a2\'c9\'cf\'cf\'df\'cd\'ea\'b3\'c9\'a3\'ac\'cf\'b5\'cd\'b3\'d7\'d4\'b6\'af\'b8\'fc\'d0\'c2\'c8\'ce\'ce\'f1\'bd\'f8\'b6\'c8\'a3\'ac\'ce\'de\'d0\'e8\'bf\'aa\'b7\'a2\'c8\'cb\'d4\'b1\'ca\'d6\'b6\'af\'d0\'de\'b8\'c4\'a1\'a3\'c0\'fd\'a3\'ba\'ba\'f3\'b6\'cb\'bf\'aa\'b7\'a2\'c8\'ce\'ce\'f1\'a3\'ac +\f0 PR +\f1 \'ba\'cf\'b2\'a2\'b5\'bd\'b2\'e2\'ca\'d4\'b7\'d6\'d6\'a7\'a3\'ac\'d7\'d4\'b6\'af\'b8\'fc\'d0\'c2\'bd\'f8\'b6\'c8\'d6\'c1 +\f0 80% +\f1 \'a3\'bb\'b2\'e2\'ca\'d4\'d3\'c3\'c0\'fd\'c8\'ab\'b2\'bf\'cd\'a8\'b9\'fd\'a3\'ac\'d7\'d4\'b6\'af\'b8\'fc\'d0\'c2\'ce\'aa +\f0 100% +\f1 \'cd\'ea\'b3\'c9\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b5\'a5\'c8\'ce\'ce\'f1\'c8\'ab\'d0\'c5\'cf\'a2\'b9\'e9\'bc\'af\'a3\'ac\'cf\'fb\'c3\'f0\'d0\'c5\'cf\'a2\'b2\'ee +\f1\b0 \'a3\'ba\'c3\'bf\'b8\'f6\'c8\'ce\'ce\'f1\'d2\'b3\'c3\'e6\'a3\'ac\'d7\'d4\'b6\'af\'be\'db\'ba\'cf\'cb\'f9\'d3\'d0\'cf\'e0\'b9\'d8\'d0\'c5\'cf\'a2\'a3\'ba\'d0\'e8\'c7\'f3\'b1\'b3\'be\'b0\'a1\'a2\'b9\'d8\'c1\'aa +\f0 KR +\f1 \'a1\'a2\'c4\'a3\'d0\'cd\'b2\'ce\'ca\'fd\'a1\'a2\'c9\'e8\'bc\'c6\'b8\'e5\'a1\'a2\'b4\'fa\'c2\'eb\'c1\'b4\'bd\'d3\'a1\'a2\'b2\'e2\'ca\'d4\'b1\'a8\'b8\'e6\'a1\'a2\'c6\'c0\'c2\'db\'b9\'b5\'cd\'a8\'a1\'a2\'c0\'fa\'ca\'b7\'b1\'e4\'b8\'fc\'a3\'ac\'b5\'e3\'bd\'f8\'c8\'a5\'be\'cd\'d5\'c6\'ce\'d5\'c8\'ab\'b2\'bf\'c9\'cf\'cf\'c2\'ce\'c4\'a3\'ac\'ce\'de\'d0\'e8\'b5\'bd\'b4\'a6\'b7\'ad\'c1\'c4\'cc\'ec\'bc\'c7\'c2\'bc\'a1\'a2\'d5\'d2\'c0\'eb\'cf\'df\'ce\'c4\'b5\'b5\'a3\'ac\'d0\'c2\'c8\'cb\'bd\'d3\'ca\'d6\'c8\'ce\'ce\'f1 +\f0 10 +\f1 \'b7\'d6\'d6\'d3\'be\'cd\'c4\'dc\'d5\'c6\'ce\'d5\'c8\'ab\'c3\'b2\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c7\'e1\'c1\'bf\'bb\'af\'b6\'d4\'c6\'eb\'bb\'fa\'d6\'c6\'a3\'ac\'b0\'d1\'bb\'e1\'d2\'e9\'ca\'b1\'bc\'e4\'d1\'b9\'cb\'f5 +\f2 80% +\f0\b0 \cb1 \uc0\u8232 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'b9\'b5\'cd\'a8\'a3\'ac\'ba\'cb\'d0\'c4\'ca\'c7\'a1\'b8\'d2\'ec\'b2\'bd\'d3\'c5\'cf\'c8\'a1\'a2\'cd\'ac\'b2\'bd\'ce\'aa\'b8\'a8\'a1\'b9\'a3\'ac\'d3\'c3\'c6\'bd\'cc\'a8\'cc\'e6\'b4\'fa\'ce\'de\'d0\'a7\'bb\'e1\'d2\'e9\'a3\'ac\'ca\'b5\'cf\'d6\'b3\'a3\'cc\'ac\'bb\'af\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls3\ilvl1 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c3\'bf\'c8\'d5 +\f2 3 +\f3 \'be\'e4\'bb\'b0\'d5\'be\'bb\'e1\'a3\'ac\'cc\'e6\'b4\'fa +\f2 15 +\f3 \'b7\'d6\'d6\'d3\'b3\'bf\'bb\'e1 +\f1\b0 \'a3\'ba\'c3\'bf\'b8\'f6\'c8\'cb\'c3\'bf\'cc\'ec\'d4\'e7\'c9\'cf\'bb\'a8 +\f0 3 +\f1 \'b7\'d6\'d6\'d3\'a3\'ac\'d4\'da\'c6\'bd\'cc\'a8\'c9\'cf\'cc\'ee\'d0\'b4 +\f0 3 +\f1 \'be\'e4\'bb\'b0\'a3\'ba\'d7\'f2\'cc\'ec\'cd\'ea\'b3\'c9\'c1\'cb\'ca\'b2\'c3\'b4\'a1\'a2\'bd\'f1\'cc\'ec\'bc\'c6\'bb\'ae\'d7\'f6\'ca\'b2\'c3\'b4\'a1\'a2\'d3\'f6\'b5\'bd\'b5\'c4\'b7\'e7\'cf\'d5 +\f0 / +\f1 \'d0\'e8\'d2\'aa\'b5\'c4\'d0\'ad\'d6\'fa\'a1\'a3 +\f0 AI +\f1 \'d7\'d4\'b6\'af\'bb\'e3\'d7\'dc\'c9\'fa\'b3\'c9\'c8\'ab\'d4\'b1\'d5\'be\'bb\'e1\'b1\'a8\'b8\'e6\'a3\'ac\'cd\'c6\'cb\'cd\'b5\'bd\'cd\'c5\'b6\'d3\'c8\'ba\'a3\'ac\'d3\'d0\'ce\'ca\'cc\'e2\'d6\'b1\'bd\'d3\'d4\'da\'b6\'d4\'d3\'a6\'c8\'ce\'ce\'f1\'cf\'c2\'c6\'c0\'c2\'db\'b9\'b5\'cd\'a8\'a3\'ac\'d2\'ec\'b2\'bd\'bd\'e2\'be\'f6\'a3\'ac\'ce\'de\'d0\'e8\'c8\'ab\'d4\'b1\'b4\'d5\'ca\'b1\'bc\'e4\'bf\'aa\'bb\'e1\'a3\'ac\'b3\'b9\'b5\'d7\'b6\'c5\'be\'f8\'a1\'b8\'d2\'bb\'c8\'cb\'bb\'e3\'b1\'a8\'a1\'a2\'be\'c5\'c8\'cb\'c3\'fe\'d3\'e3\'a1\'b9\'b5\'c4\'ce\'de\'d0\'a7\'bb\'e1\'d2\'e9\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b7\'e7\'cf\'d5\'ca\'b5\'ca\'b1\'d4\'a4\'be\'af\'d3\'eb\'cd\'ac\'b2\'bd +\f1\b0 \'a3\'ba +\f0 AI7\'d724 +\f1 \'d0\'a1\'ca\'b1\'d7\'d4\'b6\'af\'ca\'b6\'b1\'f0\'cf\'ee\'c4\'bf\'b7\'e7\'cf\'d5\'a3\'ba\'c8\'ce\'ce\'f1\'d1\'d3\'c6\'da\'a1\'a2\'d2\'c0\'c0\'b5\'ce\'b4\'cd\'ea\'b3\'c9\'a1\'a2\'c4\'a3\'d0\'cd\'c4\'dc\'c1\'a6\'b2\'bb\'b4\'ef\'b1\'ea\'a1\'a2\'cd\'c5\'b6\'d3\'b8\'ba\'d4\'d8\'b9\'fd\'d4\'d8\'a1\'a2\'d0\'e8\'c7\'f3\'c6\'b5\'b7\'b1\'b1\'e4\'b8\'fc\'a3\'ac\'d7\'d4\'b6\'af\'b1\'ea\'bc\'c7\'b7\'e7\'cf\'d5\'b5\'c8\'bc\'b6\'a3\'ac\'cd\'c6\'cb\'cd\'b8\'f8\'b8\'ba\'d4\'f0\'c8\'cb\'d3\'eb\'cf\'e0\'b9\'d8\'c8\'cb\'d4\'b1\'a3\'ac\'cd\'ac\'ca\'b1\'d4\'da\'c8\'ab\'be\'d6\'b7\'e7\'cf\'d5\'bf\'b4\'b0\'e5\'b8\'df\'c1\'c1\'d5\'b9\'ca\'be\'a1\'a3\'b9\'dc\'c0\'ed\'d5\'df\'cc\'e1\'c7\'b0\'b7\'a2\'cf\'d6\'ce\'ca\'cc\'e2\'a1\'a2\'cc\'e1\'c7\'b0\'bd\'e2\'be\'f6\'a3\'ac\'b2\'bb\'d3\'c3\'b5\'c8\'cf\'ee\'c4\'bf\'d1\'d3\'c6\'da\'c1\'cb\'b2\'c5\'ba\'f3\'d6\'aa\'ba\'f3\'be\'f5\'a1\'a3 +\f0 \cb1 \ +\ls3\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f6\'b2\'df\'c8\'d5\'d6\'be\'c8\'ab\'c1\'f4\'b4\'e6 +\f1\b0 \'a3\'ba\'cb\'f9\'d3\'d0\'cf\'ee\'c4\'bf\'cf\'e0\'b9\'d8\'b5\'c4\'be\'f6\'b2\'df\'a3\'ac\'b2\'bb\'b9\'dc\'ca\'c7\'bb\'e1\'c9\'cf\'b6\'a8\'b5\'c4\'a1\'a2\'bb\'b9\'ca\'c7\'c8\'ba\'c0\'ef\'b9\'b5\'cd\'a8\'b6\'a8\'b5\'c4\'a3\'ac\'c8\'ab\'b2\'bf\'c7\'bf\'d6\'c6\'c2\'bc\'c8\'eb\'c6\'bd\'cc\'a8\'be\'f6\'b2\'df\'c8\'d5\'d6\'be\'a3\'ac\'b9\'d8\'c1\'aa\'b6\'d4\'d3\'a6\'cf\'ee\'c4\'bf +\f0 / +\f1 \'c8\'ce\'ce\'f1\'a3\'ac\'b1\'ea\'bc\'c7\'be\'f6\'b2\'df\'c8\'cb\'a1\'a2\'be\'f6\'b2\'df\'ca\'b1\'bc\'e4\'a1\'a2\'be\'f6\'b2\'df\'c4\'da\'c8\'dd\'a1\'a2\'ba\'cb\'d0\'c4\'d4\'ad\'d2\'f2\'a3\'ac\'c8\'ab\'d4\'b1\'bf\'c9\'b2\'e9\'a1\'a2\'d3\'c0\'be\'c3\'c1\'f4\'b4\'e6\'a1\'a3\'b3\'b9\'b5\'d7\'b6\'c5\'be\'f8\'a1\'b8\'bb\'e1\'d2\'e9\'b6\'a8\'c1\'cb\'b5\'c4\'ca\'c2\'d7\'aa\'cd\'b7\'be\'cd\'cd\'fc\'a1\'b9\'a1\'b8\'b2\'bb\'cd\'ac\'c8\'cb\'b6\'d4\'be\'f6\'b2\'df\'b5\'c4\'c0\'ed\'bd\'e2\'b2\'bb\'d2\'bb\'d1\'f9\'a1\'b9\'b5\'c4\'ba\'cb\'d0\'c4\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls3\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 6 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c4\'a3\'bf\'e9\'c8\'fd\'a3\'ba\'cf\'ee\'c4\'bf\'b0\'f3\'b6\'a8\'b5\'c4 +\f0 AI +\f1 \'d4\'ad\'c9\'fa\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'a8\'bd\'e2\'be\'f6\'a1\'b8\'c4\'a3\'d0\'cd\'b5\'fc\'b4\'fa\'bf\'ec\'a1\'a2\'be\'ad\'d1\'e9\'b2\'bb\'b3\'c1\'b5\'ed\'a1\'a2\'c8\'cf\'d6\'aa\'b2\'bb\'cd\'b3\'d2\'bb\'a1\'b9\'b5\'c4\'cd\'b4\'b5\'e3\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d6\'aa\'ca\'b6\'bf\'e2\'b2\'bb\'ca\'c7\'b9\'c2\'c1\'a2\'b5\'c4\'ce\'c4\'b5\'b5\'bf\'e2\'a3\'ac\'b6\'f8\'ca\'c7\'cd\'c5\'b6\'d3\'b5\'c4\'a1\'b8\'b5\'da\'b6\'fe\'b4\'f3\'c4\'d4\'a1\'b9\'a3\'ac\'ca\'c7\'cf\'fb\'c3\'f0\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a2\'b8\'fa\'c9\'cf\'c4\'a3\'d0\'cd\'b5\'fc\'b4\'fa\'cb\'d9\'b6\'c8\'b5\'c4\'ba\'cb\'d0\'c4\'d4\'d8\'cc\'e5\'a3\'ac\'b1\'d8\'d0\'eb\'ba\'cd\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'c8\'ab\'c1\'b4\'c2\'b7\'c9\'ee\'b6\'c8\'b4\'f2\'cd\'a8\'a3\'ac\'b6\'f8\'b7\'c7\'b5\'a5\'b6\'c0\'b4\'e6\'d4\'da\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'c9\'e8\'bc\'c6 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls4\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'cf\'ee\'c4\'bf +\f2 - +\f3 \'d6\'aa\'ca\'b6\'bf\'e2\'c7\'bf\'b0\'f3\'b6\'a8\'a3\'ac\'c8\'ab\'c1\'bf\'d0\'c5\'cf\'a2\'d7\'d4\'b6\'af\'b3\'c1\'b5\'ed +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b3\'b9\'b5\'d7\'bd\'e2\'be\'f6\'a1\'b8\'ce\'c4\'b5\'b5\'ba\'cd\'d6\'b4\'d0\'d0\'cd\'d1\'bd\'da\'a1\'a2\'be\'ad\'d1\'e9\'d0\'b4\'cd\'ea\'be\'cd\'c3\'bb\'c8\'cb\'bf\'b4\'a1\'a2\'d0\'c2\'c8\'cb\'c9\'cf\'ca\'d6\'c8\'ab\'bf\'bf\'c0\'cf\'d4\'b1\'b9\'a4\'b4\'f8\'a1\'b9\'b5\'c4\'ce\'ca\'cc\'e2\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls4\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c3\'bf\'b8\'f6\'cf\'ee\'c4\'bf\'b6\'d4\'d3\'a6\'d7\'a8\'ca\'f4\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac\'d4\'a4\'c9\'e8\'b9\'cc\'b6\'a8\'b7\'d6\'c0\'e0\'a3\'ba\'d0\'e8\'c7\'f3\'ce\'c4\'b5\'b5\'a1\'a2\'c4\'a3\'d0\'cd\'ca\'d6\'b2\'e1\'a1\'a2\'bc\'bc\'ca\'f5\'b7\'bd\'b0\'b8\'a1\'a2\'b2\'e2\'ca\'d4\'b1\'a8\'b8\'e6\'a1\'a2\'be\'f6\'b2\'df\'c8\'d5\'d6\'be\'a1\'a2\'b2\'c8\'bf\'d3\'bc\'c7\'c2\'bc\'a1\'a2\'b8\'b4\'c5\'cc\'d7\'dc\'bd\'e1\'a3\'ac\'ce\'de\'d0\'e8\'ca\'d6\'b6\'af\'bd\'a8\'ce\'c4\'bc\'fe\'bc\'d0\'a3\'ac\'b9\'e6\'b7\'b6\'cd\'b3\'d2\'bb\'a3\'ac\'b2\'e9\'d5\'d2\'b7\'bd\'b1\'e3\'a1\'a3 +\f0 \cb1 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls4\ilvl1 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'d7\'d4\'b6\'af\'b3\'c1\'b5\'ed\'a3\'ac\'c1\'e3\'ca\'d6\'b6\'af\'c9\'cf\'b4\'ab +\f1\b0 \'a3\'ba\'cf\'ee\'c4\'bf\'c8\'ab\'c1\'f7\'b3\'cc\'b2\'fa\'c9\'fa\'b5\'c4\'ce\'c4\'b5\'b5\'a3\'ac\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'bd\'d6\'aa\'ca\'b6\'bf\'e2\'b6\'d4\'d3\'a6\'b7\'d6\'c0\'e0\'a1\'a3\'c0\'fd\'a3\'ba\'c8\'ce\'ce\'f1\'d6\'d0\'b5\'c4\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'b2\'e2\'ca\'d4\'b1\'a8\'b8\'e6\'a3\'ac\'cd\'ea\'b3\'c9\'ba\'f3\'d7\'d4\'b6\'af\'b4\'e6\'c8\'eb\'a1\'b8\'c4\'a3\'d0\'cd\'ca\'d6\'b2\'e1\'a1\'b9\'a3\'bb\'cf\'ee\'c4\'bf\'b5\'c4\'be\'f6\'b2\'df\'c8\'d5\'d6\'be\'a3\'ac\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'bd\'b6\'d4\'d3\'a6\'b7\'d6\'c0\'e0\'a3\'bb\'b5\'fc\'b4\'fa\'b8\'b4\'c5\'cc\'d7\'dc\'bd\'e1\'a3\'ac\'d7\'d4\'b6\'af\'b9\'e9\'b5\'b5\'b5\'bd\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac\'ce\'de\'d0\'e8\'cd\'c5\'b6\'d3\'b3\'c9\'d4\'b1\'ca\'d6\'b6\'af\'c9\'cf\'b4\'ab\'a3\'ac\'b4\'d3\'b8\'f9\'d4\'b4\'b1\'dc\'c3\'e2\'ce\'c4\'b5\'b5\'d3\'eb\'ca\'b5\'bc\'ca\'d6\'b4\'d0\'d0\'cd\'d1\'bd\'da\'a1\'a3 +\f0 \cb1 \ +\ls4\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b0\'e6\'b1\'be\'d3\'eb\'b5\'fc\'b4\'fa +\f2 / +\f3 \'c4\'a3\'d0\'cd\'c7\'bf\'b0\'f3\'b6\'a8 +\f1\b0 \'a3\'ba\'c3\'bf\'b8\'f6\'ce\'c4\'b5\'b5\'b5\'c4\'b0\'e6\'b1\'be\'a3\'ac\'b6\'bc\'b9\'d8\'c1\'aa\'b6\'d4\'d3\'a6\'b5\'c4\'cf\'ee\'c4\'bf\'b5\'fc\'b4\'fa\'a1\'a2\'c4\'a3\'d0\'cd\'b0\'e6\'b1\'be\'ba\'c5\'a3\'a8\'c0\'fd\'a3\'ba +\f0 V2.0 +\f1 \'b5\'fc\'b4\'fa +\f0 - +\f1 \'bb\'f9\'d3\'da +\f0 Qwen2-7B v1.5 +\f1 \'b0\'e6\'b1\'be\'b5\'c4\'b3\'a4\'ce\'c4\'b5\'b5\'b4\'a6\'c0\'ed\'b7\'bd\'b0\'b8\'a3\'a9\'a1\'a3\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'b5\'fc\'b4\'fa\'ca\'b1\'a3\'ac\'bf\'c9\'bf\'ec\'cb\'d9\'bb\'d8\'cb\'dd\'c0\'fa\'ca\'b7\'b7\'bd\'b0\'b8\'a1\'a2\'b2\'c8\'bf\'d3\'bc\'c7\'c2\'bc\'a1\'a2\'ca\'ca\'c5\'e4\'be\'ad\'d1\'e9\'a3\'ac\'b1\'dc\'c3\'e2\'d6\'d8\'b8\'b4\'b2\'c8\'bf\'d3\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'bf\'ec\'cb\'d9\'b8\'fa\'c9\'cf\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'b5\'c4\'cb\'d9\'b6\'c8\'a3\'ac\'b2\'bb\'bb\'e1\'b4\'ed\'b9\'fd\'bc\'bc\'ca\'f5\'ba\'ec\'c0\'fb\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls4\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls4\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 RAG +\f3 \'d4\'f6\'c7\'bf\'b5\'c4\'d6\'c7\'c4\'dc\'ce\'ca\'b4\'f0\'a3\'ac\'ca\'b5\'cf\'d6\'c8\'ab\'d4\'b1\'c8\'cf\'d6\'aa\'c0\'ad\'c6\'bd +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'bb\'f9\'d3\'da\'b1\'be\'b5\'d8\'b2\'bf\'ca\'f0\'b5\'c4\'b4\'f3\'c4\'a3\'d0\'cd\'a3\'ac\'b6\'d4\'c8\'ab\'c1\'bf\'d6\'aa\'ca\'b6\'bf\'e2\'d7\'f6\'cf\'f2\'c1\'bf\'bb\'af\'b4\'a6\'c0\'ed\'a3\'ac\'b4\'f2\'d4\'ec\'cd\'c5\'b6\'d3\'d7\'a8\'ca\'f4\'b5\'c4\'d6\'c7\'c4\'dc\'ce\'ca\'b4\'f0\'d6\'fa\'ca\'d6\'a3\'ac\'b3\'b9\'b5\'d7\'bd\'e2\'be\'f6\'a1\'b8\'c0\'cf\'d4\'b1\'b9\'a4\'be\'ad\'d1\'e9\'b4\'f8\'b2\'bb\'d7\'df\'a1\'a2\'d0\'c2\'c8\'cb\'c9\'cf\'ca\'d6\'c2\'fd\'a1\'a2\'d0\'c5\'cf\'a2\'d5\'d2\'b2\'bb\'b5\'bd\'a1\'b9\'b5\'c4\'ce\'ca\'cc\'e2\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls4\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c8\'ab\'c6\'bd\'cc\'a8\'d2\'bb\'bc\'fc\'bb\'bd\'c6\'f0\'ce\'ca\'b4\'f0\'a3\'ac\'d4\'da\'c8\'ce\'ce\'f1\'d2\'b3\'c3\'e6\'a1\'a2\'d0\'e8\'c7\'f3\'d2\'b3\'c3\'e6\'a1\'a2\'d6\'aa\'ca\'b6\'bf\'e2\'d2\'b3\'c3\'e6\'a3\'ac\'b6\'bc\'bf\'c9\'d2\'d4\'d6\'b1\'bd\'d3\'cc\'e1\'ce\'ca\'a3\'ac +\f0 AI +\f1 \'bb\'f9\'d3\'da\'d6\'aa\'ca\'b6\'bf\'e2\'c4\'da\'c8\'dd\'b8\'f8\'b3\'f6\'be\'ab\'d7\'bc\'b4\'f0\'b0\'b8\'a3\'ac\'b6\'c5\'be\'f8\'bb\'c3\'be\'f5\'a1\'a3\'c0\'fd\'a3\'ba\'d4\'da\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'c8\'ce\'ce\'f1\'d6\'d0\'a3\'ac\'d6\'b1\'bd\'d3\'ce\'ca\'a1\'b8\'d5\'e2\'b8\'f6\'c4\'a3\'d0\'cd\'d6\'ae\'c7\'b0\'b5\'c4\'b0\'e6\'b1\'be\'ca\'ca\'c5\'e4\'d3\'d0\'ca\'b2\'c3\'b4\'bf\'d3\'a3\'bf\'a1\'b9\'a3\'ac +\f0 AI +\f1 \'d6\'b1\'bd\'d3\'bc\'ec\'cb\'f7\'d6\'aa\'ca\'b6\'bf\'e2\'d6\'d0\'b5\'c4\'c0\'fa\'ca\'b7\'bc\'c7\'c2\'bc\'a3\'ac\'b8\'f8\'b3\'f6\'cd\'ea\'d5\'fb\'b5\'c4\'b1\'dc\'bf\'d3\'b7\'bd\'b0\'b8\'a3\'ac\'ce\'de\'d0\'e8\'b5\'bd\'b4\'a6\'d5\'d2\'c0\'cf\'d4\'b1\'b9\'a4\'d1\'af\'ce\'ca\'a1\'a3 +\f0 \cb1 \ +\ls4\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'d3\'b0\'cf\'ec\'b7\'d6\'ce\'f6\'a3\'ba\'d0\'c2\'c4\'a3\'d0\'cd\'b7\'a2\'b2\'bc\'ba\'f3\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'b6\'d4\'b1\'c8\'d0\'c2\'be\'c9\'c4\'a3\'d0\'cd\'b5\'c4\'c4\'dc\'c1\'a6\'b2\'ee\'d2\'ec\'a3\'ac\'bc\'ec\'cb\'f7\'d6\'aa\'ca\'b6\'bf\'e2\'d6\'d0\'cb\'f9\'d3\'d0\'d3\'c3\'b5\'bd\'b8\'c3\'c4\'a3\'d0\'cd\'b5\'c4\'cf\'ee\'c4\'bf\'a1\'a2\'b9\'a6\'c4\'dc\'a1\'a2\'bc\'bc\'ca\'f5\'b7\'bd\'b0\'b8\'a3\'ac\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'a1\'b6\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'d3\'b0\'cf\'ec\'b7\'d6\'ce\'f6\'b1\'a8\'b8\'e6\'a1\'b7\'a3\'ac\'b8\'e6\'cb\'df\'cd\'c5\'b6\'d3\'c4\'c4\'d0\'a9\'b9\'a6\'c4\'dc\'bf\'c9\'d2\'d4\'d3\'c5\'bb\'af\'a1\'a2\'c4\'c4\'d0\'a9\'b7\'bd\'b0\'b8\'d0\'e8\'d2\'aa\'d6\'d8\'b9\'b9\'a1\'a2\'c4\'c4\'d0\'a9\'b7\'e7\'cf\'d5\'d0\'e8\'d2\'aa\'cc\'e1\'c7\'b0\'b9\'e6\'b1\'dc\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'d4\'da +\f0 24 +\f1 \'d0\'a1\'ca\'b1\'c4\'da\'cd\'ea\'b3\'c9\'b6\'d4\'d0\'c2\'c4\'a3\'d0\'cd\'b5\'c4\'c6\'c0\'b9\'c0\'d3\'eb\'c2\'e4\'b5\'d8\'b9\'e6\'bb\'ae\'a3\'ac\'ca\'bc\'d6\'d5\'b8\'fa\'c9\'cf\'bc\'bc\'ca\'f5\'b5\'fc\'b4\'fa\'cb\'d9\'b6\'c8\'a1\'a3 +\f0 \cb1 \ +\ls4\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c3\'bf\'d6\'dc\'c8\'cf\'d6\'aa\'cd\'ac\'b2\'bd\'a3\'ba +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'a1\'b6\'d6\'aa\'ca\'b6\'bf\'e2\'b8\'fc\'d0\'c2\'d6\'dc\'b1\'a8\'a1\'b7\'a3\'ac\'cd\'ac\'b2\'bd\'b1\'be\'d6\'dc\'d0\'c2\'d4\'f6\'b5\'c4\'ce\'c4\'b5\'b5\'a1\'a2\'c4\'a3\'d0\'cd\'be\'ad\'d1\'e9\'a1\'a2\'b2\'c8\'bf\'d3\'bc\'c7\'c2\'bc\'a1\'a2\'be\'f6\'b2\'df\'b1\'e4\'b8\'fc\'a3\'ac\'c8\'ab\'d4\'b1\'cd\'c6\'cb\'cd\'a3\'ac\'c8\'b7\'b1\'a3\'cb\'f9\'d3\'d0\'c8\'cb\'b6\'bc\'d5\'c6\'ce\'d5\'d7\'ee\'d0\'c2\'b5\'c4\'d0\'c5\'cf\'a2\'a3\'ac\'b1\'dc\'c3\'e2\'a1\'b8\'d6\'bb\'d3\'d0\'c9\'d9\'ca\'fd\'c8\'cb\'d6\'aa\'b5\'c0\'b8\'fc\'d0\'c2\'a3\'ac\'c6\'e4\'cb\'fb\'c8\'cb\'bb\'b9\'d4\'da\'d3\'c3\'be\'c9\'b7\'bd\'b0\'b8\'a1\'b9\'b5\'c4\'c8\'cf\'d6\'aa\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls4\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c4\'a3\'bf\'e9\'cb\'c4\'a3\'ba +\f0 AI +\f1 \'b8\'a8\'d6\'fa\'b5\'c4\'b9\'dc\'c0\'ed\'d3\'eb\'b8\'b4\'c5\'cc\'b1\'d5\'bb\'b7\'a3\'a8\'bd\'e2\'be\'f6\'a1\'b8\'b9\'dc\'c0\'ed\'ce\'de\'d7\'a5\'ca\'d6\'a1\'a2\'d3\'c5\'bb\'af\'ce\'de\'b7\'bd\'cf\'f2\'a1\'a2\'be\'ad\'d1\'e9\'b2\'bb\'b3\'c1\'b5\'ed\'a1\'b9\'b5\'c4\'cd\'b4\'b5\'e3\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'b9\'dc\'c0\'ed\'a3\'ac\'ba\'cb\'d0\'c4\'b2\'bb\'ca\'c7\'b6\'a2\'c8\'cb\'a3\'ac\'b6\'f8\'ca\'c7\'b6\'a2\'c4\'bf\'b1\'ea\'a1\'a2\'bf\'d8\'b7\'e7\'cf\'d5\'a1\'a2\'cc\'e1\'c8\'cb\'d0\'a7\'a1\'a2\'b3\'d6\'d0\'f8\'d3\'c5\'bb\'af\'a1\'a3\'c6\'bd\'cc\'a8\'d2\'aa\'d7\'f6\'b5\'c4\'ca\'c7\'b8\'f8\'b9\'dc\'c0\'ed\'d5\'df\'cc\'e1\'b9\'a9\'c8\'ab\'be\'d6\'ca\'d3\'bd\'c7\'a3\'ac\'d3\'c3 +\f0 AI +\f1 \'cc\'e6\'b4\'fa\'c8\'cb\'b9\'a4\'cd\'b3\'bc\'c6\'d3\'eb\'b7\'d6\'ce\'f6\'a3\'ac\'ca\'b5\'cf\'d6\'a1\'b8\'ca\'fd\'be\'dd\'c7\'fd\'b6\'af\'b9\'dc\'c0\'ed\'a1\'b9\'a3\'ac\'b6\'f8\'b7\'c7\'a1\'b8\'be\'ad\'d1\'e9\'c7\'fd\'b6\'af\'b9\'dc\'c0\'ed\'a1\'b9\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'c9\'e8\'bc\'c6 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b9\'dc\'c0\'ed\'d5\'df\'c8\'ab\'be\'d6\'bc\'dd\'ca\'bb\'b2\'d5\'a3\'ac\'d2\'bb\'d2\'b3\'d5\'c6\'ce\'d5\'c8\'ab\'c3\'b2 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'bc\'ab\'bc\'f2\'d2\'c7\'b1\'ed\'c5\'cc\'a3\'ac\'ce\'de\'c8\'df\'d3\'e0\'ca\'fd\'be\'dd\'a3\'ac\'d6\'bb\'d5\'b9\'ca\'be\'b9\'dc\'c0\'ed\'d5\'df\'d7\'ee\'b9\'d8\'d0\'c4\'b5\'c4\'ba\'cb\'d0\'c4\'d0\'c5\'cf\'a2\'a3\'ac\'d2\'bb\'d1\'db\'bf\'b4\'c7\'e5\'cf\'ee\'c4\'bf\'c8\'ab\'be\'d6\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls5\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'cb\'d0\'c4\'ca\'fd\'be\'dd\'bf\'b4\'b0\'e5\'a3\'ba\'b1\'b1\'bc\'ab\'d0\'c7\'c4\'bf\'b1\'ea\'bd\'f8\'b6\'c8\'a1\'a2\'b5\'fc\'b4\'fa\'b3\'d8\'bd\'bb\'b8\'b6\'c7\'e9\'bf\'f6\'a1\'a2\'b7\'e7\'cf\'d5\'d4\'a4\'be\'af\'c7\'e5\'b5\'a5\'a1\'a2\'cd\'c5\'b6\'d3\'c8\'cb\'c1\'a6\'b8\'ba\'d4\'d8\'a1\'a2\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'d3\'b0\'cf\'ec\'a1\'a2\'ca\'d0\'b3\'a1\'b7\'b4\'c0\'a1\'bb\'e3\'d7\'dc\'a3\'ac\'ce\'de\'d0\'e8\'b7\'ad\'b6\'e0\'b8\'f6\'d2\'b3\'c3\'e6\'a1\'a2\'ce\'de\'d0\'e8\'b0\'a4\'b8\'f6\'d5\'d2\'c8\'cb\'ce\'ca\'bd\'f8\'b6\'c8\'a1\'a3 +\f0 \cb1 \ +\ls5\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f1 \'c3\'bf\'c8\'d5 +\f0 / +\f1 \'c3\'bf\'d6\'dc\'b9\'dc\'c0\'ed\'bc\'f2\'b1\'a8\'a3\'ba\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'b9\'dc\'c0\'ed\'bd\'e1\'c2\'db\'a3\'ac\'b6\'f8\'b7\'c7\'b5\'a5\'b4\'bf\'b5\'c4\'ca\'fd\'be\'dd\'c2\'de\'c1\'d0\'a1\'a3\'c0\'fd\'a3\'ba\'a1\'b8 +\f0 XX +\f1 \'cd\'ac\'d1\'a7\'b8\'ba\'d4\'d8\'b3\'ac\'b9\'fd +\f0 120% +\f1 \'a3\'ac\'bd\'a8\'d2\'e9\'bd\'ab +\f0 XX +\f1 \'b5\'cd\'d3\'c5\'cf\'c8\'bc\'b6\'c8\'ce\'ce\'f1\'b5\'f7\'d5\'fb\'b8\'f8 +\f0 XX +\f1 \'cd\'ac\'d1\'a7\'a3\'ac\'b1\'dc\'c3\'e2\'cf\'ee\'c4\'bf\'d1\'d3\'c6\'da\'a1\'b9\'a1\'b8\'b1\'be\'d6\'dc\'d0\'e8\'c7\'f3\'b1\'e4\'b8\'fc\'c2\'ca\'b4\'ef +\f0 35% +\f1 \'a3\'ac\'bd\'a8\'d2\'e9\'d3\'c5\'bb\'af\'d0\'e8\'c7\'f3\'c6\'c0\'b9\'c0\'c1\'f7\'b3\'cc\'a3\'ac\'bc\'f5\'c9\'d9\'c6\'b5\'b7\'b1\'b1\'e4\'b8\'fc\'b6\'d4\'cd\'c5\'b6\'d3\'bd\'da\'d7\'e0\'b5\'c4\'d3\'b0\'cf\'ec\'a1\'b9\'a3\'ac\'d6\'b1\'bd\'d3\'b8\'f8\'b3\'f6\'bf\'c9\'c2\'e4\'b5\'d8\'b5\'c4\'b9\'dc\'c0\'ed\'bd\'a8\'d2\'e9\'a3\'ac\'b8\'a8\'d6\'fa\'be\'f6\'b2\'df\'a1\'a3 +\f0 \cb1 \ +\ls5\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'cd\'c5\'b6\'d3\'bd\'a1\'bf\'b5\'b6\'c8\'b7\'d6\'ce\'f6\'a3\'ba +\f0 AI +\f1 \'d7\'d4\'b6\'af\'b7\'d6\'ce\'f6\'cd\'c5\'b6\'d3\'b5\'c4\'d0\'ad\'d7\'f7\'d0\'a7\'c2\'ca\'a1\'a2\'b5\'fc\'b4\'fa\'bd\'bb\'b8\'b6\'c2\'ca\'a1\'a2\'d0\'e8\'c7\'f3\'cf\'ec\'d3\'a6\'cb\'d9\'b6\'c8\'a1\'a2 +\f0 bug +\f1 \'c2\'ca\'a1\'a2\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb\'c7\'e9\'bf\'f6\'a3\'ac\'cc\'e1\'c7\'b0\'b7\'a2\'cf\'d6\'cd\'c5\'b6\'d3\'b5\'c4\'c7\'b1\'d4\'da\'ce\'ca\'cc\'e2\'a3\'ac\'b1\'c8\'c8\'e7\'b9\'b5\'cd\'a8\'c4\'da\'ba\'c4\'a1\'a2\'d0\'e8\'c7\'f3\'d6\'ca\'c1\'bf\'b2\'ee\'a1\'a2\'c8\'cb\'c1\'a6\'b7\'d6\'c5\'e4\'b2\'bb\'be\'f9\'a3\'ac\'b6\'f8\'b7\'c7\'b5\'c8\'b3\'f6\'c1\'cb\'ce\'ca\'cc\'e2\'d4\'d9\'b2\'b9\'be\'c8\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bc\'ab\'cb\'d9\'b8\'b4\'c5\'cc\'b1\'d5\'bb\'b7\'a3\'ac\'ca\'b5\'cf\'d6\'b3\'d6\'d0\'f8\'b5\'fc\'b4\'fa\'d3\'c5\'bb\'af +\f0\b0 \cb1 \uc0\u8232 \cb3 AI +\f1 \'ca\'b1\'b4\'fa\'b5\'c4\'b8\'b4\'c5\'cc\'a3\'ac\'b2\'bb\'c4\'dc\'b5\'c8\'d2\'bb\'b8\'f6\'bc\'be\'b6\'c8\'a1\'a2\'c9\'f5\'d6\'c1\'d2\'bb\'b8\'f6\'b5\'fc\'b4\'fa\'a3\'ac\'d2\'aa\'d7\'f6\'b5\'bd\'a1\'b8\'c9\'cf\'cf\'df\'bc\'b4\'b8\'b4\'c5\'cc\'a1\'a2\'b1\'e4\'bb\'af\'bc\'b4\'b8\'b4\'c5\'cc\'a1\'b9\'a3\'ac\'d3\'c3\'c6\'bd\'cc\'a8\'ca\'b5\'cf\'d6\'a1\'b8\'b8\'b4\'c5\'cc +\f0 - +\f1 \'b8\'c4\'bd\'f8 +\f0 - +\f1 \'c2\'e4\'b5\'d8 +\f0 - +\f1 \'d1\'e9\'d6\'a4\'a1\'b9\'b5\'c4\'cd\'ea\'d5\'fb\'b1\'d5\'bb\'b7\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'d4\'bd\'d7\'f6\'d4\'bd\'bf\'ec\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls5\ilvl1 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 AI +\f3 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'b8\'b4\'c5\'cc\'b1\'a8\'b8\'e6 +\f1\b0 \'a3\'ba\'c3\'bf\'b8\'f6\'b5\'fc\'b4\'fa +\f0 / +\f1 \'b9\'a6\'c4\'dc\'c9\'cf\'cf\'df\'ba\'f3\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c0\'ad\'c8\'a1\'c8\'ce\'ce\'f1\'ca\'fd\'be\'dd\'a1\'a2\'b4\'fa\'c2\'eb\'ca\'fd\'be\'dd\'a1\'a2\'b2\'e2\'ca\'d4\'ca\'fd\'be\'dd\'a1\'a2\'c9\'cf\'cf\'df\'d0\'a7\'b9\'fb\'a1\'a2\'ca\'d0\'b3\'a1\'b7\'b4\'c0\'a1\'a3\'ac\'c9\'fa\'b3\'c9\'b8\'b4\'c5\'cc\'b3\'f5\'b8\'e5\'a3\'ac\'d7\'dc\'bd\'e1\'d7\'f6\'b5\'c3\'ba\'c3\'b5\'c4\'b5\'d8\'b7\'bd\'a1\'a2\'ba\'cb\'d0\'c4\'ce\'ca\'cc\'e2\'a1\'a2\'b8\'f9\'d2\'f2\'b7\'d6\'ce\'f6\'a1\'a2\'b8\'c4\'bd\'f8\'bd\'a8\'d2\'e9\'a3\'ac\'cd\'c5\'b6\'d3\'bd\'f6\'d0\'e8\'d4\'da\'b3\'f5\'b8\'e5\'c9\'cf\'b2\'b9\'b3\'e4\'b5\'f7\'d5\'fb\'a3\'ac\'ce\'de\'d0\'e8\'ca\'d6\'b6\'af\'d5\'fb\'c0\'ed\'ca\'fd\'be\'dd\'a3\'ac\'b0\'d1\'b8\'b4\'c5\'cc\'ca\'b1\'bc\'e4\'b4\'d3 +\f0 2 +\f1 \'d0\'a1\'ca\'b1\'d1\'b9\'cb\'f5\'b5\'bd +\f0 30 +\f1 \'b7\'d6\'d6\'d3\'a1\'a3 +\f0 \cb1 \ +\ls5\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b8\'b4\'c5\'cc\'bd\'e1\'c2\'db\'d7\'d4\'b6\'af\'c2\'e4\'b5\'d8 +\f1\b0 \'a3\'ba\'b8\'b4\'c5\'cc\'b3\'f6\'c0\'b4\'b5\'c4\'ce\'ca\'cc\'e2\'a1\'a2\'b8\'c4\'bd\'f8\'b4\'eb\'ca\'a9\'a3\'ac\'d6\'b1\'bd\'d3\'c9\'fa\'b3\'c9\'c8\'ce\'ce\'f1\'a3\'ac\'bd\'f8\'c8\'eb\'b5\'fc\'b4\'fa\'b3\'d8\'a3\'ac\'b9\'d8\'c1\'aa\'b6\'d4\'d3\'a6\'b5\'c4\'cf\'ee\'c4\'bf\'d3\'eb\'b8\'ba\'d4\'f0\'c8\'cb\'a3\'ac\'cf\'b5\'cd\'b3\'d7\'d4\'b6\'af\'b8\'fa\'d7\'d9\'bd\'f8\'b6\'c8\'a3\'ac\'cd\'ea\'b3\'c9\'ba\'f3\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'b5\'bd\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac\'b3\'b9\'b5\'d7\'b6\'c5\'be\'f8\'a1\'b8\'b8\'b4\'c5\'cc\'c1\'cb\'be\'cd\'cd\'fc\'c1\'cb\'a3\'ac\'ce\'ca\'cc\'e2\'cf\'c2\'b4\'ce\'bb\'b9\'b7\'b8\'a1\'b9\'b5\'c4\'cd\'e7\'bc\'b2\'a1\'a3 +\f0 \cb1 \ +\ls5\ilvl1 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'d7\'a8\'cf\'ee\'b8\'b4\'c5\'cc +\f1\b0 \'a3\'ba\'c3\'bf\'b4\'ce\'b4\'f3\'b5\'c4\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'c2\'e4\'b5\'d8\'ba\'f3\'a3\'ac +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'d7\'a8\'cf\'ee\'b8\'b4\'c5\'cc\'b1\'a8\'b8\'e6\'a3\'ac\'d7\'dc\'bd\'e1\'b1\'be\'b4\'ce\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'b4\'f8\'c0\'b4\'b5\'c4\'ca\'d5\'d2\'e6\'a1\'a2\'d3\'f6\'b5\'bd\'b5\'c4\'bf\'d3\'a1\'a2\'d3\'c5\'bb\'af\'bf\'d5\'bc\'e4\'a1\'a2\'bc\'bc\'ca\'f5\'b3\'c1\'b5\'ed\'a3\'ac\'b4\'e6\'c8\'eb\'cd\'c5\'b6\'d3\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'b5\'c4\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'be\'ad\'d1\'e9\'b3\'d6\'d0\'f8\'bb\'fd\'c0\'db\'a3\'ac\'ba\'f3\'d0\'f8\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'b5\'c4\'c2\'e4\'b5\'d8\'cb\'d9\'b6\'c8\'d4\'bd\'c0\'b4\'d4\'bd\'bf\'ec\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'cd\'c5\'b6\'d3\'c4\'dc\'c1\'a6\'d3\'eb\'b8\'ba\'d4\'d8\'b9\'dc\'c0\'ed +\f0\b0 \cb1 \uc0\u8232 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'d2\'bb\'c8\'cb\'b6\'e0\'d4\'f0\'a3\'ac\'d7\'ee\'c8\'dd\'d2\'d7\'b3\'f6\'cf\'d6\'b8\'ba\'d4\'d8\'b2\'bb\'be\'f9\'a1\'a2\'c4\'dc\'c1\'a6\'b4\'ed\'c5\'e4\'b5\'c4\'ce\'ca\'cc\'e2\'a3\'ac\'c6\'bd\'cc\'a8\'d7\'d4\'b6\'af\'ca\'b5\'cf\'d6\'be\'ab\'cf\'b8\'bb\'af\'b9\'dc\'c0\'ed\'a3\'ba +\f0 \cb1 \uc0\u8232 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls5\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'cd\'c5\'b6\'d3\'b8\'ba\'d4\'d8\'ca\'b5\'ca\'b1\'bc\'e0\'bf\'d8\'a3\'ba\'d7\'d4\'b6\'af\'cd\'b3\'bc\'c6\'c3\'bf\'b8\'f6\'c8\'cb\'b5\'c4\'c8\'ce\'ce\'f1\'c1\'bf\'a1\'a2\'b9\'a4\'ca\'b1\'a1\'a2\'b8\'ba\'d4\'d8\'c7\'e9\'bf\'f6\'a3\'ac\'b8\'ba\'d4\'d8\'b9\'fd\'b8\'df +\f0 / +\f1 \'b9\'fd\'b5\'cd\'d7\'d4\'b6\'af\'b8\'df\'c1\'c1\'a3\'ac\'b9\'dc\'c0\'ed\'d5\'df\'bf\'c9\'bf\'ec\'cb\'d9\'b5\'f7\'d5\'fb\'c8\'ce\'ce\'f1\'b7\'d6\'c5\'e4\'a3\'ac\'b1\'dc\'c3\'e2\'a1\'b8\'c3\'a6\'b5\'c4\'c3\'a6\'cb\'c0\'a1\'a2\'cf\'d0\'b5\'c4\'cf\'d0\'cb\'c0\'a1\'b9\'a3\'ac\'d7\'ee\'b4\'f3\'bb\'af +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'c8\'cb\'d0\'a7\'a1\'a3 +\f0 \cb1 \ +\ls5\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'cd\'c5\'b6\'d3\'c4\'dc\'c1\'a6\'cd\'bc\'c6\'d7\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'a3\'ba +\f0 AI +\f1 \'b8\'f9\'be\'dd\'c3\'bf\'b8\'f6\'c8\'cb\'cd\'ea\'b3\'c9\'b5\'c4\'c8\'ce\'ce\'f1\'a1\'a2\'b3\'c1\'b5\'ed\'b5\'c4\'ce\'c4\'b5\'b5\'a1\'a2\'bd\'e2\'be\'f6\'b5\'c4\'ce\'ca\'cc\'e2\'a3\'ac\'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'cd\'c5\'b6\'d3\'c4\'dc\'c1\'a6\'cd\'bc\'c6\'d7\'a3\'ac\'b1\'c8\'c8\'e7\'cb\'ad\'c9\'c3\'b3\'a4\'c4\'a3\'d0\'cd\'ce\'a2\'b5\'f7\'a1\'a2\'cb\'ad\'c9\'c3\'b3\'a4\'b8\'df\'b2\'a2\'b7\'a2\'bf\'aa\'b7\'a2\'a1\'a2\'cb\'ad\'c9\'c3\'b3\'a4\'c7\'b0\'b6\'cb\'bd\'bb\'bb\'a5\'a3\'ac\'d3\'f6\'b5\'bd\'d0\'c2\'b5\'c4\'d0\'e8\'c7\'f3 +\f0 / +\f1 \'ca\'d0\'b3\'a1\'b1\'e4\'bb\'af\'a3\'ac\'bf\'c9\'bf\'ec\'cb\'d9\'b7\'d6\'c5\'e4\'b8\'f8\'d7\'ee\'ba\'cf\'ca\'ca\'b5\'c4\'c8\'cb\'a3\'ac\'b4\'f3\'b7\'f9\'cc\'e1\'c9\'fd\'cf\'ec\'d3\'a6\'cb\'d9\'b6\'c8\'d3\'eb\'bd\'bb\'b8\'b6\'d6\'ca\'c1\'bf\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls5\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 6 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c8\'fd\'a1\'a2\'ca\'ca\'c5\'e4 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'c2\'e4\'b5\'d8\'c2\'b7\'be\'b6\'d3\'eb\'bc\'bc\'ca\'f5\'d1\'a1\'d0\'cd +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d3\'c5\'cf\'c8\'b7\'bd\'b0\'b8\'a3\'ba\'bf\'aa\'d4\'b4\'b9\'a4\'be\'df\'d7\'e9\'ba\'cf +\f0 + +\f1 \'c7\'e1\'c1\'bf\'b6\'fe\'b4\'ce\'bf\'aa\'b7\'a2\'a3\'a8 +\f0 2 +\f1 \'c8\'cb\'bf\'aa\'b7\'a2\'a3\'ac +\f0 2 +\f1 \'b8\'f6\'d4\'c2\'c8\'ab\'c1\'bf\'c9\'cf\'cf\'df\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'ce\'de\'d0\'e8\'b4\'d3\'c1\'e3\'d7\'d4\'d1\'d0\'a3\'ac\'d3\'c5\'cf\'c8\'d1\'a1\'d4\'f1\'b3\'c9\'ca\'ec\'bf\'aa\'d4\'b4\'b9\'a4\'be\'df\'d7\'f6\'d7\'e9\'ba\'cf\'d3\'eb\'b6\'fe\'b4\'ce\'bf\'aa\'b7\'a2\'a3\'ac\'be\'db\'bd\'b9\'ba\'cb\'d0\'c4\'b5\'c4 +\f0 AI +\f1 \'c4\'dc\'c1\'a6\'a1\'a2\'c1\'f7\'b3\'cc\'b1\'d5\'bb\'b7\'a1\'a2\'ca\'fd\'be\'dd\'b4\'f2\'cd\'a8\'a3\'ac\'d7\'ee\'bf\'ec\'cb\'d9\'b6\'c8\'c2\'e4\'b5\'d8\'a3\'ac\'cf\'c8\'c5\'dc\'cd\'a8\'c1\'f7\'b3\'cc\'d4\'d9\'d3\'c5\'bb\'af\'b6\'a8\'d6\'c6\'a1\'a3 +\f0 \cb1 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'cd\'c6\'bc\'f6\'bf\'aa\'d4\'b4\'d7\'e9\'ba\'cf\'a3\'a8\'bf\'aa\'cf\'e4\'bc\'b4\'d3\'c3\'a1\'a2 +\f0 AI +\f1 \'d3\'d1\'ba\'c3\'a1\'a2\'b2\'bf\'ca\'f0\'bc\'f2\'b5\'a5\'a1\'a2\'b6\'fe\'b4\'ce\'bf\'aa\'b7\'a2\'b3\'c9\'b1\'be\'b5\'cd\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b1\'ed\'b8\'f1 +\f0 \cb1 \ +\ +\ +\ + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrt\brdrnil \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f3\b \cf2 \cb3 \'ba\'cb\'d0\'c4\'c4\'a3\'bf\'e9 +\f2 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f3 \cf2 \cb3 \'d1\'a1\'d0\'cd\'cd\'c6\'bc\'f6 +\f2 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f3 \cf2 \cb3 \'ba\'cb\'d0\'c4\'c0\'ed\'d3\'c9 +\f2 \cb1 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1\b0 \cf2 \cb3 \'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed +\f0 + +\f1 \'d6\'aa\'ca\'b6\'bf\'e2\'b5\'d7\'d7\'f9 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 \cb3 Plane\cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'bf\'aa\'d4\'b4\'cc\'e6\'b4\'fa +\f0 Jira+Notion +\f1 \'a3\'ac\'d7\'d4\'b4\'f8\'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'a1\'a2\'bf\'b4\'b0\'e5\'a1\'a2 +\f0 OKR +\f1 \'a1\'a2\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac +\f0 API +\f1 \'cd\'ea\'c9\'c6\'a3\'ac\'c7\'e1\'c1\'bf\'d2\'d7\'b2\'bf\'ca\'f0\'a3\'ac\'cd\'ea\'c3\'c0\'ca\'ca\'c5\'e4 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'a3\'ac\'d6\'a7\'b3\'d6\'d7\'d4\'b6\'a8\'d2\'e5\'d7\'d6\'b6\'ce\'d3\'eb\'c1\'f7\'b3\'cc\'a3\'ac\'b6\'fe\'b4\'ce\'bf\'aa\'b7\'a2\'d3\'d1\'ba\'c3 +\f0 \cb1 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b1\'be\'b5\'d8 +\f0 AI +\f1 \'d2\'fd\'c7\'e6 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 \cb3 Ollama + Spring AI\cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d2\'bb\'bc\'fc\'b2\'bf\'ca\'f0\'b1\'be\'b5\'d8\'b4\'f3\'c4\'a3\'d0\'cd\'a3\'ac\'d7\'d4\'b4\'f8 +\f0 OpenAI +\f1 \'bc\'e6\'c8\'dd +\f0 API +\f1 \'a3\'ac\'cd\'ea\'c3\'c0\'ca\'ca\'c5\'e4\'b9\'fa\'c4\'da\'bf\'aa\'d4\'b4\'c4\'a3\'d0\'cd\'a3\'ac +\f0 Spring AI +\f1 \'ce\'de\'b7\'ec\'bc\'af\'b3\'c9\'a3\'ac\'bf\'ec\'cb\'d9\'ca\'b5\'cf\'d6\'c8\'ce\'ce\'f1\'b2\'f0\'bd\'e2\'a1\'a2 +\f0 RAG +\f1 \'ce\'ca\'b4\'f0\'a1\'a2\'d6\'c7\'c4\'dc\'b7\'d6\'ce\'f6\'b5\'c8\'c4\'dc\'c1\'a6\'a3\'ac\'d4\'cb\'ce\'ac\'b3\'c9\'b1\'be\'bc\'ab\'b5\'cd +\f0 \cb1 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d6\'aa\'ca\'b6\'bf\'e2 +\f0 RAG +\f1 \'d4\'f6\'c7\'bf +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 \cb3 MaxKB\cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b9\'fa\'b2\'fa\'bf\'aa\'d4\'b4\'d6\'aa\'ca\'b6\'bf\'e2 +\f0 RAG +\f1 \'c6\'bd\'cc\'a8\'a3\'ac\'cd\'ea\'c3\'c0\'bc\'e6\'c8\'dd +\f0 Ollama +\f1 \'a3\'ac\'d2\'bb\'bc\'fc\'b2\'bf\'ca\'f0\'a3\'ac\'ba\'cd +\f0 Plane +\f1 \'cb\'ab\'cf\'f2\'b4\'f2\'cd\'a8\'a3\'ac\'ca\'b5\'cf\'d6\'ce\'c4\'b5\'b5\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'a1\'a2\'cf\'f2\'c1\'bf\'bb\'af\'a1\'a2\'d6\'c7\'c4\'dc\'ce\'ca\'b4\'f0\'a3\'ac\'ce\'de\'d0\'e8\'b4\'d3\'c1\'e3\'bf\'aa\'b7\'a2 +\f0 RAG +\f1 \'c1\'f7\'b3\'cc +\f0 \cb1 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'d7\'a5\'c8\'a1 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 \cb3 Python + +\f1 \'b6\'a8\'ca\'b1\'c8\'ce\'ce\'f1 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c7\'e1\'c1\'bf\'c5\'c0\'b3\'e6\'ca\'b5\'cf\'d6\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'a1\'a2\'be\'ba\'c6\'b7\'d0\'c5\'cf\'a2\'d7\'d4\'b6\'af\'d7\'a5\'c8\'a1\'a3\'ac\'cd\'a8\'b9\'fd +\f0 API +\f1 \'cd\'ac\'b2\'bd\'b5\'bd +\f0 Plane +\f1 \'b5\'c4\'d0\'e8\'c7\'f3\'b3\'d8\'a3\'ac\'bf\'aa\'b7\'a2\'c1\'bf\'bc\'ab\'d0\'a1 +\f0 \cb1 \cell \row + +\itap1\trowd \taflags0 \trgaph108\trleft-108 \trbrdrl\brdrnil \trbrdrt\brdrnil \trbrdrr\brdrnil +\clvertalc \clshdrawnil \clwWidth1568\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx2880 +\clvertalc \clshdrawnil \clwWidth1306\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx5760 +\clvertalc \clshdrawnil \clwWidth10725\clftsWidth3 \clmart10 \clmarl10 \clmarb10 \clmarr10 \clbrdrt\brdrnil \clbrdrl\brdrnil \clbrdrb\brdrnil \clbrdrr\brdrnil \clpadl0 \clpadr0 \gaph\cellx8640 +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b5\'da\'c8\'fd\'b7\'bd\'bc\'af\'b3\'c9 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 +\cf2 \cb3 Plane +\f1 \'d4\'ad\'c9\'fa\'bc\'af\'b3\'c9 +\f0 \cb1 \cell +\pard\intbl\itap1\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'d7\'d4\'b4\'f8 +\f0 GitLab/Gitee +\f1 \'a1\'a2\'b7\'c9\'ca\'e9 +\f0 / +\f1 \'c6\'f3\'d2\'b5\'ce\'a2\'d0\'c5\'a1\'a2 +\f0 Jenkins +\f1 \'b4\'f2\'cd\'a8\'a3\'ac\'ca\'b5\'cf\'d6\'b4\'fa\'c2\'eb\'cc\'e1\'bd\'bb\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'c8\'ce\'ce\'f1\'bd\'f8\'b6\'c8\'a1\'a2\'cf\'fb\'cf\'a2\'d7\'d4\'b6\'af\'cd\'c6\'cb\'cd\'a3\'ac\'ce\'de\'d0\'e8\'b6\'ee\'cd\'e2\'bf\'aa\'b7\'a2 +\f0 \cb1 \cell \lastrow\row +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b7\'d6\'bd\'d7\'b6\'ce\'c2\'e4\'b5\'d8\'c2\'b7\'be\'b6\'a3\'a8 +\f0 2 +\f1 \'c8\'cb\'b7\'d6\'b9\'a4\'a3\'ac +\f0 8 +\f1 \'d6\'dc\'c9\'cf\'cf\'df\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bd\'d7\'b6\'ce +\f2 1 +\f3 \'a3\'ba\'bb\'f9\'b4\'a1\'b5\'d7\'d7\'f9\'b4\'ee\'bd\'a8\'d3\'eb\'c1\'f7\'b3\'cc\'c5\'dc\'cd\'a8\'a3\'a8 +\f2 2 +\f3 \'d6\'dc\'a3\'a9 +\f0\b0 \cb1 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls6\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b7\'d6\'b9\'a4\'a3\'ba\'c7\'b0\'ba\'f3\'b6\'cb\'c5\'e4\'ba\'cf\'a3\'ac\'cd\'ea\'b3\'c9 +\f0 Plane +\f1 \'a1\'a2 +\f0 Ollama +\f1 \'a1\'a2 +\f0 MaxKB +\f1 \'b5\'c4\'b2\'bf\'ca\'f0\'a3\'ac\'b4\'f2\'cd\'a8\'b7\'c9\'ca\'e9 +\f0 / +\f1 \'c6\'f3\'d2\'b5\'ce\'a2\'d0\'c5\'a1\'a2\'b4\'fa\'c2\'eb\'b2\'d6\'bf\'e2\'a3\'ac\'c5\'e4\'d6\'c3\'ba\'c3\'bb\'f9\'b4\'a1\'b5\'c4\'cf\'ee\'c4\'bf\'a1\'a2\'c8\'ce\'ce\'f1\'a1\'a2\'d6\'aa\'ca\'b6\'bf\'e2\'a1\'a2 +\f0 OKR +\f1 \'c4\'a3\'bf\'e9\'a1\'a3 +\f0 \cb1 \ +\ls6\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'cb\'d0\'c4\'b2\'fa\'b3\'f6\'a3\'ba\'c6\'bd\'cc\'a8\'bb\'f9\'b4\'a1\'bb\'b7\'be\'b3\'bf\'c9\'d3\'c3\'a3\'ac\'cd\'c5\'b6\'d3\'c5\'dc\'cd\'a8\'a1\'b8\'d0\'e8\'c7\'f3 +\f0 - +\f1 \'c8\'ce\'ce\'f1 +\f0 - +\f1 \'bd\'bb\'b8\'b6\'a1\'b9\'b5\'c4\'bb\'f9\'b4\'a1\'c1\'f7\'b3\'cc\'a3\'ac\'c8\'b7\'c8\'cf\'ba\'cb\'d0\'c4\'c1\'f7\'b3\'cc\'d3\'eb\'d0\'e8\'c7\'f3\'b1\'df\'bd\'e7\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bd\'d7\'b6\'ce +\f2 2 +\f3 \'a3\'ba +\f2 AI +\f3 \'ba\'cb\'d0\'c4\'c4\'dc\'c1\'a6\'bf\'aa\'b7\'a2\'d3\'eb\'bc\'af\'b3\'c9\'a3\'a8 +\f2 4 +\f3 \'d6\'dc\'a3\'a9 +\f0\b0 \cb1 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls6\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b7\'d6\'b9\'a4\'a3\'ba\'ba\'f3\'b6\'cb\'b8\'ba\'d4\'f0\'c8\'cb\'d6\'f7\'b9\'a5 +\f0 AI +\f1 \'d2\'fd\'c7\'e6\'a1\'a2\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'d7\'a5\'c8\'a1\'a1\'a2\'b5\'da\'c8\'fd\'b7\'bd\'bc\'af\'b3\'c9\'a1\'a2\'d7\'d4\'b6\'af\'bb\'af\'c1\'f7\'b3\'cc\'bf\'aa\'b7\'a2\'a3\'bb\'c7\'b0\'b6\'cb\'b8\'ba\'d4\'f0\'c8\'cb\'d6\'f7\'b9\'a5\'bd\'bb\'bb\'a5\'d3\'c5\'bb\'af\'a1\'a2 +\f0 AI +\f1 \'b6\'d4\'bb\'b0\'c8\'eb\'bf\'da\'a1\'a2\'bc\'dd\'ca\'bb\'b2\'d5\'d2\'b3\'c3\'e6\'bf\'aa\'b7\'a2\'a1\'a3 +\f0 \cb1 \ +\ls6\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'cb\'d0\'c4\'b2\'fa\'b3\'f6\'a3\'ba\'cd\'ea\'b3\'c9\'b1\'e4\'bb\'af\'d0\'c5\'ba\'c5\'b9\'e9\'bc\'af\'a1\'a2 +\f0 AI +\f1 \'c8\'ce\'ce\'f1\'b2\'f0\'bd\'e2\'a1\'a2\'bd\'f8\'b6\'c8\'d7\'d4\'b6\'af\'cd\'ac\'b2\'bd\'a1\'a2\'b7\'e7\'cf\'d5\'d4\'a4\'be\'af\'a1\'a2 +\f0 RAG +\f1 \'d6\'c7\'c4\'dc\'ce\'ca\'b4\'f0\'a1\'a2\'b9\'dc\'c0\'ed\'bc\'dd\'ca\'bb\'b2\'d5\'b5\'c4\'ba\'cb\'d0\'c4\'b9\'a6\'c4\'dc\'a3\'ac\'c8\'ab\'c1\'b4\'c2\'b7\'b4\'f2\'cd\'a8\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bd\'d7\'b6\'ce +\f2 3 +\f3 \'a3\'ba\'d3\'c5\'bb\'af\'b5\'fc\'b4\'fa\'d3\'eb\'c8\'ab\'c1\'bf\'c9\'cf\'cf\'df\'a3\'a8 +\f2 2 +\f3 \'d6\'dc\'a3\'a9 +\f0\b0 \cb1 \ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls6\ilvl1\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b7\'d6\'b9\'a4\'a3\'ba\'c1\'bd\'c8\'cb\'c5\'e4\'ba\'cf\'cd\'ea\'b3\'c9\'c8\'ab\'c1\'f7\'b3\'cc\'b2\'e2\'ca\'d4\'a1\'a2 +\f0 AI +\f1 \'c4\'dc\'c1\'a6\'b5\'f7\'d3\'c5\'a1\'a2 +\f0 Prompt +\f1 \'d3\'c5\'bb\'af\'a3\'ac\'cd\'c5\'b6\'d3\'d0\'a1\'b7\'b6\'ce\'a7\'ca\'d4\'d3\'c3\'a3\'ac\'ca\'d5\'bc\'af\'b7\'b4\'c0\'a1\'bf\'ec\'cb\'d9\'b5\'fc\'b4\'fa\'a3\'ac\'cd\'ea\'b3\'c9\'d7\'ee\'d6\'d5\'d3\'c5\'bb\'af\'a1\'a3 +\f0 \cb1 \ +\ls6\ilvl1\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'cb\'d0\'c4\'b2\'fa\'b3\'f6\'a3\'ba\'c6\'bd\'cc\'a8\'c8\'ab\'c1\'bf\'c9\'cf\'cf\'df\'a3\'ac\'cd\'c5\'b6\'d3\'c8\'ab\'d4\'b1\'ca\'b9\'d3\'c3\'a3\'ac\'cd\'ea\'b3\'c9\'cd\'ea\'d5\'fb\'b5\'c4\'a1\'b8\'b1\'e4\'bb\'af +\f0 - +\f1 \'be\'f6\'b2\'df +\f0 - +\f1 \'d6\'b4\'d0\'d0 +\f0 - +\f1 \'b8\'b4\'c5\'cc +\f0 - +\f1 \'b3\'c1\'b5\'ed\'a1\'b9\'b1\'d5\'bb\'b7\'a3\'ac\'c5\'e4\'cc\'d7\'ca\'b9\'d3\'c3\'b9\'e6\'b7\'b6\'c2\'e4\'b5\'d8\'a1\'a3 +\f0 \cb1 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls6\ilvl0\cf2 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 6 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b1\'b8\'d1\'a1\'b7\'bd\'b0\'b8\'a3\'ba\'c8\'ab\'d7\'d4\'d1\'d0\'a3\'a8 +\f0 4-6 +\f1 \'b8\'f6\'d4\'c2\'c9\'cf\'cf\'df\'a3\'ac\'ca\'ca\'c5\'e4\'c7\'bf\'b6\'a8\'d6\'c6\'bb\'af\'d0\'e8\'c7\'f3\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'c8\'e7\'b9\'fb\'cd\'c5\'b6\'d3\'d3\'d0\'c7\'bf\'b6\'a8\'d6\'c6\'bb\'af\'d0\'e8\'c7\'f3\'a1\'a2\'ca\'fd\'be\'dd\'b0\'b2\'c8\'ab\'d2\'aa\'c7\'f3\'bc\'ab\'b8\'df\'a3\'ac\'bf\'c9\'bb\'f9\'d3\'da\'d6\'ae\'c7\'b0\'b5\'c4\'d7\'d4\'d1\'d0\'bc\'dc\'b9\'b9\'c9\'fd\'bc\'b6\'a3\'ac\'bc\'bc\'ca\'f5\'d5\'bb\'b1\'a3\'b3\'d6\'d2\'bb\'d6\'c2\'a3\'ac\'be\'db\'bd\'b9\'ba\'cb\'d0\'c4\'c4\'a3\'bf\'e9\'bf\'aa\'b7\'a2\'a3\'ba +\f0 \cb1 \ +\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls7\ilvl0\cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'c7\'b0\'b6\'cb\'a3\'ba +\f0 Vue3 + Vite + Element Plus +\f1 \'a3\'ac\'b1\'a3\'b3\'d6\'cd\'b3\'d2\'bb\'c9\'fa\'cc\'ac\'a3\'ac\'bd\'b5\'b5\'cd\'bf\'aa\'b7\'a2\'b3\'c9\'b1\'be +\f0 \cb1 \ +\ls7\ilvl0\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'f3\'b6\'cb\'a3\'ba +\f0 Spring Boot 3.x + MyBatis-Plus + Spring AI +\f1 \'a3\'ac\'b3\'c9\'ca\'ec\'ce\'c8\'b6\'a8\'a3\'ac +\f0 AI +\f1 \'bc\'af\'b3\'c9\'d3\'d1\'ba\'c3 +\f0 \cb1 \ +\ls7\ilvl0\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ca\'fd\'be\'dd\'bf\'e2\'a3\'ba +\f0 PostgreSQL 16+ pgvector +\f1 \'a3\'ac\'d2\'bb\'cc\'d7\'ca\'fd\'be\'dd\'bf\'e2\'b8\'e3\'b6\'a8\'bd\'e1\'b9\'b9\'bb\'af\'ca\'fd\'be\'dd\'a1\'a2\'c8\'ab\'ce\'c4\'bc\'ec\'cb\'f7\'a1\'a2\'cf\'f2\'c1\'bf\'b4\'e6\'b4\'a2\'a3\'ac\'bc\'ab\'d6\'c2\'bd\'b5\'b5\'cd\'d4\'cb\'ce\'ac\'b3\'c9\'b1\'be +\f0 \cb1 \ +\ls7\ilvl0\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b1\'be\'b5\'d8\'c4\'a3\'d0\'cd\'a3\'ba +\f0 Ollama + Qwen2-7B/14B +\f1 \'d6\'d0\'ce\'c4\'d4\'f6\'c7\'bf\'b0\'e6 +\f0 + bge-small-zh-v1.5 Embedding +\f1 \'c4\'a3\'d0\'cd\'a3\'ac\'ca\'ca\'c5\'e4 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'d3\'b2\'bc\'fe\'c5\'e4\'d6\'c3 +\f0 \cb1 \ +\ls7\ilvl0\cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 } +\f1 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'ba\'cb\'d0\'c4\'b7\'d6\'b9\'a4\'a3\'ba\'c7\'b0\'b6\'cb\'b8\'ba\'d4\'f0\'c8\'cb\'b8\'ba\'d4\'f0\'c8\'ab\'c1\'bf\'d2\'b3\'c3\'e6\'d3\'eb\'bd\'bb\'bb\'a5\'bf\'aa\'b7\'a2\'a3\'ac\'ba\'f3\'b6\'cb\'b8\'ba\'d4\'f0\'c8\'cb\'b8\'ba\'d4\'f0\'bc\'dc\'b9\'b9\'a1\'a2\'d2\'b5\'ce\'f1\'c2\'df\'bc\'ad\'a1\'a2 +\f0 AI +\f1 \'c4\'dc\'c1\'a6\'a1\'a2\'d4\'cb\'ce\'ac\'b2\'bf\'ca\'f0\'a3\'ac\'c1\'bd\'c8\'cb\'bd\'bb\'b2\'e6\'c6\'c0\'c9\'f3\'a3\'ac\'d1\'cf\'bf\'d8\'d0\'e8\'c7\'f3\'b1\'df\'bd\'e7\'a1\'a3 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'cb\'c4\'a1\'a2\'ba\'cb\'d0\'c4\'b1\'dc\'bf\'d3\'d6\'b8\'c4\'cf\'a3\'a8 +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3 +\f0 AI +\f1 \'cf\'ee\'c4\'bf\'b9\'dc\'c0\'ed\'b5\'c4\'c9\'fa\'cb\'c0\'cf\'df\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls8\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'ce\'aa\'c1\'cb +\f2 AI +\f3 \'b6\'f8 +\f2 AI +\f3 \'a3\'ac\'ba\'cb\'d0\'c4\'ca\'c7\'bc\'f5\'b8\'ba\'b6\'f8\'b7\'c7\'d4\'f6\'b8\'ba +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'d7\'ee\'b3\'a3\'bc\'fb\'b5\'c4\'bf\'d3\'a3\'ba\'b6\'d1\'c6\'f6\'d2\'bb\'b6\'d1 +\f0 AI +\f1 \'b9\'a6\'c4\'dc\'a3\'ac\'c8\'b4\'b2\'bb\'bd\'e2\'be\'f6\'ca\'b5\'bc\'ca\'ce\'ca\'cc\'e2\'a3\'ac\'b7\'b4\'b6\'f8\'d4\'f6\'bc\'d3\'cd\'c5\'b6\'d3\'b9\'a4\'d7\'f7\'c1\'bf\'a1\'a3\'b1\'c8\'c8\'e7 +\f0 AI +\f1 \'c9\'fa\'b3\'c9\'b5\'c4\'c8\'ce\'ce\'f1\'b2\'f0\'bd\'e2\'b2\'bb\'b7\'fb\'ba\'cf\'ca\'b5\'bc\'ca\'a3\'ac\'bb\'b9\'d2\'aa\'ca\'d6\'b6\'af\'b8\'c4\'b0\'eb\'cc\'ec\'a3\'bb\'c7\'bf\'d6\'c6\'d2\'aa\'c7\'f3\'cd\'c5\'b6\'d3\'ba\'cd +\f0 AI +\f1 \'b6\'d4\'bb\'b0\'a3\'ac\'b7\'b4\'b6\'f8\'b6\'e0\'c1\'cb\'d2\'bb\'b2\'bd\'b2\'d9\'d7\'f7\'a1\'a3\'bc\'c7\'d7\'a1\'a3\'ba\'cb\'f9\'d3\'d0 +\f0 AI +\f1 \'b9\'a6\'c4\'dc\'a3\'ac\'b1\'d8\'d0\'eb\'c4\'dc\'bc\'f5\'c9\'d9\'cd\'c5\'b6\'d3\'b5\'c4\'d6\'d8\'b8\'b4\'c0\'cd\'b6\'af\'a3\'ac\'b7\'f1\'d4\'f2\'d2\'bb\'c2\'c9\'bf\'b3\'b5\'f4\'a1\'a3 +\f0 \cb1 \ +\ls8\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'b8\'e3\'d6\'d8\'c1\'f7\'b3\'cc\'a1\'a2\'d6\'d8\'c9\'f3\'c5\'fa\'a3\'ac\'b1\'a3\'b3\'d6\'b1\'e2\'c6\'bd\'bb\'af\'bc\'ab\'cb\'d9\'be\'f6\'b2\'df +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b2\'bb\'d2\'aa\'d5\'d5\'b0\'e1\'b4\'f3\'b3\'a7\'b5\'c4\'a1\'b8\'d0\'e8\'c7\'f3\'c8\'fd\'bc\'b6\'c6\'c0\'c9\'f3\'a1\'a2\'b2\'e3\'b2\'e3\'c9\'f3\'c5\'fa\'a1\'b9\'c4\'a3\'ca\'bd\'a3\'ac +\f0 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3\'b5\'c4\'ba\'cb\'d0\'c4\'d3\'c5\'ca\'c6\'be\'cd\'ca\'c7\'c1\'e9\'bb\'ee\'a3\'ac\'d2\'bb\'b8\'f6\'d0\'e8\'c7\'f3\'c9\'f3\'c5\'fa +\f0 3 +\f1 \'cc\'ec\'a3\'ac\'ca\'d0\'b3\'a1\'d4\'e7\'be\'cd\'b1\'e4\'c1\'cb\'a1\'a3\'bc\'e1\'b3\'d6\'a1\'b8\'cb\'ad\'b8\'ba\'d4\'f0\'cb\'ad\'be\'f6\'b2\'df\'a3\'ac +\f0 15 +\f1 \'b7\'d6\'d6\'d3\'c4\'da\'cd\'ea\'b3\'c9\'b6\'d4\'c6\'eb\'a3\'ac +\f0 24 +\f1 \'d0\'a1\'ca\'b1\'c4\'da\'c2\'e4\'b5\'d8\'d6\'b4\'d0\'d0\'a1\'b9\'a3\'ac\'c1\'f7\'b3\'cc\'d4\'bd\'bc\'f2\'b5\'a5\'a3\'ac\'cf\'ec\'d3\'a6\'cb\'d9\'b6\'c8\'d4\'bd\'bf\'ec\'a1\'a3 +\f0 \cb1 \ +\ls8\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'b4\'f2\'c6\'c6\'b5\'a5\'d4\'b4\'d5\'e6\'c0\'ed\'d4\'ad\'d4\'f2\'a3\'ac\'b6\'c5\'be\'f8\'d0\'c5\'cf\'a2\'b7\'d6\'c9\'a2 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'c8\'cf\'d6\'aa\'b2\'ee\'b5\'c4\'b8\'f9\'d4\'b4\'a3\'ac\'be\'cd\'ca\'c7\'d0\'c5\'cf\'a2\'b7\'d6\'c9\'a2\'d4\'da\'b6\'e0\'b8\'f6\'b9\'a4\'be\'df\'c0\'ef\'a1\'a3\'b1\'d8\'d0\'eb\'c7\'bf\'d6\'c6\'cd\'c5\'b6\'d3\'a3\'ba\'cb\'f9\'d3\'d0\'cf\'ee\'c4\'bf\'cf\'e0\'b9\'d8\'b5\'c4\'d0\'c5\'cf\'a2\'a3\'ac\'c8\'ab\'b2\'bf\'c2\'bc\'c8\'eb\'c6\'bd\'cc\'a8\'a3\'ac\'b2\'bb\'d4\'ca\'d0\'ed\'d4\'da\'ce\'a2\'d0\'c5 +\f0 / +\f1 \'b7\'c9\'ca\'e9\'c0\'ef\'b6\'a8\'be\'f6\'b2\'df\'a1\'a2\'b2\'bb\'d4\'ca\'d0\'ed\'d3\'c3\'c0\'eb\'cf\'df\'ce\'c4\'b5\'b5\'d0\'b4\'b7\'bd\'b0\'b8\'a1\'a2\'b2\'bb\'d4\'ca\'d0\'ed\'cb\'bd\'cf\'c2\'cd\'ac\'b2\'bd\'bd\'f8\'b6\'c8\'a3\'ac\'c8\'b7\'b1\'a3\'c6\'bd\'cc\'a8\'ca\'c7\'ce\'a8\'d2\'bb\'b5\'c4\'d0\'c5\'cf\'a2\'c8\'eb\'bf\'da\'a3\'ac\'b4\'d3\'b8\'f9\'d4\'b4\'cf\'fb\'c3\'f0\'d0\'c5\'cf\'a2\'b2\'ee\'a1\'a3 +\f0 \cb1 \ +\ls8\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'d3\'c3\'b9\'cc\'b6\'a8\'b5\'fc\'b4\'fa\'ca\'f8\'b8\'bf\'cd\'c5\'b6\'d3\'a3\'ac\'ca\'ca\'c5\'e4 +\f2 AI +\f3 \'d0\'d0\'d2\'b5\'b5\'c4\'bf\'ec\'bd\'da\'d7\'e0 +\f0\b0 \cb1 \uc0\u8232 +\f1 \cb3 \'b4\'ab\'cd\'b3 +\f0 2 +\f1 \'d6\'dc\'b9\'cc\'b6\'a8\'b5\'fc\'b4\'fa\'a3\'ac\'d4\'da +\f0 AI +\f1 \'ca\'b1\'b4\'fa\'d2\'d1\'be\'ad\'b9\'fd\'ca\'b1\'a1\'a3\'b2\'bb\'d2\'aa\'ce\'aa\'c1\'cb\'a1\'b8\'b5\'fc\'b4\'fa\'ce\'c8\'b6\'a8\'a1\'b9\'a3\'ac\'be\'dc\'be\'f8\'b8\'df\'d3\'c5\'cf\'c8\'bc\'b6\'b5\'c4\'ca\'d0\'b3\'a1 +\f0 / +\f1 \'c4\'a3\'d0\'cd\'b1\'e4\'bb\'af\'a3\'ac\'d3\'c3\'b9\'f6\'b6\'af\'ca\'bd\'b6\'af\'cc\'ac\'b5\'fc\'b4\'fa\'a3\'ac\'d3\'c5\'cf\'c8\'bc\'b6\'b8\'df\'b5\'c4\'d0\'e8\'c7\'f3\'cb\'e6\'ca\'b1\'bd\'f8\'c8\'eb\'a1\'a2\'cb\'e6\'ca\'b1\'c9\'cf\'cf\'df\'a3\'ac\'b5\'cd\'d3\'c5\'cf\'c8\'bc\'b6\'d0\'e8\'c7\'f3\'d7\'d4\'b6\'af\'cb\'b3\'d1\'d3\'a3\'ac\'bc\'c8\'b1\'a3\'d6\'a4\'ba\'cb\'d0\'c4\'bd\'bb\'b8\'b6\'a3\'ac\'d3\'d6\'c4\'dc\'c1\'e9\'bb\'ee\'cf\'ec\'d3\'a6\'b1\'e4\'bb\'af\'a1\'a3 +\f0 \cb1 \ +\ls8\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'d6\'bb\'b6\'a2\'bd\'f8\'b6\'c8\'a3\'ac\'b2\'bb\'b6\'a2\'c8\'cf\'d6\'aa\'b6\'d4\'c6\'eb +\f0\b0 \cb1 \uc0\u8232 \cb3 10 +\f1 \'c8\'cb\'cd\'c5\'b6\'d3 +\f0 80% +\f1 \'b5\'c4\'b7\'b5\'b9\'a4\'a3\'ac\'b6\'bc\'c0\'b4\'d7\'d4\'c8\'cf\'d6\'aa\'b2\'bb\'d2\'bb\'d6\'c2\'a3\'ba\'b2\'fa\'c6\'b7\'cf\'eb\'b5\'c4\'ba\'cd\'bc\'bc\'ca\'f5\'d7\'f6\'b5\'c4\'b2\'bb\'d2\'bb\'d1\'f9\'a3\'ac\'cb\'e3\'b7\'a8\'b6\'d4\'c4\'a3\'d0\'cd\'c4\'dc\'c1\'a6\'b5\'c4\'c5\'d0\'b6\'cf\'ba\'cd\'b2\'fa\'c6\'b7\'d4\'a4\'c6\'da\'b2\'bb\'d2\'bb\'d1\'f9\'a3\'ac\'cd\'c5\'b6\'d3\'b6\'d4\'c4\'bf\'b1\'ea\'b5\'c4\'c0\'ed\'bd\'e2\'b2\'bb\'d2\'bb\'d1\'f9\'a1\'a3\'c6\'bd\'cc\'a8\'b5\'c4\'ba\'cb\'d0\'c4\'ca\'c7\'cf\'c8\'b6\'d4\'c6\'eb\'c8\'cf\'d6\'aa\'a3\'ac\'d4\'d9\'b9\'dc\'bf\'d8\'bd\'f8\'b6\'c8\'a3\'ac\'b7\'f1\'d4\'f2\'d7\'f6\'b5\'c3\'d4\'bd\'bf\'ec\'a3\'ac\'b4\'ed\'b5\'c3\'d4\'bd\'d4\'b6\'a1\'a3 +\f0 \cb1 \ +\ls8\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 6 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'be\'f8\'b6\'d4\'b2\'bb\'d2\'aa\'ba\'f6\'ca\'d3\'c4\'a3\'d0\'cd\'be\'ad\'d1\'e9\'b5\'c4\'b3\'c1\'b5\'ed\'a3\'ac\'b1\'dc\'c3\'e2\'d6\'d8\'b8\'b4\'b2\'c8\'bf\'d3 +\f0\b0 \cb1 \uc0\u8232 \cb3 AI +\f1 \'d0\'d0\'d2\'b5\'c4\'a3\'d0\'cd\'b5\'fc\'b4\'fa\'bc\'ab\'bf\'ec\'a3\'ac\'b2\'bb\'b3\'c1\'b5\'ed\'be\'ad\'d1\'e9\'a3\'ac\'cd\'c5\'b6\'d3\'be\'cd\'bb\'e1\'d2\'bb\'d6\'b1\'d4\'da\'d4\'ad\'b5\'d8\'b2\'c8\'bf\'d3\'a1\'a3\'b1\'d8\'d0\'eb\'c7\'bf\'d6\'c6\'cb\'f9\'d3\'d0\'c4\'a3\'d0\'cd\'ca\'ca\'c5\'e4\'a1\'a2\'d4\'a4\'d1\'d0\'a1\'a2\'b2\'e2\'ca\'d4\'b5\'c4\'be\'ad\'d1\'e9\'a3\'ac\'c8\'ab\'b2\'bf\'b3\'c1\'b5\'ed\'b5\'bd\'d6\'aa\'ca\'b6\'bf\'e2\'a3\'ac\'ba\'cd\'cf\'ee\'c4\'bf\'a1\'a2\'c4\'a3\'d0\'cd\'b0\'e6\'b1\'be\'c7\'bf\'b0\'f3\'b6\'a8\'a3\'ac\'c8\'c3\'cd\'c5\'b6\'d3\'b5\'c4\'be\'ad\'d1\'e9\'cb\'e6\'d7\'c5\'c4\'a3\'d0\'cd\'b5\'fc\'b4\'fa\'b3\'d6\'d0\'f8\'bb\'fd\'c0\'db\'a3\'ac\'d4\'bd\'d7\'f6\'d4\'bd\'bf\'ec\'a3\'ac\'b6\'f8\'b2\'bb\'ca\'c7\'c3\'bf\'b4\'ce\'c4\'a3\'d0\'cd\'b8\'fc\'d0\'c2\'b6\'bc\'b4\'d3\'c1\'e3\'bf\'aa\'ca\'bc\'a1\'a3 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'ce\'e5\'a1\'a2\'c5\'e4\'cc\'d7\'cd\'c5\'b6\'d3\'d0\'ad\'d7\'f7\'b9\'e6\'b7\'b6\'a3\'a8\'b9\'a4\'be\'df +\f0 + +\f1 \'b9\'e6\'b7\'b6\'a3\'ac\'b2\'c5\'c4\'dc\'b7\'a2\'bb\'d3\'d7\'ee\'b4\'f3\'bc\'db\'d6\'b5\'a3\'a9 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f1 \cf2 \cb3 \'b9\'a4\'be\'df\'d6\'bb\'ca\'c7\'d4\'d8\'cc\'e5\'a3\'ac\'b1\'d8\'d0\'eb\'c5\'e4\'cc\'d7\'bc\'ab\'bc\'f2\'b5\'c4\'d0\'ad\'d7\'f7\'b9\'e6\'b7\'b6\'a3\'ac\'b2\'c5\'c4\'dc\'b3\'b9\'b5\'d7\'c2\'e4\'b5\'d8\'a3\'ba +\f0 \cb1 \ +\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls9\ilvl0 +\f2\b \cf2 \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'d0\'c5\'cf\'a2\'c2\'bc\'c8\'eb\'b9\'e6\'b7\'b6 +\f1\b0 \'a3\'ba\'cb\'f9\'d3\'d0\'be\'f6\'b2\'df\'a1\'a2\'d0\'e8\'c7\'f3\'a1\'a2\'b7\'bd\'b0\'b8\'a1\'a2\'ce\'ca\'cc\'e2\'a3\'ac\'b1\'d8\'d0\'eb\'b5\'da\'d2\'bb\'ca\'b1\'bc\'e4\'c2\'bc\'c8\'eb\'c6\'bd\'cc\'a8\'a3\'ac\'b2\'bb\'d4\'ca\'d0\'ed\'cb\'bd\'cf\'c2\'b7\'a2\'c9\'a2\'b9\'b5\'cd\'a8\'a1\'a3 +\f0 \cb1 \ +\ls9\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'bb\'e1\'d2\'e9\'b9\'e6\'b7\'b6 +\f1\b0 \'a3\'ba\'c4\'dc\'d2\'ec\'b2\'bd\'b9\'b5\'cd\'a8\'b5\'c4\'be\'f8\'b2\'bb\'bf\'aa\'bb\'e1\'a3\'ac\'bf\'aa\'bb\'e1\'b1\'d8\'d0\'eb\'d3\'d0\'c3\'f7\'c8\'b7\'d6\'f7\'cc\'e2\'a3\'ac\'bb\'e1\'ba\'f3 +\f0 AI +\f1 \'d7\'d4\'b6\'af\'c9\'fa\'b3\'c9\'bc\'cd\'d2\'aa\'a3\'ac\'cd\'ac\'b2\'bd\'b5\'bd\'c6\'bd\'cc\'a8\'a3\'ac\'be\'f6\'b2\'df\'c4\'da\'c8\'dd\'d6\'b1\'bd\'d3\'d7\'aa\'ce\'aa\'c8\'ce\'ce\'f1\'a1\'a3 +\f0 \cb1 \ +\ls9\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 3 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'d3\'c5\'cf\'c8\'bc\'b6\'b9\'e6\'b7\'b6 +\f1\b0 \'a3\'ba +\f0 P0 +\f1 \'d0\'e8\'c7\'f3\'b2\'bb\'b3\'ac\'b9\'fd +\f0 2 +\f1 \'b8\'f6\'a3\'ac +\f0 P1 +\f1 \'d0\'e8\'c7\'f3\'b2\'bb\'b3\'ac\'b9\'fd +\f0 5 +\f1 \'b8\'f6\'a3\'ac\'b6\'c5\'be\'f8\'c8\'ab\'d4\'b1\'cd\'ac\'ca\'b1\'d7\'f6\'d2\'bb\'b6\'d1\'ca\'c2\'a3\'ac\'c8\'b7\'b1\'a3\'ba\'cb\'d0\'c4\'c4\'bf\'b1\'ea\'bf\'ec\'cb\'d9\'c2\'e4\'b5\'d8\'a1\'a3 +\f0 \cb1 \ +\ls9\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 4 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'b8\'b4\'c5\'cc\'b9\'e6\'b7\'b6 +\f1\b0 \'a3\'ba\'c3\'bf\'d6\'dc\'b9\'cc\'b6\'a8 +\f0 30 +\f1 \'b7\'d6\'d6\'d3\'b8\'b4\'c5\'cc\'a3\'ac\'d6\'bb\'cc\'b8\'ce\'ca\'cc\'e2\'ba\'cd\'b8\'c4\'bd\'f8\'b4\'eb\'ca\'a9\'a3\'ac\'b2\'bb\'cc\'b8\'bf\'d5\'bb\'b0\'cc\'d7\'bb\'b0\'a3\'ac\'b8\'c4\'bd\'f8\'b4\'eb\'ca\'a9\'b1\'d8\'d0\'eb\'c9\'fa\'b3\'c9\'bf\'c9\'c2\'e4\'b5\'d8\'b5\'c4\'c8\'ce\'ce\'f1\'a1\'a3 +\f0 \cb1 \ +\ls9\ilvl0 +\f2\b \cb3 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 5 } +\f3 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 \'d6\'aa\'ca\'b6\'bf\'e2\'b9\'e6\'b7\'b6 +\f1\b0 \'a3\'ba\'cb\'ad\'d6\'b4\'d0\'d0\'a1\'a2\'cb\'ad\'b3\'c1\'b5\'ed\'a3\'ac\'c8\'ce\'ce\'f1\'cd\'ea\'b3\'c9\'ba\'f3\'a3\'ac\'b6\'d4\'d3\'a6\'b5\'c4\'be\'ad\'d1\'e9\'a1\'a2\'b7\'bd\'b0\'b8\'a1\'a2\'b2\'c8\'bf\'d3\'bc\'c7\'c2\'bc\'a3\'ac\'b1\'d8\'d0\'eb\'d7\'d4\'b6\'af +\f0 / +\f1 \'ca\'d6\'b6\'af\'cd\'ac\'b2\'bd\'b5\'bd\'d6\'aa\'ca\'b6\'bf\'e2\'a1\'a3 +\f0 \cb1 \ +\pard\pardeftab720\partightenfactor0 +\cf2 \ +\pard\pardeftab720\qc\partightenfactor0 +\cf4 \strokec4 \ +\pard\pardeftab720\partightenfactor0 +\cf0 \strokec5 \ +\pard\pardeftab720\qc\partightenfactor0 + +\f4 \cf3 \strokec3 \ + +\f5 \'bf\'aa\'ca\'bc\'ca\'d4\'d3\'c3\'b6\'b9\'b0\'fc +\f4 \ +\pard\pardeftab720\qc\partightenfactor0 + +\f0 \cf6 \strokec6 \ +} \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..d9dd0e2b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,96 @@ +# Database Configuration +DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow +# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。 +PMFLOW_EXPECTED_DB_DIALECT=postgresql +# For PostgreSQL with pgvector: +# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync +# For local SQLite quick-start: +# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db + +# Redis Configuration +REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0 + +# Knowledge Graph Incremental Scheduler +KG_INCREMENTAL_INTERVAL_MINUTES=5 +KG_INCREMENTAL_DELTA_LIMIT=1000 +KG_INCREMENTAL_EVENT_BATCH=200 +KG_CODE_SCAN_INTERVAL_HOURS=6 + +# ======================================== +# LLM / OpenClaw Control Plane +# ======================================== +# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible) +LLM_PROVIDER=openai +OPENAI_BASE_URL=http://openclaw:18789/v1 +OPENAI_API_KEY= + +# OpenClaw 函数路由 +OPENCLAW_ROUTING_ENABLED=true +OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json +# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds +OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 + +# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准 +LLM_MIXED_ROUTING_ENABLED=false +LLM_ROUTING_DEFAULT_TIER=active +LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true + +# low/high/safety tier 默认模型(由网关解释) +LLM_ROUTING_LOW_PROVIDER=openai +LLM_ROUTING_LOW_MODEL=zai/glm-4-flash +LLM_ROUTING_HIGH_PROVIDER=openai +LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5 +LLM_ROUTING_SAFETY_PROVIDER=openai +LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat + +# 应急开关:仅在网关不可用时允许直连 provider(默认关闭) +LLM_DIRECT_EMERGENCY_ENABLED=false +LLM_FALLBACK_PROVIDER=ollama + +# Adaptive RAG 深入思考模式(前端开关=deep 时生效) +RAG_DEEP_RETRIEVAL_STEPS=5 +RAG_DEEP_VECTOR_TOP_K=12 +RAG_DEEP_CONTEXT_BLOCKS=16 +RAG_DEEP_ANSWER_MAX_TOKENS=1400 + +# --- Emergency Direct Provider Keys (optional) --- +GLM_API_KEY= +GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 +GLM_MODEL=glm-4-flash + +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# --- Ollama 本地配置(应急本地兜底) --- +OLLAMA_HOST=http://127.0.0.1:11434 +OLLAMA_CHAT_MODEL=llama3:8b +OLLAMA_EMBEDDING_MODEL=nomic-embed-text +OLLAMA_BASE_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=llama3:latest + +# ======================================== + +# Multi-Model Voting Configuration (仅 Ollama 支持) +# For high-impact signals (impact_score >= VOTING_THRESHOLD), multiple models will vote +# Available models: llama3, llama3.2, qwen2.5, mistral, deepseek-coder, etc. +VOTING_MODELS=llama3,qwen2.5,mistral +VOTING_THRESHOLD=0.8 + +# Business Context for AI Strategic Analysis +# Customize this to match your actual product/team +# This context is used by the AI to evaluate external signals +BUSINESS_CONTEXT=Pixelle-Video: An AI-powered automated short video generation platform. Core features: topic-to-video automation (script generation → AI images/videos → TTS → BGM → final video). Tech stack: Python, Streamlit, FastAPI, ComfyUI workflows. AI models: FLUX/Qwen/SDXL for images, WAN 2.1/2.2 for videos, Edge TTS/Index TTS for voice. Supports multiple pipelines: standard generation, custom assets, digital human, image-to-video, action transfer. + +# Plane PM Tool Integration (optional, simulation mode if not set) +PLANE_API_URL=https://api.plane.so/api/v1 +PLANE_API_KEY= +PLANE_WORKSPACE_SLUG= +PLANE_PROJECT_ID= + +# Lark/Feishu Bot Integration (optional) +LARK_APP_ID= +LARK_APP_SECRET= +LARK_VERIFICATION_TOKEN= +# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次) +FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1 +FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json new file mode 100644 index 00000000..c509e59f --- /dev/null +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -0,0 +1,74 @@ +{ + "version": "v2", + "updated_at": "2026-03-05T00:00:00Z", + "source_of_truth": "openclaw", + "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "rules": [ + { + "name": "engine_low_execute_glm_flash", + "enabled": true, + "priority": 300, + "tier": "low", + "provider": "openai", + "model": "zai/glm-4-flash", + "match_any": [ + "feishu_level_classification", + "feishu_signal_extraction", + "rag_intent_classification", + "classify_intent", + "feishu_humanized_reply", + "extract_context_insights" + ] + }, + { + "name": "engine_high_reasoning_minimax", + "enabled": true, + "priority": 220, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "adaptive_rag_answer", + "feishu_project_prefill", + "feishu_task_orchestration", + "generate_task_breakdown", + "generate_charter_from_signal", + "decompose_tasks_from_charter", + "recommend_assignees_for_task", + "evaluate_model_requirement", + "calculate_alignment_score", + "detect_cognitive_conflicts", + "rag_db_project_semantic_retrieval", + "workload_optimize_prompt", + "analyze_cost_roi" + ] + }, + { + "name": "engine_high_extended_reasoning", + "enabled": true, + "priority": 200, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "analyze_food_calories", + "feishu_new_project_brief", + "feishu_project_signal_classification", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", + "skill_score_update", + "test_engine_connection" + ] + }, + { + "name": "engine_safety_fallback", + "enabled": true, + "priority": 50, + "tier": "safety", + "provider": "openai", + "model": "deepseek/deepseek-chat", + "match_all": true + } + ] +} diff --git a/backend/app/api/ai_logs.py b/backend/app/api/ai_logs.py new file mode 100644 index 00000000..bfe7f8bc --- /dev/null +++ b/backend/app/api/ai_logs.py @@ -0,0 +1,341 @@ +"""AI 处理可视化日志 API。""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, case, func, or_, select, union_all +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import success_response +from ..database import get_db +from ..dependencies.auth import get_current_user +from ..models import ( + AIProcessingLog, + FeishuItem, + ProjectSignalLink, + ProjectSuggestion, + Signal, + User, + WorkItem, +) + + +router = APIRouter(prefix="/ai-logs", tags=["AI Logs"]) + + +def _normalize_bullets(value: Any) -> list[str]: + if isinstance(value, list): + return [str(x).strip() for x in value if str(x).strip()][:3] + if isinstance(value, str): + raw = value.strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(x).strip() for x in parsed if str(x).strip()][:3] + except Exception: + pass + return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3] + return [] + + +def _safe_json(value: Optional[str]) -> Any: + text = (value or "").strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return None + + +def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]: + return { + "id": str(row.id), + "feishu_message_id": row.feishu_message_id, + "feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None, + "raw_message": row.raw_message, + "project_score": row.project_score, + "signal_confidence": row.signal_confidence, + "level": row.level, + "routing_decision": row.routing_decision, + "routing_reason": row.routing_reason, + "reasoning_bullets": _normalize_bullets(row.reasoning_bullets), + "processing_status": row.processing_status, + "error_message": row.error_message, + "duration_ms": row.duration_ms, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]: + payload = _serialize_log_row(row) + payload["ai_raw_output"] = row.ai_raw_output + payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output) + return payload + + +def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]): + if project_id is None: + return stmt + + work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.project_id == int(project_id), + WorkItem.source_system == "feishu", + WorkItem.source_message_id.is_not(None), + ) + ) + direct_items = select(FeishuItem.id.label("item_id")).where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.matched_project_id == int(project_id), + ) + ) + signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where( + and_( + Signal.tenant_id == tenant_id, + Signal.matched_project_id == int(project_id), + Signal.feishu_item_id.is_not(None), + ) + ) + linked_items = ( + select(Signal.feishu_item_id.label("item_id")) + .select_from(Signal) + .join( + ProjectSignalLink, + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == Signal.id, + ), + ) + .where( + and_( + Signal.tenant_id == tenant_id, + Signal.feishu_item_id.is_not(None), + ) + ) + ) + auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where( + and_( + ProjectSuggestion.tenant_id == tenant_id, + ProjectSuggestion.created_project_id == int(project_id), + ProjectSuggestion.feishu_item_id.is_not(None), + ) + ) + + all_item_ids = union_all( + direct_items, + signal_matched_items, + linked_items, + auto_created_items, + ).subquery("project_item_ids") + item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None)) + message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.id.in_(item_ids_stmt), + FeishuItem.feishu_message_id.is_not(None), + ) + ) + return stmt.where( + or_( + AIProcessingLog.feishu_item_id.in_(item_ids_stmt), + AIProcessingLog.feishu_message_id.in_(message_ids_stmt), + AIProcessingLog.feishu_message_id.in_(work_item_message_ids), + ) + ) + + +@router.get("") +async def list_ai_logs( + project_id: Optional[int] = Query(default=None, ge=1), + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id) + data_stmt = ( + select(AIProcessingLog) + .where(AIProcessingLog.tenant_id == tenant_id) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .offset(offset) + .limit(limit) + ) + total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id) + data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id) + + total = int((await db.execute(total_stmt)).scalar() or 0) + rows = (await db.execute(data_stmt)).scalars().all() + items = [_serialize_log_row(row) for row in rows] + return success_response( + { + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "has_more": (offset + len(items)) < total, + } + ) + + +@router.get("/stats") +async def get_ai_log_stats( + project_id: Optional[int] = Query(default=None, ge=1), + date_value: str = Query(default="today", alias="date"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + if date_value == "today": + target_date = datetime.now().date() + else: + try: + target_date = date.fromisoformat(date_value) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc + + stats_stmt = ( + select( + func.count(AIProcessingLog.id).label("received_count"), + func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"), + func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"), + func.sum( + case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0) + ).label("duplicate_filtered_count"), + func.sum( + case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0) + ).label("execute_reply_guard_count"), + func.sum( + case( + ( + AIProcessingLog.routing_decision.in_( + ["auto_create_project", "auto_enter_signal"] + ), + 1, + ), + else_=0, + ) + ).label("auto_entered_count"), + func.sum( + case( + ( + AIProcessingLog.routing_decision.in_( + [ + "pending_low_confidence", + "pending_cold_start", + "pending_uncertain_project", + ] + ), + 1, + ), + else_=0, + ) + ).label("pending_count"), + func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label( + "processing_count" + ), + func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"), + func.avg(AIProcessingLog.project_score).label("avg_project_score"), + ) + .where( + and_( + AIProcessingLog.tenant_id == tenant_id, + func.date(AIProcessingLog.created_at) == target_date.isoformat(), + ) + ) + ) + stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id) + row = (await db.execute(stats_stmt)).one() + return success_response( + { + "date": target_date.isoformat(), + "received_count": int(row.received_count or 0), + "success_count": int(row.success_count or 0), + "failed_count": int(row.failed_count or 0), + "duplicate_filtered_count": int(row.duplicate_filtered_count or 0), + "execute_reply_guard_count": int(row.execute_reply_guard_count or 0), + "auto_entered_count": int(row.auto_entered_count or 0), + "pending_count": int(row.pending_count or 0), + "processing_count": int(row.processing_count or 0), + "avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4), + "avg_project_score": round(float(row.avg_project_score or 0.0), 4), + } + ) + + +@router.get("/alerts/execute-reply-guard") +async def list_execute_reply_guard_alerts( + limit: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + rows = ( + await db.execute( + select(AIProcessingLog) + .where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.routing_decision == "execute_reply_guard_triggered", + ) + ) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .limit(limit) + ) + ).scalars().all() + return success_response( + { + "count": len(rows), + "items": [_serialize_log_row(row) for row in rows], + } + ) + + +@router.get("/latest") +async def get_latest_ai_log( + project_id: Optional[int] = Query(default=None, ge=1), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + stmt = ( + select(AIProcessingLog) + .where(AIProcessingLog.tenant_id == tenant_id) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .limit(1) + ) + stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id) + row = (await db.execute(stmt)).scalar_one_or_none() + return success_response(_serialize_log_detail(row) if row else None) + + +@router.get("/{log_id}") +async def get_ai_log_detail( + log_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + row = ( + await db.execute( + select(AIProcessingLog).where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.id == int(log_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="ai_log_not_found") + return success_response(_serialize_log_detail(row)) diff --git a/backend/app/api/ai_pmo.py b/backend/app/api/ai_pmo.py new file mode 100644 index 00000000..d18fedd1 --- /dev/null +++ b/backend/app/api/ai_pmo.py @@ -0,0 +1,417 @@ +"""AI PMO 决策中枢 API。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import success_response +from ..database import get_db +from ..dependencies.auth import get_current_user +from ..dependencies.tenant import get_tenant_id +from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem +from ..services.ai_pmo_cadence_service import AIPMOCadenceService +from ..services.ai_pmo_kernel_service import AIPMOKernelService + +router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) + + +class PMOProcessTextRequest(BaseModel): + message_text: str = Field(..., min_length=1, max_length=4000) + sender: Optional[str] = Field(default=None, max_length=120) + source_message_id: Optional[str] = Field(default=None, max_length=128) + project_id: Optional[int] = Field(default=None) + + +class PMOCadenceRunRequest(BaseModel): + cadence_type: str = Field(..., min_length=1, max_length=32) + project_id: Optional[int] = Field(default=None) + + +class PMOResumeWorkItemRequest(BaseModel): + project_id: Optional[int] = Field(default=None) + source_message_id: Optional[str] = Field(default=None, max_length=128) + + +@router.post("/process-text") +async def process_text( + request: PMOProcessTextRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AIPMOKernelService(db, tenant_id=tenant_id) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id=request.source_message_id, + message_text=request.message_text, + sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"), + channel_ref="pmflow_manual", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=request.project_id, + ) + if not result.get("handled"): + raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入")) + return success_response(result) + + +@router.get("/work-items") +async def list_work_items( + limit: int = 20, + state: Optional[str] = None, + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + stmt = ( + select(WorkItem, IngestEvent, Project) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id)) + .where(WorkItem.tenant_id == tenant_id) + .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) + ) + if state: + stmt = stmt.where(WorkItem.state == state) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all() + items = [ + _serialize_work_item_summary(row=row, ingest=ingest, project=project) + for row, ingest, project in rows + ] + return success_response({"items": items}) + + +@router.get("/work-items/{work_item_id}") +async def get_work_item( + work_item_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + row = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + decision_rows = ( + await db.execute( + select(DecisionRecord).where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id == int(work_item_id), + ) + ).order_by(DecisionRecord.id.asc()) + ) + ).scalars().all() + plan_rows = ( + await db.execute( + select(PlanBundle).where( + and_( + PlanBundle.tenant_id == tenant_id, + PlanBundle.work_item_id == int(work_item_id), + ) + ).order_by(PlanBundle.id.asc()) + ) + ).scalars().all() + reply_rows = ( + await db.execute( + select(ReplyReceipt).where( + and_( + ReplyReceipt.tenant_id == tenant_id, + ReplyReceipt.work_item_id == int(work_item_id), + ) + ).order_by(ReplyReceipt.id.asc()) + ) + ).scalars().all() + payload: dict[str, Any] = { + "id": int(row.id), + "source_system": row.source_system, + "source_message_id": row.source_message_id, + "state": row.state, + "intent": row.intent, + "decision_level": row.decision_level, + "project_id": _safe_int(row.project_id), + "signal_id": _safe_int(row.signal_id), + "context_snapshot": row.context_snapshot, + "action_bundle": row.action_bundle, + "execution_result": row.execution_result, + "review_summary": row.review_summary, + "reply_template_key": row.reply_template_key, + "decision_records": [ + { + "id": int(item.id), + "decision_level": item.decision_level, + "decision_object_type": item.decision_object_type, + "decision_object_id": _safe_int(item.decision_object_id), + "rationale": item.rationale, + "risk_score": item.risk_score, + "budget_impact": item.budget_impact, + "resource_impact": item.resource_impact, + "simulation_result": item.simulation_result, + "policy_gate_hit": bool(item.policy_gate_hit), + "policy_gate_code": item.policy_gate_code, + "approval_requirement": item.approval_requirement, + "execution_result": item.execution_result, + "final_outcome": item.final_outcome, + } + for item in decision_rows + ], + "plan_bundles": [ + { + "id": int(item.id), + "plan_type": item.plan_type, + "summary": item.summary, + "payload": item.payload, + } + for item in plan_rows + ], + "reply_receipts": [ + { + "id": int(item.id), + "receipt_phase": item.receipt_phase, + "template_key": item.template_key, + "payload": item.payload, + "status": item.status, + } + for item in reply_rows + ], + } + return success_response(payload) + + +@router.post("/work-items/{work_item_id}/resume") +async def resume_work_item( + work_item_id: int, + request: PMOResumeWorkItemRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + target = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + service = AIPMOKernelService(db, tenant_id=tenant_id) + result = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=str(current_user.name or current_user.username or "PMFlow用户"), + channel_ref="ai_pmo_ui", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=request.project_id or _safe_int(target.project_id), + ) + if not result.get("handled"): + raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed")) + return success_response(result) + + +@router.post("/cadence/run") +async def run_cadence( + request: PMOCadenceRunRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + try: + snapshot = await service.generate_snapshot( + cadence_type=request.cadence_type, + project_id=request.project_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + await db.commit() + return success_response(snapshot) + + +@router.get("/cadence/latest") +async def get_latest_cadence_snapshot( + cadence_type: str, + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + snapshot = await service.get_latest_snapshot( + cadence_type=cadence_type, + project_id=project_id, + ) + return success_response(snapshot) + + +@router.get("/portfolio/program-view") +async def get_program_view( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + return success_response(await service.build_program_view()) + + +@router.get("/portfolio/latest") +async def get_latest_portfolio_snapshot( + scope_type: str = "tenant", + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}" + row = ( + await db.execute( + select(PortfolioSnapshot).where( + and_( + PortfolioSnapshot.tenant_id == tenant_id, + PortfolioSnapshot.scope_type == scope_type, + PortfolioSnapshot.scope_ref == scope_ref, + ) + ).order_by(PortfolioSnapshot.id.desc()).limit(1) + ) + ).scalar_one_or_none() + if row is None: + return success_response(None) + return success_response( + { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + ) + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _compact_text(value: Any, limit: int) -> str: + text = " ".join(str(value or "").split()).strip() + if len(text) <= limit: + return text + return f"{text[: max(limit - 1, 1)].rstrip()}…" + + +def _priority_from_review(review_summary: Any) -> tuple[str, int]: + payload = review_summary if isinstance(review_summary, dict) else {} + risk_score = float(payload.get("risk_score_after") or 0.0) + if risk_score >= 0.75: + return "high", 3 + if risk_score >= 0.45: + return "medium", 2 + return "low", 1 + + +def _blocking_reason(row: WorkItem) -> Optional[str]: + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip() + if reason: + return reason + divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] + if divergences and isinstance(divergences[0], dict): + return str(divergences[0].get("detail") or "").strip() or None + return None + + +def _next_step_text(row: WorkItem) -> Optional[str]: + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else [] + if next_actions: + return str(next_actions[0]).strip() + action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {} + summary = str(action_bundle.get("summary") or "").strip() + return summary or None + + +def _stage_text(state: str) -> str: + mapping = { + "received": "已接收", + "modeled": "已建模", + "decided": "已决策", + "planned": "已规划", + "executing": "执行中", + "waiting_manual": "待人工确认", + "succeeded": "执行完成", + "failed": "执行失败", + "ignored": "已忽略", + } + return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") + + +def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + message_text = str(payload.get("message_text") or "").strip() + summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180) + title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64) + priority, priority_rank = _priority_from_review(row.review_summary) + project_name = None + if project is not None: + project_name = str(project.title or project.name or "").strip() or None + project_id = _safe_int(row.project_id) + return { + "id": int(row.id), + "source_system": row.source_system, + "source_message_id": row.source_message_id, + "state": row.state, + "intent": row.intent, + "decision_level": row.decision_level, + "project_id": project_id, + "project_name": project_name, + "signal_id": _safe_int(row.signal_id), + "reply_template_key": row.reply_template_key, + "title": title or f"工作项#{int(row.id)}", + "summary": summary, + "ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None, + "blocking_reason": _blocking_reason(row), + "next_step_text": _next_step_text(row), + "stage_text": _stage_text(str(row.state or "")), + "priority": priority, + "priority_rank": priority_rank, + "target_url": f"/projects/{project_id}/board" if project_id is not None else None, + "review_summary": row.review_summary, + "execution_result": row.execution_result, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/app/api/ai_steward.py b/backend/app/api/ai_steward.py new file mode 100644 index 00000000..8378a1f4 --- /dev/null +++ b/backend/app/api/ai_steward.py @@ -0,0 +1,1430 @@ +""" +AI Steward API - 主动式 AI 管家接口 + +核心功能: +1. 零配置项目创建 +2. 主动信号捕获 +3. 认知冲突检测 +4. 阻塞自动解决 +5. 个性化进度简报 +""" +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any, Literal +from datetime import datetime, timezone, timedelta +import json + +from ..core.response import success_response +from ..database import get_db +from ..dependencies.auth import get_current_user +from ..dependencies.permissions import require_manager_user +from ..dependencies.tenant import get_tenant_id +from ..models import ProjectSignalLink, Signal, User +from ..services.ai_steward import get_ai_steward_service, AIStewardService +from ..services.signal_capture import ProactiveSignalCaptureService +from ..services.cognitive_conflict import get_cognitive_conflict_service +from ..services.blocker_detection import get_blocker_detection_service +from ..services.daily_briefing import get_daily_briefing_service +from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType +from ..services.adaptive_rag_service import AdaptiveRAGService +from ..services.ai_orchestration_run_service import AIOrchestrationRunService +from ..services.openclaw_commander_service import OpenClawCommanderService +from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService +from ..schemas.rag import RAGAskRequest + + +router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +async def _ensure_project_signal_link( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + signal_id: int, + link_type: str = "related", +) -> None: + existing = ( + await db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == int(tenant_id), + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return + db.add( + ProjectSignalLink( + tenant_id=int(tenant_id), + project_id=int(project_id), + signal_id=int(signal_id), + link_type=link_type, + relevance_score=1.0, + ) + ) + + +# ==================== Request/Response Models ==================== + +class ProjectFromTextRequest(BaseModel): + """零配置项目创建请求""" + input_text: str = Field( + ..., + description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线", + min_length=10, + max_length=500 + ) + + +class ProjectFromTextResponse(BaseModel): + """零配置项目创建响应""" + project: Dict[str, Any] + signal: Dict[str, Any] + charter: Dict[str, Any] + tasks: List[Dict[str, Any]] + risks: List[Dict[str, Any]] + analysis: Dict[str, Any] + + +class CognitiveConflictResponse(BaseModel): + """认知冲突响应""" + id: str + type: str + description: str + severity: str + resolution_suggestion: Optional[str] = None + related_users: List[str] + + +class DailyBriefingResponse(BaseModel): + """每日简报响应""" + date: str + total_updates: int + your_updates: int + relevant_changes: List[Dict[str, Any]] + risk_alerts: List[Dict[str, Any]] + alignment_needed: List[Dict[str, Any]] + + +class FeishuCaptureRequest(BaseModel): + """飞书信号捕获请求""" + group_chat_id: str + message_content: str + sender: str + feishu_message_id: Optional[str] = None + + +class BrowserCaptureRequest(BaseModel): + """浏览器信号捕获请求""" + user_id: int + url: str + title: str + content_preview: str + + +class GitHubCaptureRequest(BaseModel): + """GitHub信号捕获请求""" + repo_url: str + event_type: str + event_data: Dict[str, Any] + + +class ConflictDetectRequest(BaseModel): + """认知冲突检测请求""" + project_id: int + detect_type: str = "all" # all, charter, definition + + +class OpenClawCommandDispatchRequest(BaseModel): + """OpenClaw 指挥层任务分配请求""" + project_id: int = Field(..., ge=1) + instruction: str = Field(..., min_length=4, max_length=2000) + dry_run: bool = False + max_operations: int = Field(default=8, ge=1, le=20) + response_mode: Literal["fast", "complete"] = "fast" + + +class SignalSourceOnboardRequest(BaseModel): + project_id: int = Field(..., ge=1) + instruction: str = Field(..., min_length=6, max_length=2000) + dry_run: bool = False + response_mode: Literal["fast", "complete"] = "fast" + + +class SignalSourceListResponse(BaseModel): + items: List[Dict[str, Any]] + + +def _parse_related_users(raw_value: Any) -> List[str]: + """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" + if raw_value is None: + return [] + if isinstance(raw_value, list): + return [str(item) for item in raw_value if item is not None] + if isinstance(raw_value, (bytes, bytearray)): + raw_value = raw_value.decode("utf-8", errors="ignore") + if isinstance(raw_value, str): + text = raw_value.strip() + if not text: + return [] + try: + parsed = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + if "," in text: + return [v.strip() for v in text.split(",") if v.strip()] + return [text] + if isinstance(parsed, list): + return [str(item) for item in parsed if item is not None] + if parsed is None: + return [] + return [str(parsed)] + return [str(raw_value)] + + +# ==================== Zero-Config Project Creation ==================== + +@router.post("/rag/ask") +async def ask_ai_steward_rag( + request: RAGAskRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + result = await service.answer_question( + question=request.question, + asked_by=int(current_user.id), + asked_via="pmflow", + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_id=request.session_id, + force_new_session=bool(request.new_session), + thinking_mode=request.thinking_mode, + action_mode=request.action_mode, + sync_requirement=bool(request.sync_requirement), + ) + return success_response(result) + + +@router.post("/command/dispatch") +async def dispatch_openclaw_command( + request: OpenClawCommandDispatchRequest, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + service = OpenClawCommanderService(db) + try: + result = await service.dispatch_command( + tenant_id=effective_tenant_id, + operator_user=current_user, + project_id=request.project_id, + instruction=request.instruction, + dry_run=request.dry_run, + max_operations=request.max_operations, + response_mode=request.response_mode, + ) + except ValueError as exc: + detail = str(exc) + if detail == "project not found": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + return success_response(result) + + +@router.post("/signal-sources/onboard") +async def onboard_signal_source( + request: SignalSourceOnboardRequest, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + signal = Signal( + tenant_id=effective_tenant_id, + source="PMFlow", + content=f"[PMFlow用户#{int(current_user.id)}]: {request.instruction[:1800]}", + impact_score=0.7, + priority="medium", + processed=False, + strategic_analysis=json.dumps( + { + "source": "pmflow_manual_signal_source_onboard", + "instruction": request.instruction[:2000], + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ensure_ascii=False, + ), + tasks_generated=json.dumps([], ensure_ascii=False), + matched_project_id=int(request.project_id), + title=(request.instruction[:80] or "新增信号源"), + signal_type="新需求", + summary=request.instruction[:300], + reasoning=json.dumps(["来自 AI Steward 手动信号源接入请求。"], ensure_ascii=False), + confidence=0.82, + recommended_action="enter_only", + status="待处理", + ) + if not request.dry_run: + db.add(signal) + await db.flush() + await _ensure_project_signal_link( + db, + tenant_id=effective_tenant_id, + project_id=int(request.project_id), + signal_id=int(signal.id), + ) + await db.commit() + await db.refresh(signal) + signal_id_value = int(signal.id) if not request.dry_run and getattr(signal, "id", None) is not None else None + + service = SignalSourceOrchestrationService(db) + result = await service.orchestrate_from_signal( + tenant_id=effective_tenant_id, + project_id=int(request.project_id), + signal_id=signal_id_value, + initiating_text=request.instruction, + requested_via="pmflow", + operator_user_id=int(current_user.id), + dry_run=bool(request.dry_run), + response_mode=request.response_mode, + ) + return success_response( + { + "signal_id": signal_id_value, + **result, + } + ) + + +@router.get("/orchestration-runs/{trace_id}") +async def get_orchestration_run( + trace_id: str, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + row = await AIOrchestrationRunService(db).get_run( + tenant_id=effective_tenant_id, + trace_id=str(trace_id or "").strip(), + ) + if row is None: + raise HTTPException(status_code=404, detail="orchestration run not found") + return success_response(AIOrchestrationRunService.serialize_run(row)) + + +@router.get("/projects/{project_id}/signal-sources") +async def list_project_signal_sources( + project_id: int, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + items = await SignalSourceOrchestrationService(db).list_project_sources( + tenant_id=effective_tenant_id, + project_id=int(project_id), + ) + return success_response(SignalSourceListResponse(items=items).model_dump()) + +@router.post("/project-from-text", response_model=ProjectFromTextResponse) +async def create_project_from_text( + request: ProjectFromTextRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 零配置项目创建 - 从一句话生成完整项目 + + 示例输入: + ``` + 我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线 + ``` + + AI 将自动生成: + - 完整的项目配置 + - 竞品分析信号 + - 项目宪章 + - 任务链(含依赖关系) + - 风险预警规则 + """ + try: + service = get_ai_steward_service(db) + + result = await service.create_project_from_natural_language( + input_text=request.input_text, + creator_id=int(current_user.id), + tenant_id=tenant_id + ) + + return ProjectFromTextResponse(**result) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"项目创建失败: {str(e)}" + ) + + +# ==================== Signal Capture Endpoints ==================== + +@router.post("/signal/capture-feishu") +async def capture_feishu_signal( + request: FeishuCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从飞书群聊捕获信号 + + AI 会自动判断消息是否包含有价值的项目或技术信息, + 如果是,则创建信号并自动关联到相关项目 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_feishu( + group_chat_id=request.group_chat_id, + message_content=request.message_content, + sender=request.sender, + tenant_id=tenant_id, + feishu_message_id=request.feishu_message_id, + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}") + + +@router.post("/signal/capture-browser") +async def capture_browser_signal( + request: BrowserCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从浏览器标签页捕获信号 + + 监控用户访问的技术文档、竞品页面、GitHub issues等 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_browser( + user_id=request.user_id, + url=request.url, + title=request.title, + content_preview=request.content_preview, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"浏览器信号捕获失败: {str(e)}") + + +@router.post("/signal/capture-github") +async def capture_github_signal( + request: GitHubCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从GitHub事件捕获信号 + + 监控竞品仓库更新、上游依赖更新等 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_github( + repo_url=request.repo_url, + event_type=request.event_type, + event_data=request.event_data, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"GitHub信号捕获失败: {str(e)}") + + +@router.post("/signal/start-hn-monitor") +async def start_hn_monitor( + background_tasks: BackgroundTasks, + interval_minutes: int = 30, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 启动 HackerNews 定时监控(后台任务) + + 定期抓取 HackerNews 首页,分析技术趋势 + """ + async def run_monitor(): + service = ProactiveSignalCaptureService(db) + await service.start_hackernews_monitor(interval_minutes=interval_minutes, tenant_id=tenant_id) + + background_tasks.add_task(run_monitor) + return { + "status": "started", + "message": f"HackerNews 监控已启动,间隔: {interval_minutes} 分钟" + } + + +# ==================== Cognitive Conflict Endpoints ==================== + +@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse]) +async def get_cognitive_conflicts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + project_id: Optional[int] = None +): + """ + 获取认知冲突列表 + + AI 会自动识别团队中的隐性认知差异,例如: + - 产品说"用户留存提升10%",开发实现的是"DAU提升10%" + - 双方没有争论,但目标不一致,AI 会主动识别并推送对齐提醒 + """ + from ..models import CognitiveConflict, TruthNode + from sqlalchemy.future import select + + query = select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == 'active' + ) + + if project_id: + # 通过 truth_node 关联到项目 + query = query.join( + TruthNode, CognitiveConflict.truth_node_id == TruthNode.id + ).where( + TruthNode.project_id == project_id + ) + + result = await db.execute(query) + conflicts = result.scalars().all() + + return [ + CognitiveConflictResponse( + id=str(c.id), + type=c.conflict_type, + description=c.description, + severity="high" if "critical" in (c.description or "").lower() else "medium", + related_users=_parse_related_users(c.involved_user_ids), + ) + for c in conflicts + ] + + +@router.post("/conflicts/detect") +async def detect_conflicts( + request: ConflictDetectRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测项目中的认知冲突 + + 支持检测类型: + - all: 全部检测(宪章冲突 + 定义冲突) + - charter: 仅检测宪章中的潜在冲突 + - definition: 仅检测用户定义冲突 + """ + try: + service = get_cognitive_conflict_service(db) + + detected_conflicts = [] + + if request.detect_type in ["all", "charter"]: + charter_conflicts = await service.detect_charter_conflicts( + project_id=request.project_id, + tenant_id=tenant_id + ) + detected_conflicts.extend(charter_conflicts) + + if request.detect_type in ["all", "definition"]: + definition_conflicts = await service.detect_user_definition_conflicts( + project_id=request.project_id, + tenant_id=tenant_id + ) + detected_conflicts.extend(definition_conflicts) + + return { + "project_id": request.project_id, + "detected_count": len(detected_conflicts), + "conflicts": detected_conflicts + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"冲突检测失败: {str(e)}") + + +@router.post("/conflicts/{conflict_id}/propose-resolution") +async def propose_conflict_resolution( + conflict_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + AI 生成认知冲突解决方案 + + 为冲突双方生成对齐方案: + 1. 分析冲突根因 + 2. 提出妥协方案 + 3. 生成可执行的对齐计划 + """ + try: + service = get_cognitive_conflict_service(db) + resolution = await service.propose_alignment_resolution( + conflict_id=conflict_id, + tenant_id=tenant_id + ) + return resolution + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"方案生成失败: {str(e)}") + + +@router.post("/conflicts/{conflict_id}/align") +async def trigger_async_alignment( + conflict_id: int, + user1_id: int, + user2_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 触发异步 1v1 认知对齐 + + 为冲突双方创建独立的对齐任务: + 1. 发送对齐邀请 + 2. 提供背景信息 + 3. 收集双方观点 + 4. AI 生成共识方案 + """ + try: + service = get_cognitive_conflict_service(db) + alignment = await service.trigger_async_alignment( + conflict_id=conflict_id, + user1_id=user1_id, + user2_id=user2_id, + tenant_id=tenant_id + ) + return alignment + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"对齐触发失败: {str(e)}") + + +@router.post("/cognitive-conflicts/{conflict_id}/resolve") +async def resolve_cognitive_conflict( + conflict_id: int, + resolution_note: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 解决认知冲突 + + AI 会生成解决方案建议,双方确认后标记为已解决 + """ + from ..models import CognitiveConflict + from sqlalchemy.future import select + + result = await db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = result.scalar_one_or_none() + + if not conflict: + raise HTTPException(status_code=404, detail="冲突记录不存在") + + conflict.status = 'resolved' + conflict.resolved_at = _utc_now_naive() + conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}" + + await db.commit() + + return {"message": "冲突已标记为已解决"} + + +@router.get("/conflicts/task-mismatch/{task_id}") +async def check_task_charter_mismatch( + task_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测任务与宪章的一致性 + + 检查: + 1. 任务描述是否与宪章目标一致 + 2. 技术选型是否符合约束 + 3. 是否偏离项目方向 + """ + from ..models import Task, TruthNode + from sqlalchemy.future import select + + try: + # 获取任务 + result = await db.execute( + select(Task).where( + Task.id == task_id, + Task.tenant_id == tenant_id + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # 获取宪章 + charter_result = await db.execute( + select(TruthNode).where( + TruthNode.id == task.truth_node_id, + TruthNode.tenant_id == tenant_id + ) + ) + charter = charter_result.scalar_one_or_none() + + if not charter: + raise HTTPException(status_code=404, detail="宪章不存在") + + # 检测一致性 + service = get_cognitive_conflict_service(db) + mismatch = await service.detect_task_charter_mismatch( + task=task, + charter_content=charter.content, + tenant_id=tenant_id + ) + + if mismatch: + return { + "is_aligned": False, + "mismatch": mismatch + } + + return { + "is_aligned": True, + "message": "任务与宪章一致" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}") + + +# ==================== Other AI Steward Endpoints ==================== + +@router.get("/daily-briefing") +async def get_daily_briefing( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取个性化每日简报 + + AI 为每个角色生成定制化简报,只包含: + - 与你相关的任务变更 + - 风险提醒 + - 需要对齐的事项 + - 高影响信号 + - 团队更新 + + 彻底过滤无关信息,替代传统日报/周报 + """ + try: + service = get_daily_briefing_service(db) + briefing = await service.generate_personalized_briefing( + user_id=int(current_user.id), + tenant_id=tenant_id + ) + return briefing + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"生成简报失败: {str(e)}" + ) + + +@router.get("/daily-briefing/team/{project_id}") +async def get_team_briefing( + project_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取团队简报(给管理者) + + 包含: + - 整体进度 + - 每个成员的工作量 + - 风险和阻塞 + - 需要决策的事项 + """ + try: + service = get_daily_briefing_service(db) + briefing = await service.generate_team_briefing( + project_id=project_id, + tenant_id=tenant_id + ) + return briefing + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"生成团队简报失败: {str(e)}" + ) + + +@router.post("/analyze-blocker") +async def analyze_blocker( + code_context: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 分析代码阻塞 + + 当开发在代码注释中提到「这个接口达不到效果」时, + AI 自动触发分析,推荐替代方案或生成代码片段 + """ + from ..services.llm import LLMService + + service = get_ai_steward_service(db) + + prompt = f"""分析以下代码/注释中提到的问题,提供解决方案: + +代码上下文: +{code_context} + +请提供: +1. 问题分析 +2. 推荐的替代方案 +3. 示例代码(如适用) + +以 JSON 格式返回。""" + + try: + llm = LLMService() + response = await llm.generate( + prompt, + db=db, + tenant_id=tenant_id, + ) + + return { + 'analysis': response, + 'suggestions': [], + 'auto_generated': True + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"分析失败: {str(e)}" + ) + + +# ==================== Blocker Detection Endpoints ==================== + +class CodeCommentScanRequest(BaseModel): + """代码注释扫描请求""" + code_content: str + file_path: str + line_number: int + project_id: int + + +class BlockerResolveRequest(BaseModel): + """阻塞解决请求""" + blocker_id: int + resolution_note: str + + +@router.post("/blockers/scan-comment") +async def scan_code_comment_for_blockers( + request: CodeCommentScanRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 扫描代码注释,检测阻塞 + + 自动识别阻塞模式: + - "等待 X 接口完成" + - "这个 API 达不到效果" + - "由于 Y 的限制,暂时跳过" + + 如果检测到阻塞,AI 会自动: + 1. 分析严重程度 + 2. 生成解决方案建议 + 3. 创建追踪任务(如需要) + """ + try: + service = get_blocker_detection_service(db) + result = await service.scan_code_comment( + code_content=request.code_content, + file_path=request.file_path, + line_number=request.line_number, + project_id=request.project_id, + user_id=int(current_user.id), + tenant_id=tenant_id + ) + + if result: + return { + 'status': 'blocker_detected', + 'blocker': result + } + + return { + 'status': 'no_blocker', + 'message': '未检测到阻塞' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"扫描失败: {str(e)}" + ) + + +@router.post("/blockers/detect-tasks") +async def detect_task_blockers( + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测所有任务中的阻塞 + + 扫描所有超过3天未更新的进行中任务, + 使用 AI 判断是否存在阻塞 + """ + try: + service = get_blocker_detection_service(db) + blockers = await service.detect_task_blockers(tenant_id=tenant_id) + + return { + 'total_detected': len(blockers), + 'blockers': blockers + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"检测失败: {str(e)}" + ) + + +@router.get("/blockers/active") +async def get_active_blockers( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取所有活跃的阻塞 + + 返回所有未解决的阻塞,按严重程度排序 + """ + try: + service = get_blocker_detection_service(db) + blockers = await service.get_active_blockers(tenant_id=tenant_id) + + # 按严重程度排序 + severity_order = {'high': 0, 'medium': 1, 'low': 2} + blockers.sort(key=lambda b: severity_order.get(b['severity'], 1)) + + return { + 'total': len(blockers), + 'blockers': blockers + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"获取失败: {str(e)}" + ) + + +@router.post("/blockers/resolve") +async def resolve_blocker( + request: BlockerResolveRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 标记阻塞为已解决 + + 记录解决方案并关闭阻塞记录 + """ + try: + service = get_blocker_detection_service(db) + result = await service.resolve_blocker( + blocker_id=request.blocker_id, + resolution_note=request.resolution_note, + tenant_id=tenant_id + ) + + return result + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"解决失败: {str(e)}" + ) + + +@router.get("/stats") +async def get_ai_steward_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取 AI Steward 统计数据 + + 展示 AI 为团队节省的工作量: + - 自动捕获的信号数 + - 避免的认知冲突数 + - 自动解决的阻塞数 + - 节省的会议时间(小时) + """ + from ..models import Signal, CognitiveConflict + from sqlalchemy import func, text + + # 统计信号数量 + week_ago = _utc_now_naive() - timedelta(days=7) + month_ago = _utc_now_naive() - timedelta(days=30) + + signal_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.created_at >= week_ago + ) + ) + signals_captured = signal_result.scalar() or 0 + + # 统计高影响信号 + high_impact_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.impact_score >= 0.8, + Signal.created_at >= week_ago + ) + ) + high_impact_count = high_impact_result.scalar() or 0 + + # 统计认知冲突 + conflict_result = await db.execute( + select(func.count(CognitiveConflict.id)).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status.in_(['detected', 'active']) + ) + ) + active_conflicts = conflict_result.scalar() or 0 + + # 统计已解决的冲突 + resolved_conflict_result = await db.execute( + select(func.count(CognitiveConflict.id)).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == 'resolved', + CognitiveConflict.resolved_at >= month_ago + ) + ) + conflicts_resolved = resolved_conflict_result.scalar() or 0 + + # 统计阻塞 + blocker_result = await db.execute( + text(""" + SELECT COUNT(*) FROM cognitive_conflicts + WHERE tenant_id = :tenant_id + AND conflict_type IN ('code_blocker', 'task_blocker') + AND status = 'detected' + """), + {"tenant_id": tenant_id} + ) + active_blockers = blocker_result.scalar() or 0 + + # 活跃项目数 + from ..models import Project + project_result = await db.execute( + select(func.count(Project.id)).where( + Project.tenant_id == tenant_id, + Project.status == 'active' + ) + ) + active_projects = project_result.scalar() or 0 + + # 估算节省时间(每个冲突/阻塞避免约2小时会议) + hours_saved = conflicts_resolved * 2 + + stats = { + 'signals_captured': signals_captured, + 'high_impact_signals': high_impact_count, + 'active_conflicts': active_conflicts, + 'conflicts_resolved': conflicts_resolved, + 'active_blockers': active_blockers, + 'active_projects': active_projects, + 'hours_saved': hours_saved, + 'last_updated': datetime.now(timezone.utc).isoformat() + } + + return stats + + +@router.post("/sync-signal") +async def sync_external_signal( + source: str, + content: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 手动同步外部信号 + + 支持从以下来源同步: + - 飞书群聊 + - GitHub commits/PRs + - 浏览器标签页 + """ + from ..models import Signal + from ..services.ai_steward import get_ai_steward_service + + service = get_ai_steward_service(db) + + # AI 分析信号重要性 + prompt = f"""分析以下信号的重要性: + +来源:{source} +内容:{content} + +请评估: +1. impact_score (0-1) +2. priority (low/medium/high/critical) +3. 是否需要立即行动 + +以 JSON 格式返回。""" + + try: + from ..services.llm import LLMService + llm = LLMService() + response = await llm.generate( + prompt, + db=db, + tenant_id=tenant_id, + ) + + # 创建信号 + signal = Signal( + source=source, + content=content, + impact_score=0.7, + priority='medium', + processed=False, + tenant_id=tenant_id + ) + db.add(signal) + await db.commit() + + return { + 'signal_id': str(signal.id), + 'analysis': response, + 'status': 'captured' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"信号同步失败: {str(e)}" + ) + + +# ==================== Event-Driven Workflow Endpoints ==================== + +class WorkflowTriggerRequest(BaseModel): + """工作流触发请求""" + event_type: str + event_data: Dict[str, Any] + + +@router.post("/workflow/trigger") +async def trigger_workflow( + request: WorkflowTriggerRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 触发事件驱动工作流 + + 支持的事件类型: + - high_impact_signal: 高影响信号到达 + - blocker_detected: 阻塞检测 + - cognitive_conflict: 认知冲突 + - tech_stack_change: 技术栈变更 + - competitor_update: 竞品动态 + - task_completed: 任务完成 + - task_blocked: 任务阻塞 + - project_risk: 项目风险 + + 工作流会自动: + 1. 调整项目优先级 + 2. 创建响应任务 + 3. 通知相关角色 + 4. 更新依赖关系 + """ + try: + engine = get_event_workflow_engine(db) + result = await engine.trigger_workflow( + event_type=request.event_type, + event_data=request.event_data, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"工作流触发失败: {str(e)}" + ) + + +@router.post("/workflow/signal/{signal_id}") +async def trigger_signal_workflow( + signal_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 当接收到高影响信号时自动触发工作流 + + 检查信号的 impact_score,如果超过阈值(0.85), + 自动触发工作流来调整相关项目的优先级 + """ + from ..models import Signal + from sqlalchemy.future import select + + signal_result = await db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id + ) + ) + signal = signal_result.scalar_one_or_none() + + if not signal: + raise HTTPException(status_code=404, detail="信号不存在") + + # 检查是否达到阈值 + if signal.impact_score < 0.85: + return { + 'status': 'ignored', + 'reason': f'信号影响分数 {signal.impact_score} 低于阈值 0.85' + } + + # 触发高影响信号工作流 + engine = get_event_workflow_engine(db) + result = await engine.trigger_workflow( + event_type=WorkflowEventType.HIGH_IMPACT_SIGNAL, + event_data={'signal_id': signal_id}, + tenant_id=tenant_id + ) + + return result + + +@router.get("/workflow/status") +async def get_workflow_status( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取工作流状态 + + 显示最近触发的工作流及其结果 + """ + from ..models import AICallLog + from sqlalchemy.future import select + + # 获取最近的工作流记录 + # 这里简化处理,实际应该有专门的工作流记录表 + return { + 'active_workflows': [], + 'recent_triggers': [], + 'message': '工作流状态追踪' + } + + +@router.post("/workflow/auto-enable") +async def enable_auto_workflow( + project_id: int, + enable: bool = True, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 启用/禁用项目的自动工作流 + + 启用后,当以下情况发生时会自动触发工作流: + - 高影响信号到达(>0.85) + - 任务阻塞检测 + - 认知冲突检测 + """ + from ..models import Project + from sqlalchemy.future import select + + project_result = await db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id + ) + ) + project = project_result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 这里应该设置项目的 auto_workflow 标志 + # 由于 Project 模型可能没有这个字段,暂时返回成功 + return { + 'project_id': project_id, + 'auto_workflow': enable, + 'message': f'自动工作流已{"启用" if enable else "禁用"}' + } + + +# ==================== AI Config Suggestion ==================== + +@router.get("/config/suggest") +async def get_ai_config_suggestion( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + AI 建议配置方案 + + 根据团队现状,智能推荐应该优先配置哪些集成源 + """ + from ..models import Integration, Project, Signal + from sqlalchemy.future import select + from sqlalchemy import func + + # 检查已配置的集成 + configured_sources = [] + + # 检查飞书配置 + feishu_result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu', + Integration.is_active == True + ) + ) + if feishu_result.scalar_one_or_none(): + configured_sources.append('feishu') + + # 检查活跃项目数 + project_result = await db.execute( + select(func.count(Project.id)).where( + Project.tenant_id == tenant_id, + Project.status == 'active' + ) + ) + active_projects = project_result.scalar() or 0 + + # 检查近期信号数量 + signal_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.created_at >= (_utc_now_naive() - timedelta(days=7)) + ) + ) + recent_signals = signal_result.scalar() or 0 + + # AI 分析并推荐配置 + recommended = [] + reasoning_parts = [] + + # 根据项目数量推荐 + if active_projects > 0: + if 'feishu' not in configured_sources: + recommended.append('feishu') + reasoning_parts.append(f"您有 {active_projects} 个活跃项目,建议配置飞书群聊集成以自动捕获需求讨论") + + # 根据信号量推荐 + if recent_signals < 10: + if 'hn' not in [s.split('_')[0] for s in configured_sources]: + recommended.append('hn') + reasoning_parts.append("近期信号较少,建议启用 HackerNews 监控获取更多技术趋势") + + # 默认推荐 + if not recommended: + if 'feishu' not in configured_sources: + recommended.append('feishu') + if 'hn' not in [s.split('_')[0] for s in configured_sources]: + recommended.append('hn') + reasoning_parts.append("建议配置基础集成源以启动 AI 主动捕获能力") + + reasoning = ";".join(reasoning_parts) if reasoning_parts else "所有基础集成已配置,AI 主动捕获能力已全面启用" + + # 估算影响 + impact_levels = { + 0: "暂无明显影响", + 1: "预计每周节省 2 小时手动录入时间", + 2: "预计每周节省 4 小时手动录入时间,捕获 10+ 个需求信号", + 3: "预计每周节省 6 小时会议时间,自动对齐 5+ 个认知冲突", + 4: "预计每周节省 8 小时会议时间,主动捕获 20+ 个需求信号" + } + impact = impact_levels.get(len(recommended), impact_levels[4]) + + return { + 'recommended_sources': recommended, + 'reasoning': reasoning, + 'estimated_impact': impact, + 'current_config': { + 'feishu_configured': 'feishu' in configured_sources, + 'hn_configured': any('hn' in s for s in configured_sources), + 'github_configured': any('github' in s for s in configured_sources), + 'browser_installed': False # 需要前端检测 + } + } diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py new file mode 100644 index 00000000..0eb0ec0a --- /dev/null +++ b/backend/app/api/feishu.py @@ -0,0 +1,376 @@ +"""Feishu Integration API - 飞书集成接口。""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from ..database import get_db +from ..dependencies.auth import get_current_user +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..services.auth import has_any_role +from ..services.feishu_integration import get_feishu_service +from ..services.feishu_long_connection import get_feishu_long_connection_manager + + +router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) + + +def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]: + code = str(long_status.get("last_error_code") or "").strip().lower() + last_error = str(long_status.get("last_error") or "").strip() + retryable = bool(long_status.get("last_error_retryable")) + if code in {"network_error", "thread_exited"} and not retryable: + return ( + "长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接", + last_error or None, + ) + if code == "auth_failed": + return ( + "长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整", + last_error or None, + ) + if code == "network_error": + return ( + "长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常", + last_error or None, + ) + if code == "sdk_missing": + return ("长连接 SDK 未安装(缺少 lark-oapi)", None) + if code == "thread_exited": + return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None) + if last_error: + return ("长连接未建立,请检查配置与后端日志", last_error) + return ("长连接未建立,请检查配置后重试", None) + + +# ==================== Request/Response Models ==================== + +class FeishuConfigRequest(BaseModel): + """飞书配置请求""" + app_id: str = Field(..., description="飞书 App ID") + app_secret: str = Field(..., description="飞书 App Secret") + encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") + verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") + event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection") + + +class FeishuSendMessageRequest(BaseModel): + """发送消息请求""" + chat_id: str = Field(..., description="群聊 ID") + message: str = Field(..., description="消息内容") + + +class FeishuSendCardRequest(BaseModel): + """发送卡片请求""" + chat_id: str = Field(..., description="群聊 ID") + title: str = Field(..., description="卡片标题") + content: List[Dict[str, Any]] = Field(..., description="卡片内容") + + +# ==================== Configuration Endpoints ==================== + +@router.post("/config") +async def save_feishu_config( + request: FeishuConfigRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 保存飞书配置 + + 配置信息说明: + - app_id: 飞书应用的 App ID,从飞书开放平台获取 + - app_secret: 飞书应用的 App Secret + + 获取步骤: + 1. 访问 https://open.feishu.cn/ + 2. 创建企业自建应用 + 3. 在「凭证与基础信息」页面获取 App ID 和 App Secret + 4. 添加权限:im:message, im:message:group_at_msg, im:chat + 5. 订阅事件:接收消息 v1 (message.receive_v1) + """ + # 检查用户权限 + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成") + + try: + event_mode = "long_connection" + + service = get_feishu_service(db) + await service.save_config( + app_id=request.app_id, + app_secret=request.app_secret, + encrypt_key="", + verification_token="", + event_mode=event_mode, + tenant_id=tenant_id + ) + manager = get_feishu_long_connection_manager() + if manager: + await manager.restart() + + next_steps = [ + "1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)", + "2. 当前模式:long_connection", + "3. 后端已自动重启飞书长连接", + ] + + return { + 'status': 'success', + 'message': '飞书配置已保存,并已切换到长连接模式', + 'next_steps': next_steps + } + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"保存配置失败: {str(e)}" + ) + + +@router.get("/config") +async def get_feishu_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """获取飞书配置状态。管理员可获取完整配置用于回填修改表单。""" + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + manager = get_feishu_long_connection_manager() + can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) + + return { + 'is_configured': service.is_configured(), + 'app_id': service.app_id, + 'app_secret': service.app_secret if can_view_secrets else None, + 'event_mode': "long_connection", + 'long_connection': manager.status() if manager else None, + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"获取配置失败: {str(e)}" + ) + + +@router.delete("/config") +async def delete_feishu_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """删除飞书配置""" + from ..models import Integration + from sqlalchemy.future import select + + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以删除配置") + + try: + result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu' + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.is_active = False + await db.commit() + manager = get_feishu_long_connection_manager() + if manager: + await manager.restart() + + return {'status': 'success', 'message': '飞书配置已删除'} + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"删除配置失败: {str(e)}" + ) + + +@router.get("/long-connection/status") +async def feishu_long_connection_status( + current_user: User = Depends(get_current_user), +): + """查询飞书长连接状态。""" + manager = get_feishu_long_connection_manager() + if not manager: + return { + "status": "error", + "message": "Feishu 长连接管理器未初始化", + "mode": "long_connection", + "data": None, + } + return { + "status": "success", + "mode": "long_connection", + "data": manager.status(), + } + + +@router.post("/long-connection/restart") +async def restart_feishu_long_connection( + current_user: User = Depends(get_current_user), +): + """手动重启飞书长连接。""" + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接") + + manager = get_feishu_long_connection_manager() + if not manager: + raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化") + + await manager.restart() + return { + "status": "success", + "message": "Feishu 长连接已重启", + "mode": "long_connection", + "data": manager.status(), + } + +# ==================== Message Endpoints ==================== + +@router.post("/send-message") +async def send_feishu_message( + request: FeishuSendMessageRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 发送文本消息到飞书群 + + 用于发送简报、通知等 + """ + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + + result = await service.send_message_to_group( + chat_id=request.chat_id, + message=request.message + ) + + return { + 'status': 'success', + 'result': result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"发送消息失败: {str(e)}" + ) + + +@router.post("/send-card") +async def send_feishu_card( + request: FeishuSendCardRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 发送富文本卡片到飞书群 + + 用于发送格式化的每日简报、风险预警等 + """ + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + + result = await service.send_rich_card( + chat_id=request.chat_id, + title=request.title, + content=request.content + ) + + return { + 'status': 'success', + 'result': result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"发送卡片失败: {str(e)}" + ) + + +# ==================== Test Endpoints ==================== + +@router.post("/test-connection") +async def test_feishu_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """测试飞书连接是否正常""" + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + manager = get_feishu_long_connection_manager() + + if not service.is_configured(): + return { + 'status': 'error', + 'message': '飞书未配置' + } + + # 尝试获取 tenant_access_token 测试连接 + try: + access_token = await service._get_tenant_access_token() + event_mode = "long_connection" + long_status = manager.status() if manager else None + + if not long_status: + return { + 'status': 'error', + 'message': '长连接管理器未初始化', + 'event_mode': event_mode, + } + if not long_status.get("sdk_available"): + return { + 'status': 'error', + 'message': '长连接 SDK 未安装(缺少 lark-oapi)', + 'event_mode': event_mode, + 'long_connection': long_status, + } + if not long_status.get("thread_alive"): + message, detail = _build_long_connection_failure(long_status) + return { + 'status': 'error', + 'message': message, + 'event_mode': event_mode, + 'long_connection': long_status, + 'detail': detail, + } + + return { + 'status': 'success', + 'message': '飞书连接正常', + 'has_token': bool(access_token), + 'mode': 'long_connection', + 'event_mode': event_mode, + 'long_connection': long_status, + } + except Exception as token_error: + return { + 'status': 'error', + 'message': f'获取 Token 失败: {str(token_error)}', + 'hint': '请检查 App ID 和 App Secret 是否正确' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"测试连接失败: {str(e)}" + ) diff --git a/backend/app/api/model_capability.py b/backend/app/api/model_capability.py new file mode 100644 index 00000000..7c788788 --- /dev/null +++ b/backend/app/api/model_capability.py @@ -0,0 +1,491 @@ +"""模型能力矩阵 API""" + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import ErrorCode, error_response, success_response +from ..database import get_db +from ..dependencies import get_current_user, get_tenant_id +from ..models import Experiment, TruthNode, User +from ..schemas.model_capability import ( + CostOptimizationAnalyzeRequest, + ExperimentSyncRequest, + ExperimentSyncReviewRequest, + ExperimentSyncRollbackRequest, + GenerateMigrationTasksRequest, + LifecycleImpactRequest, + ValidateMatchRequest, +) +from ..services.ai_service import get_ai_service +from ..services.cost_optimization_service import CostOptimizationService +from ..services.experiment_sync_service import ExperimentSyncService +from ..services.model_capability_service import ModelCapabilityService +from ..services.model_lifecycle_service import ModelLifecycleService + +router = APIRouter(prefix="/model-capability", tags=["model-capability"]) + + +def _build_service(db: AsyncSession) -> ModelCapabilityService: + return ModelCapabilityService(db=db, ai_service=get_ai_service()) + + +@router.post("/validate-match") +async def validate_model_requirement_match( + request: ValidateMatchRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """需求-模型匹配校验""" + _ = current_user + service = _build_service(db) + try: + data = await service.validate_model_requirement_match( + truth_node_id=request.truth_node_id, + model_version_id=request.model_version_id, + tenant_id=tenant_id, + project_id=request.project_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"), + ) + + +@router.get("/capability-gaps") +async def list_capability_gaps( + status: Optional[str] = Query(default=None), + severity: Optional[str] = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """列出能力差距预警""" + _ = current_user + service = _build_service(db) + try: + data = await service.list_capability_gaps( + tenant_id=tenant_id, + status=status, + severity=severity, + limit=limit, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"), + ) + + +@router.get("/cost-tracking/{truth_node_id}") +async def get_cost_tracking( + truth_node_id: int, + period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询成本跟踪与 ROI""" + _ = current_user + service = _build_service(db) + try: + data = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"), + ) + + +@router.get("/cost-optimization/{truth_node_id}") +async def get_cost_optimization( + truth_node_id: int, + period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询成本优化建议(必要时自动触发一次计算)""" + _ = current_user + service = _build_service(db) + try: + tracking = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + + optimization = tracking.get("optimization_suggestions") or {} + if not optimization: + computed = await service.track_cost_and_roi( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + optimization = computed.get("optimization_suggestions", {}) + tracking = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + + return success_response( + data={ + "truth_node_id": truth_node_id, + "period": period, + "optimization_suggestions": optimization, + "roi_score": tracking.get("roi_score"), + "actual_cost_usd": tracking.get("actual_cost_usd", 0.0), + } + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"), + ) + + +@router.post("/lifecycle/assess-impact") +async def assess_model_update_impact( + request: LifecycleImpactRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """评估模型升级对现有需求集合的影响。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.assess_model_update_impact( + old_model_version_id=request.old_model_version_id, + new_model_version_id=request.new_model_version_id, + tenant_id=tenant_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"), + ) + + +@router.get("/lifecycle/impacts") +async def list_lifecycle_impacts( + limit: int = Query(default=50, ge=1, le=200), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """列出模型生命周期影响评估记录。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.list_impacts(tenant_id=tenant_id, limit=limit) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"), + ) + + +@router.post("/lifecycle/{impact_id}/migration-tasks") +async def create_migration_tasks( + impact_id: int, + request: GenerateMigrationTasksRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """基于影响评估自动生成迁移任务建议。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.generate_migration_tasks( + impact_id=impact_id, + tenant_id=tenant_id, + max_tasks=request.max_tasks, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"), + ) + + +@router.post("/experiment-sync") +async def create_experiment_sync( + request: ExperimentSyncRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """创建实验结果同步记录,满足阈值时自动同步。""" + _ = current_user + service = ExperimentSyncService(db) + try: + data = await service.auto_sync_experiment_result( + experiment_id=request.experiment_id, + truth_node_id=request.truth_node_id, + tenant_id=tenant_id, + project_id=request.project_id, + sync_type=request.sync_type, + auto_apply_threshold=request.auto_apply_threshold, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"), + ) + + +@router.get("/experiment-sync") +async def list_experiment_sync_records( + truth_node_id: Optional[int] = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询实验同步记录。""" + _ = current_user + service = ExperimentSyncService(db) + try: + data = await service.list_sync_records( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + limit=limit, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"), + ) + + +@router.post("/experiment-sync/{sync_id}/review") +async def review_experiment_sync( + sync_id: int, + request: ExperimentSyncReviewRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """人工审核实验同步记录。""" + service = ExperimentSyncService(db) + try: + data = await service.review_sync( + sync_id=sync_id, + tenant_id=tenant_id, + reviewer_id=current_user.id, + action=request.action, + review_notes=request.review_notes, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"), + ) + + +@router.post("/experiment-sync/{sync_id}/rollback") +async def rollback_experiment_sync( + sync_id: int, + request: ExperimentSyncRollbackRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """回滚已应用的实验同步。""" + service = ExperimentSyncService(db) + try: + data = await service.rollback_sync( + sync_id=sync_id, + tenant_id=tenant_id, + reviewer_id=current_user.id, + rollback_notes=request.rollback_notes, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"), + ) + + +@router.post("/cost-optimization/analyze") +async def analyze_cost_optimization( + request: CostOptimizationAnalyzeRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """生成并持久化成本优化建议。""" + _ = current_user + service = CostOptimizationService(db) + try: + data = await service.analyze_cost_optimization( + truth_node_id=request.truth_node_id, + tenant_id=tenant_id, + period=request.period, + project_id=request.project_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"), + ) + + +@router.get("/context/projects/{project_id}/truth-nodes") +async def list_project_truth_nodes( + project_id: int, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """获取项目上下文中的宪章列表,用于前端选择器。""" + _ = current_user + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ).order_by(TruthNode.created_at.desc()) + ) + nodes = result.scalars().all() + return success_response( + data={ + "items": [ + { + "id": n.id, + "title": n.title, + "status": n.status, + "node_type": n.node_type, + } + for n in nodes + ] + } + ) + + +@router.get("/context/projects/{project_id}/experiments") +async def list_project_experiments( + project_id: int, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。""" + _ = current_user + truth_ids_result = await db.execute( + select(TruthNode.id).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ) + ) + truth_ids = {int(row[0]) for row in truth_ids_result.all()} + if not truth_ids: + return success_response(data={"items": []}) + + exp_result = await db.execute( + select(Experiment) + .where(Experiment.tenant_id == tenant_id) + .order_by(Experiment.created_at.desc()) + ) + experiments = exp_result.scalars().all() + + def _linked_ids(raw) -> set[int]: + ids = set() + if isinstance(raw, list): + for x in raw: + try: + ids.add(int(x)) + except (TypeError, ValueError): + continue + return ids + + items = [] + for e in experiments: + if e.project_id is not None: + if int(e.project_id) != int(project_id): + continue + items.append( + { + "id": e.id, + "title": e.title or f"Experiment {e.id}", + "status": e.status, + "target_metric": e.target_metric, + } + ) + continue + + # 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。 + if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids): + continue + items.append( + { + "id": e.id, + "title": e.title or f"Experiment {e.id}", + "status": e.status, + "target_metric": e.target_metric, + } + ) + return success_response(data={"items": items}) + diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 00000000..adc542e4 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,386 @@ +"""项目管理 API。""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import and_, delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..dependencies import get_current_user, get_tenant_id, require_roles +from ..models import Project, ProjectSignalLink, Signal, Task, User +from ..services.context_cache import context_cache_service +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence +from ..utils.title import compact_title + +router = APIRouter(prefix="/projects", tags=["projects"]) + +DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} + + +def _as_dict(value: object) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return {} + + +class ProjectCreateRequest(BaseModel): + title: str + description: Optional[str] = None + status: str = "active" + domain_id: Optional[int] = None + assigned_role: Optional[str] = None + + +class ProjectUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + domain_id: Optional[int] = None + assigned_role: Optional[str] = None + + +class LinkSignalRequest(BaseModel): + link_type: Optional[str] = "related" + relevance_score: Optional[float] = 1.0 + + +def _serialize_project_row(project: Project) -> dict: + return { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + "status": project.status, + "domain_id": str(project.domain_id) if project.domain_id is not None else None, + "assigned_role": project.assigned_role, + "signal_count": int(project.signal_count or 0), + "created_at": project.created_at.isoformat() if project.created_at else None, + "updated_at": project.updated_at.isoformat() if project.updated_at else None, + } + + +async def _build_project_summary( + db: AsyncSession, + *, + tenant_id: int, + project: Project, +) -> dict: + base = _serialize_project_row(project) + + signal_count_result = await db.execute( + select(func.count(ProjectSignalLink.id)).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == int(project.id), + ) + ) + ) + signal_count = int(signal_count_result.scalar() or 0) + base["signal_count"] = signal_count + + task_rows = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == int(project.id), + ) + ) + ) + ).scalars().all() + + total_tasks = len(task_rows) + completed_tasks = 0 + in_progress_tasks = 0 + pending_tasks = 0 + task_items: list[dict] = [] + for task in task_rows: + status_value = (task.status or "").lower() + if status_value in DONE_STATUSES: + completed_tasks += 1 + elif status_value in {"in_progress", "review", "blocked"}: + in_progress_tasks += 1 + else: + pending_tasks += 1 + + task_items.append( + { + "task_name": task.title, + "type": "task", + "status": task.status, + "signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "", + } + ) + + base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0 + base["total_tasks"] = total_tasks + base["completed_tasks"] = completed_tasks + base["in_progress_tasks"] = in_progress_tasks + base["pending_tasks"] = pending_tasks + base["tasks"] = task_items + return base + + +async def _get_project_or_404( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> Project: + project = ( + await db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.get("/") +async def list_projects( + summary: Optional[int] = Query(default=0), + status: Optional[str] = Query(default=None), + assigned_role: Optional[str] = Query(default=None), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + query = select(Project).where(Project.tenant_id == tenant_id) + if status: + query = query.where(Project.status == status) + if assigned_role: + query = query.where(Project.assigned_role == assigned_role) + query = query.order_by(Project.updated_at.desc(), Project.id.desc()) + projects = (await db.execute(query)).scalars().all() + + if int(summary or 0) == 1: + rows = [ + await _build_project_summary( + db, + tenant_id=tenant_id, + project=project, + ) + for project in projects + ] + return {"projects": rows} + + return [_serialize_project_row(project) for project in projects] + + +@router.get("/{project_id}") +async def get_project( + project_id: int, + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + return _serialize_project_row(project) + + +@router.post("/") +async def create_project( + body: ProjectCreateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + title = compact_title(body.title, max_len=24) + if not title: + raise HTTPException(status_code=400, detail="title required") + await ensure_project_id_sequence(db) + project = Project( + tenant_id=tenant_id, + name=title, + title=title, + description=body.description, + status=body.status or "active", + domain_id=body.domain_id, + assigned_role=body.assigned_role, + owner_id=int(current_user.id), + signal_count=0, + ) + db.add(project) + await db.flush() + await ensure_project_default_members( + db, + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(current_user.id), + assigned_role=project.assigned_role, + ) + await db.commit() + await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return _serialize_project_row(project) + + +@router.patch("/{project_id}") +async def update_project( + project_id: int, + body: ProjectUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + if body.title is not None: + title = compact_title(body.title, max_len=24) + if not title: + raise HTTPException(status_code=400, detail="title cannot be empty") + project.title = title + project.name = title + if body.description is not None: + project.description = body.description + if body.status is not None: + project.status = body.status + if body.domain_id is not None: + project.domain_id = body.domain_id + if body.assigned_role is not None: + project.assigned_role = body.assigned_role + await db.commit() + await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return _serialize_project_row(project) + + +@router.delete("/{project_id}") +async def delete_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + _ = current_user + await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + await db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ) + ) + ) + await db.execute( + delete(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + ) + ) + ) + await db.execute( + delete(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return {"message": "Project deleted"} + + +@router.post("/{project_id}/signals/{signal_id}") +async def link_signal_to_project( + project_id: int, + signal_id: int, + body: LinkSignalRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + + signal = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + + existing = ( + await db.execute( + select(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if existing: + return { + "id": str(existing.id), + "project_id": str(existing.project_id), + "signal_id": str(existing.signal_id), + "link_type": existing.link_type, + "relevance_score": float(existing.relevance_score or 0.0), + "created_at": existing.created_at.isoformat() if existing.created_at else None, + } + + link = ProjectSignalLink( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + link_type=body.link_type or "related", + relevance_score=float(body.relevance_score or 1.0), + ) + db.add(link) + await db.commit() + await db.refresh(link) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return { + "id": str(link.id), + "project_id": str(link.project_id), + "signal_id": str(link.signal_id), + "link_type": link.link_type, + "relevance_score": float(link.relevance_score or 0.0), + "created_at": link.created_at.isoformat() if link.created_at else None, + } + + +@router.delete("/{project_id}/signals/{signal_id}") +async def unlink_signal_from_project( + project_id: int, + signal_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + _ = current_user + await db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return {"message": "Signal unlinked"} diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py new file mode 100644 index 00000000..11fdc8c4 --- /dev/null +++ b/backend/app/api/rag.py @@ -0,0 +1,570 @@ +"""Adaptive RAG API。""" + +from __future__ import annotations + +import asyncio +import io +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import success_response +from ..database import get_db +from ..dependencies.auth import get_current_user, require_roles +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..schemas.rag import ( + EntityResolutionRequest, + KnowledgeDocCreateRequest, + KnowledgeURLCreateRequest, + MultiStepSearchRequest, + QAExportQuery, + QAFeedbackCreateRequest, + QAListQuery, + QASessionListQuery, + QASessionRenameRequest, + RAGAskRequest, + SymbolSearchRequest, + SymbolSyncRequest, +) +from ..services.adaptive_rag_service import AdaptiveRAGService +from ..services.symbol_index_service import SymbolIndexService + +router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) + + +def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int: + try: + return int(getattr(user, "tenant_id") or fallback_tenant_id or 1) + except Exception: + return int(fallback_tenant_id or 1) + + +def _safe_decode_bytes(raw: bytes) -> str: + for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): + try: + return raw.decode(encoding) + except Exception: + continue + return raw.decode("utf-8", errors="ignore") + + +def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]: + name = str(filename or "uploaded.txt") + suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt" + + if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}: + return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw)) + + if suffix == "pdf": + try: + from pypdf import PdfReader # type: ignore + except Exception as exc: + raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc + reader = PdfReader(io.BytesIO(raw)) + parts: list[str] = [] + for idx, page in enumerate(reader.pages, start=1): + page_text = (page.extract_text() or "").strip() + if not page_text: + continue + # 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。 + parts.append(f"[[PAGE:{idx}]]\n{page_text}") + text = "\n\n".join(parts) + return ("pdf", text) + + if suffix in {"docx", "doc"}: + try: + from docx import Document # type: ignore + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc + doc = Document(io.BytesIO(raw)) + text = "\n".join([p.text for p in doc.paragraphs if p.text]) + return ("word", text) + + raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}") + + +@router.post("/ask") +async def rag_ask( + request: RAGAskRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.answer_question( + question=request.question, + asked_by=int(current_user.id), + asked_via=request.asked_via, + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_id=request.session_id, + force_new_session=bool(request.new_session), + thinking_mode=request.thinking_mode, + action_mode=request.action_mode, + sync_requirement=bool(request.sync_requirement), + ) + return success_response(result) + + +@router.get("/manifest") +async def rag_manifest( + project_id: int | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_project_manifest(project_id=project_id) + return success_response(data) + + +@router.get("/features/catalog") +async def rag_feature_catalog( + query: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_feature_catalog(query=query, limit=limit) + return success_response(data) + + +@router.post("/entity-resolution") +async def rag_entity_resolution( + request: EntityResolutionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.entity_resolution( + question=request.question, + project_id=request.project_id, + limit=request.limit, + ) + return success_response(data) + + +@router.post("/multi-step-search") +async def rag_multi_step_search( + request: MultiStepSearchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.multi_step_search( + question=request.question, + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_state=None, + ) + return success_response(data) + + +@router.post("/symbols/sync") +async def rag_symbols_sync( + request: SymbolSyncRequest, + current_user: User = Depends(require_roles("ADMIN", "BOSS")), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.sync_codebase_symbols( + base_dir=request.base_dir, + max_files=request.max_files, + force_reindex=request.force_reindex, + ) + return success_response(data) + + +@router.post("/symbols/search") +async def rag_symbols_search( + request: SymbolSearchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.search_symbols( + query=request.query, + language=request.language, + symbol_type=request.symbol_type, + limit=request.limit, + ) + return success_response(data) + + +@router.get("/symbols/overview") +async def rag_symbols_overview( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False) + return success_response(data) + + +@router.get("/symbols/{symbol_id}") +async def rag_symbol_detail( + symbol_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + data = await service.get_symbol_detail(symbol_id=symbol_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(data) + + +@router.get("/knowledge-base/docs") +async def list_knowledge_docs( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + return success_response(await service.list_knowledge_docs()) + + +@router.post("/knowledge-base/docs") +async def create_knowledge_doc( + request: KnowledgeDocCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.create_knowledge_doc( + title=request.title, + file_type=request.file_type, + raw_content=request.content, + uploaded_by=int(current_user.id), + source_type=request.source_type, + file_url=request.file_url, + project_id=request.project_id, + ) + return success_response(result) + + +@router.post("/knowledge-base/upload") +async def upload_knowledge_doc( + file: UploadFile = File(...), + project_id: int | None = Form(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="上传文件为空") + if len(raw) > 15 * 1024 * 1024: + raise HTTPException(status_code=400, detail="文件过大,限制 15MB") + + file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw) + if not str(text or "").strip(): + raise HTTPException(status_code=400, detail="无法提取有效文本内容") + + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + doc = await service.create_knowledge_doc_placeholder( + title=(file.filename or "uploaded.txt")[:255], + file_type=file_type, + uploaded_by=int(current_user.id), + source_type="file", + file_url=None, + ) + asyncio.create_task( + AdaptiveRAGService.process_knowledge_doc_content_async( + tenant_id=effective_tenant_id, + doc_id=int(doc.id), + raw_content=text, + project_id=project_id, + ) + ) + return success_response(service._serialize_doc(doc)) + + +@router.post("/knowledge-base/urls") +async def create_knowledge_url( + request: KnowledgeURLCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + url = str(request.url or "").strip() + if not re.match(r"^https?://", url, flags=re.IGNORECASE): + raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头") + + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + doc = await service.create_knowledge_doc_placeholder( + title=(request.title or url)[:255], + file_type="url", + uploaded_by=int(current_user.id), + source_type="url", + file_url=url, + ) + asyncio.create_task( + AdaptiveRAGService.refresh_url_doc_async( + tenant_id=effective_tenant_id, + doc_id=int(doc.id), + project_id=request.project_id, + ) + ) + return success_response(service._serialize_doc(doc)) + + +@router.post("/knowledge-base/docs/{doc_id}/refresh") +async def refresh_knowledge_doc( + doc_id: int, + project_id: int | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + try: + doc = await service.get_knowledge_doc(doc_id=doc_id) + if str(doc.get("source_type") or "").lower() != "url": + raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh") + marked = await service.mark_doc_processing(doc_id=doc_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + asyncio.create_task( + AdaptiveRAGService.refresh_url_doc_async( + tenant_id=effective_tenant_id, + doc_id=doc_id, + project_id=project_id, + ) + ) + return success_response(marked) + + +@router.delete("/knowledge-base/docs/{doc_id}") +async def delete_knowledge_doc( + doc_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + ok = await service.delete_knowledge_doc(doc_id=doc_id) + if not ok: + raise HTTPException(status_code=404, detail="文档不存在") + return success_response({"deleted": True, "doc_id": doc_id}) + + +@router.post("/qa-records/{qa_id}/feedback") +async def submit_qa_feedback( + qa_id: int, + request: QAFeedbackCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.submit_feedback( + qa_id=qa_id, + rating=request.rating, + helpfulness=request.helpfulness, + error_types=request.error_types, + correct_answer=request.correct_answer, + submitted_by=int(current_user.id), + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + + +@router.get("/qa-records") +async def list_qa_records( + project_id: int | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + rating: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + query = QAListQuery( + project_id=project_id, + date_from=date_from, + date_to=date_to, + rating=rating, + page=page, + page_size=page_size, + ) + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.list_qa_records( + project_id=query.project_id, + date_from=query.date_from, + date_to=query.date_to, + rating=query.rating, + page=query.page, + page_size=query.page_size, + ) + return success_response(result) + + +@router.get("/qa-sessions") +async def list_qa_sessions( + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + query = QASessionListQuery( + date_from=date_from, + date_to=date_to, + page=page, + page_size=page_size, + ) + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.list_qa_sessions( + user_id=int(current_user.id), + date_from=query.date_from, + date_to=query.date_to, + page=query.page, + page_size=query.page_size, + ) + return success_response(result) + + +@router.get("/qa-sessions/{session_id}") +async def qa_session_detail( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + + +@router.get("/qa-sessions/{session_id}/state") +async def qa_session_state_replay( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.get_session_state_replay( + session_id=session_id, + user_id=int(current_user.id), + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + + +@router.patch("/qa-sessions/{session_id}/title") +async def rename_qa_session( + session_id: int, + request: QASessionRenameRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.rename_qa_session( + session_id=session_id, + user_id=int(current_user.id), + title=request.title, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + + +@router.delete("/qa-sessions/{session_id}") +async def delete_qa_session( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id)) + if not deleted: + raise HTTPException(status_code=404, detail="qa session 不存在") + return success_response({"deleted": True, "session_id": session_id}) + + +@router.get("/qa-records/stats") +async def qa_records_stats( + project_id: int | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.get_qa_stats( + project_id=project_id, + date_from=date_from, + date_to=date_to, + ) + return success_response(result) + + +@router.get("/qa-records/export") +async def export_qa_records_jsonl( + include_unrated: bool = Query(default=False), + project_id: int | None = Query(default=None), + rating: str | None = Query(default=None), + domain: str | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + current_user: User = Depends(require_roles("ADMIN", "BOSS")), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + query = QAExportQuery( + include_unrated=include_unrated, + project_id=project_id, + rating=rating, + domain=domain, + date_from=date_from, + date_to=date_to, + ) + + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + payload = await service.export_qa_jsonl( + include_unrated=query.include_unrated, + project_id=query.project_id, + rating=query.rating, + domain=query.domain, + date_from=query.date_from, + date_to=query.date_to, + ) + return Response(content=payload, media_type="application/x-ndjson") diff --git a/backend/app/api/signal_sources.py b/backend/app/api/signal_sources.py new file mode 100644 index 00000000..0ab97679 --- /dev/null +++ b/backend/app/api/signal_sources.py @@ -0,0 +1,401 @@ +"""食谱系统信号源 API + +功能: +1. 接收食谱系统的 webhook 请求 +2. 将食谱识别需求转换为 PMFlow 信号 +3. 自动关联到指定项目(食谱系统项目) +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +import json + +from ..database import get_db +from ..dependencies.tenant import get_tenant_id +from ..core.logging import get_logger +from ..models import Signal, ProjectSignalLink, Project + +router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"]) +logger = get_logger(__name__) + +RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc") + + +async def _resolve_target_project_id( + *, + db: AsyncSession, + tenant_id: int, + project_id: Optional[int], +) -> int: + """解析目标项目:优先显式 project_id,其次按关键字自动匹配。""" + from sqlalchemy import select, or_ + + if project_id is not None: + result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + Project.id == int(project_id), + ).limit(1) + ) + found = result.scalar_one_or_none() + if found is None: + raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限") + return int(found) + + keyword_conditions = [] + for keyword in RECIPE_PROJECT_KEYWORDS: + keyword_conditions.extend([ + Project.title.ilike(f"%{keyword}%"), + Project.name.ilike(f"%{keyword}%"), + ]) + + match_result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + or_(*keyword_conditions), + ).limit(1) + ) + matched = match_result.scalar_one_or_none() + if matched is not None: + return int(matched) + + # 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。 + active_result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + Project.status == "active", + ).limit(2) + ) + active_ids = list(active_result.scalars().all()) + if len(active_ids) == 1: + return int(active_ids[0]) + + raise HTTPException( + status_code=422, + detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)", + ) + + +# ============================================================================ +# 食谱系统信号源 Webhook +# ============================================================================ + +class RecipeRecognitionRequest(BaseModel): + """食谱识别请求""" + # 食谱信息 + food_name: str = Field(..., description="食物名称") + food_description: Optional[str] = Field(None, description="食物描述") + image_url: Optional[str] = Field(None, description="图片URL") + category: Optional[str] = Field(None, description="分类") + cuisine: Optional[str] = Field(None, description="菜系") + + # 营养信息(如果来源系统已分析) + calories: Optional[float] = Field(None, description="热量(千卡)") + protein: Optional[float] = Field(None, description="蛋白质(克)") + fat: Optional[float] = Field(None, description="脂肪(克)") + carbs: Optional[float] = Field(None, description="碳水化合物(克)") + fiber: Optional[float] = Field(None, description="膳食纤维(克)") + + # 意图 + intent: str = Field( + default="analyze", + description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱" + ) + + # 用户信息(可选) + user_name: Optional[str] = Field(None, description="用户名") + + +class RecipeRecognitionResponse(BaseModel): + """响应""" + success: bool + signal_id: Optional[int] = None + project_id: Optional[int] = None + message: str + details: Optional[Dict[str, Any]] = None + + +@router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook") +async def recipe_webhook( + request: RecipeRecognitionRequest, + project_id: Optional[int] = Query(None, description="项目ID,不传则自动查找食谱系统项目"), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """ + 接收食谱系统的识别请求 + + 用法: + - 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow + - PMFlow 会自动创建信号并关联到食谱系统项目 + + 请求示例: + ```json + { + "food_name": "红烧肉", + "food_description": "一块红烧肉,约200克,肥瘦相间", + "image_url": "https://example.com/image.jpg", + "category": "肉类", + "cuisine": "中式", + "calories": 350, + "protein": 20, + "fat": 25, + "carbs": 10, + "fiber": 0, + "intent": "analyze", + "user_name": "张三" + } + ``` + """ + try: + # 1. 查找目标项目 + target_project_id = await _resolve_target_project_id( + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + + # 2. 构建信号内容 + content_parts = [] + content_parts.append(f"食物:{request.food_name}") + + if request.food_description: + content_parts.append(f"描述:{request.food_description}") + + # 营养信息 + if request.calories or request.protein or request.fat or request.carbs or request.fiber: + nutrition = [] + if request.calories: + nutrition.append(f"热量 {request.calories} kcal") + if request.protein: + nutrition.append(f"蛋白 {request.protein}g") + if request.fat: + nutrition.append(f"脂肪 {request.fat}g") + if request.carbs: + nutrition.append(f"碳水 {request.carbs}g") + if request.fiber: + nutrition.append(f"纤维 {request.fiber}g") + content_parts.append("营养:" + ", ".join(nutrition)) + + if request.category: + content_parts.append(f"分类:{request.category}") + if request.cuisine: + content_parts.append(f"菜系:{request.cuisine}") + content_parts.append(f"意图:{request.intent}") + + content = "\n".join(content_parts) + + # 3. 构建标题 + intent_text = { + "analyze": "热量分析", + "save": "保存食谱", + "recommend": "推荐菜谱" + }.get(request.intent, request.intent) + + title = f"🍽️ 食谱{intent_text}:{request.food_name}" + + # 4. 构建摘要 + summary = f"食物:{request.food_name}" + if request.calories: + summary += f"\n热量:{request.calories} kcal" + if request.category: + summary += f"\n分类:{request.category}" + summary += f"\n意图:{intent_text}" + + # 5. 构建信号元数据 + signal_metadata = { + "source": "recipe_system", + "intent": request.intent, + "category": request.category, + "cuisine": request.cuisine, + "nutrition": { + "calories": request.calories, + "protein": request.protein, + "fat": request.fat, + "carbs": request.carbs, + "fiber": request.fiber, + }, + "image_url": request.image_url, + "user_name": request.user_name, + } + + # 6. 创建信号 + signal = Signal( + tenant_id=tenant_id, + source="recipe_system", + title=title, + content=content, + summary=summary, + signal_type="recipe_recognition", + priority="medium", + impact_score=0.5, + status="待处理", + processed=False, + strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False), + ) + + db.add(signal) + await db.flush() # 获取 signal.id + + # 7. 关联到项目 + link = ProjectSignalLink( + tenant_id=tenant_id, + project_id=target_project_id, + signal_id=signal.id, + ) + db.add(link) + + await db.commit() + await db.refresh(signal) + + return RecipeRecognitionResponse( + success=True, + signal_id=signal.id, + project_id=target_project_id, + message="信号创建成功并已关联到食谱系统项目", + details={ + "signal_id": signal.id, + "project_id": target_project_id, + "title": title, + "status": signal.status, + } + ) + + except HTTPException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.exception( + "recipe_webhook_create_signal_failed", + tenant_id=tenant_id, + project_id=project_id, + food_name=request.food_name, + error=str(e), + ) + raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}") + + +@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态") +async def get_recipe_signal_status( + signal_id: int, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """查询食谱识别信号的处理状态""" + from sqlalchemy import select + + result = await db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id, + Signal.source == "recipe_system", + ) + ) + signal = result.scalar_one_or_none() + + if not signal: + raise HTTPException(status_code=404, detail="信号不存在") + + return { + "signal_id": signal.id, + "title": signal.title, + "status": signal.status, + "processed": signal.processed, + "summary": signal.summary, + } + + +# ============================================================================ +# 批量导入食谱记录 +# ============================================================================ + +class RecipeBatchRequest(BaseModel): + """批量导入请求""" + recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表") + + +@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱") +async def recipe_batch_import( + request: RecipeBatchRequest, + project_id: Optional[int] = Query(None, description="项目ID"), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """批量导入食谱记录,每个都会创建信号""" + try: + target_project_id = await _resolve_target_project_id( + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + + signal_ids = [] + for recipe in request.recipes: + # 构建内容 + content = f"食物:{recipe.food_name}" + if recipe.calories: + content += f"\n热量:{recipe.calories} kcal" + + title = f"🍽️ 食谱记录:{recipe.food_name}" + + metadata = { + "source": "recipe_system", + "intent": recipe.intent, + "nutrition": { + "calories": recipe.calories, + "protein": recipe.protein, + "fat": recipe.fat, + "carbs": recipe.carbs, + } + } + + signal = Signal( + tenant_id=tenant_id, + source="recipe_system", + title=title, + content=content, + summary=f"食物:{recipe.food_name}", + signal_type="recipe_recognition", + priority="low", + impact_score=0.3, + status="待处理", + processed=True, # 批量导入的视为已处理 + strategic_analysis=json.dumps(metadata), + ) + db.add(signal) + await db.flush() + + # 关联项目 + link = ProjectSignalLink( + tenant_id=tenant_id, + project_id=target_project_id, + signal_id=signal.id, + ) + db.add(link) + signal_ids.append(signal.id) + + await db.commit() + + return RecipeRecognitionResponse( + success=True, + signal_id=len(signal_ids), # 返回数量 + project_id=target_project_id, + message=f"成功导入 {len(signal_ids)} 条食谱记录", + details={"signal_count": len(signal_ids)} + ) + + except HTTPException: + await db.rollback() + raise + except Exception as e: + await db.rollback() + logger.exception( + "recipe_batch_import_failed", + tenant_id=tenant_id, + project_id=project_id, + recipe_count=len(request.recipes), + error=str(e), + ) + raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}") diff --git a/backend/app/api/signals.py b/backend/app/api/signals.py new file mode 100644 index 00000000..28b9ae64 --- /dev/null +++ b/backend/app/api/signals.py @@ -0,0 +1,462 @@ +"""信号相关 API 路由""" + +import json +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ..database import get_db +from ..dependencies.tenant import get_tenant_id +from ..models import Signal, SignalFeedback, Project, ProjectSignalLink +from ..schemas import ( + SignalCreate, + SignalPriority, + classify_signal_priority, + SignalFeedbackCreate, + SignalFeedbackResponse, +) +from ..services.fetcher import fetch_hn_stories +from ..services.analyzer import analyze_impact +from ..services.llm import llm_service +from ..services.plane import plane_service +from ..services.lark import LarkService + +router = APIRouter(prefix="/signals", tags=["signals"]) +lark_service = LarkService() + + +@router.get("/") +async def list_signals( + roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"), + db: AsyncSession = Depends(get_db), +): + """获取信号列表,支持按角色过滤""" + result = await db.execute(select(Signal).order_by(Signal.impact_score.desc())) + signals = result.scalars().all() + + if roles: + role_list = [r.strip() for r in roles.split(",")] + filtered_signals = [] + for signal in signals: + tasks = [] + if signal.tasks_generated: + try: + tasks = json.loads(signal.tasks_generated) + except Exception: + tasks = [] + + filtered_tasks = [t for t in tasks if t.get("type") in role_list] + + if filtered_tasks: + signal_dict = { + "id": str(signal.id), + "source": signal.source, + "content": signal.content, + "impact_score": signal.impact_score, + "priority": signal.priority, + "processed": signal.processed, + "strategic_analysis": signal.strategic_analysis, + "tasks_generated": signal.tasks_generated, + "tasks_filtered": filtered_tasks, + "created_at": signal.created_at.isoformat() if signal.created_at else None, + } + filtered_signals.append(signal_dict) + + return filtered_signals + + return signals + + +@router.post("/", response_model=dict) +async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)): + new_signal = Signal( + source=signal.source, + content=signal.content, + impact_score=signal.impact_score, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + return {"id": str(new_signal.id), "status": "received"} + + +@router.post("/ingest", response_model=dict) +async def ingest_signal( + signal: SignalCreate, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """接入外部信号,按优先级处理""" + initial_priority = signal.priority or classify_signal_priority(signal.impact_score) + + new_signal = Signal( + source=signal.source, + content=signal.content, + impact_score=signal.impact_score, + priority=initial_priority.value, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + + if initial_priority == SignalPriority.LOW: + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "recorded", + "message": "Low priority signal recorded without processing", + } + + async def process_signal_internal(): + try: + relevant_context = await llm_service.find_relevant_context( + signal_content=signal.content, + db_session=db, + top_k=3, + min_similarity=0.3, + tenant_id=tenant_id, + ) + + strategy_result = await llm_service.evaluate_with_voting_async( + signal_content=signal.content, + relevant_context=relevant_context, + impact_score=signal.impact_score, + db=db, + tenant_id=tenant_id, + ) + except RuntimeError: + if initial_priority == SignalPriority.CRITICAL: + raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)") + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "blocked", + "processing_mode": "background", + "message": "预算超限,AI 调用被阻止(hard limit)", + } + + is_strategic = strategy_result.get("is_strategic", False) + decision_proposal = strategy_result.get("decision_proposal", {}) + tasks = strategy_result.get("tasks", []) + confidence = strategy_result.get("confidence", 0.0) + + final_priority = classify_signal_priority(signal.impact_score, confidence) + new_signal.priority = final_priority.value + + sync_result = None + notification_sent = False + + if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]: + if is_strategic and tasks: + sync_result = await plane_service.sync_tasks( + tasks, is_urgent=(final_priority == SignalPriority.CRITICAL) + ) + default_chat_id = "oc_1234567890" + await lark_service.send_strategic_alert( + default_chat_id, signal.content, decision_proposal, tasks + ) + notification_sent = True + new_signal.impact_score = 1.0 + + analysis_data = { + "decision_proposal": decision_proposal, + "competitiveness_radar": strategy_result.get("competitiveness_radar"), + "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), + "related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []), + "rag_context_used": len(relevant_context) if relevant_context else 0, + "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], + } + + if "_voting" in strategy_result: + voting_info = strategy_result["_voting"] + analysis_data["voting"] = { + "enabled": voting_info.get("enabled"), + "models_used": voting_info.get("models_used"), + "strategic_votes": voting_info.get("strategic_votes"), + "total_votes": voting_info.get("total_votes"), + "unanimous": voting_info.get("unanimous"), + } + + new_signal.strategic_analysis = json.dumps(analysis_data) + new_signal.tasks_generated = json.dumps(tasks) + new_signal.processed = True + + await db.commit() + await db.refresh(new_signal) + + return { + "id": str(new_signal.id), + "priority": final_priority.value, + "is_strategic": is_strategic, + "confidence": confidence, + "decision_proposal": decision_proposal, + "rag_context_used": len(relevant_context) if relevant_context else 0, + "voting": strategy_result.get("_voting"), + "tasks_generated": len(tasks), + "sync_result": sync_result, + "notification_sent": notification_sent, + } + + if initial_priority == SignalPriority.CRITICAL: + result = await process_signal_internal() + result["processing_mode"] = "synchronous" + return result + else: + background_tasks.add_task(process_signal_internal) + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "queued", + "processing_mode": "background", + "message": f"{initial_priority.value.upper()} priority signal queued for processing", + } + + +@router.post("/fetch", response_model=dict) +async def trigger_signal_fetch( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """手动触发抓取并分析信号""" + + def _stop_ollama(): + import subprocess + import signal + import os + + killed = False + try: + result = subprocess.run( + ["pgrep", "-f", "ollama"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for pid in result.stdout.strip().split("\n"): + if pid: + try: + os.kill(int(pid), signal.SIGTERM) + killed = True + except ProcessLookupError: + pass + except Exception as e: + print(f"[AI] pgrep failed: {e}") + + if not killed: + try: + result = subprocess.run( + ["pkill", "-9", "-f", "ollama"], + capture_output=True, + timeout=5, + ) + killed = result.returncode == 0 + except Exception as e: + print(f"[AI] pkill failed: {e}") + + if killed: + print("[AI] Ollama stopped - analysis complete") + + raw_stories = await fetch_hn_stories(limit=30) + new_signals_count = 0 + processed_count = 0 + + for story in raw_stories: + result = await db.execute(select(Signal).where(Signal.content == story["content"])) + if result.scalars().first(): + continue + + impact = analyze_impact(story["content"]) + + if impact > 0.4: + initial_priority = classify_signal_priority(impact) + + new_signal = Signal( + source=story["source"], + content=story["content"], + impact_score=impact, + priority=initial_priority.value, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + new_signals_count += 1 + + if initial_priority == SignalPriority.LOW: + continue + + async def process_fetched(): + nonlocal processed_count + try: + relevant_context = await llm_service.find_relevant_context( + signal_content=story["content"], + db_session=db, + top_k=3, + min_similarity=0.3, + tenant_id=tenant_id, + ) + strategy_result = await llm_service.evaluate_with_voting_async( + signal_content=story["content"], + relevant_context=relevant_context, + impact_score=impact, + db=db, + tenant_id=tenant_id, + ) + except RuntimeError: + return + is_strategic = strategy_result.get("is_strategic", False) + decision_proposal = strategy_result.get("decision_proposal", {}) + confidence = strategy_result.get("confidence", 0.0) + final_priority = classify_signal_priority(impact, confidence) + new_signal.priority = final_priority.value + + analysis_data = { + "decision_proposal": decision_proposal, + "competitiveness_radar": strategy_result.get("competitiveness_radar"), + "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), + "related_knowledge_nodes": strategy_result.get( + "related_knowledge_nodes", [] + ), + "rag_context_used": len(relevant_context) if relevant_context else 0, + "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], + "confidence": confidence, + "is_strategic": is_strategic, + } + if "_voting" in strategy_result: + vi = strategy_result["_voting"] + analysis_data["voting"] = { + "enabled": vi.get("enabled"), + "models_used": vi.get("models_used"), + "strategic_votes": vi.get("strategic_votes"), + "total_votes": vi.get("total_votes"), + "unanimous": vi.get("unanimous"), + } + new_signal.strategic_analysis = json.dumps(analysis_data) + new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", [])) + new_signal.processed = True + await db.commit() + processed_count += 1 + + if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]: + background_tasks.add_task(process_fetched) + else: + await process_fetched() + + _stop_ollama() + + return { + "status": "success", + "new_signals_count": new_signals_count, + "processed_count": processed_count, + "message": f"Fetched {new_signals_count} signals, {processed_count} analyzed", + } + + +@router.get("/{signal_id}/projects") +async def get_signal_projects( + signal_id: str, + db: AsyncSession = Depends(get_db), +): + """获取信号关联的项目""" + try: + sid = int(signal_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid signal_id") + + signal_result = await db.execute(select(Signal).where(Signal.id == sid)) + if not signal_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Signal not found") + + result = await db.execute( + select(Project, ProjectSignalLink) + .join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id) + .where(ProjectSignalLink.signal_id == sid) + ) + rows = result.all() + + return [ + { + "id": str(p.id), + "title": p.title, + "status": p.status, + "assigned_role": p.assigned_role, + "link_type": link.link_type, + "relevance_score": link.relevance_score, + } + for p, link in rows + ] + + +@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse) +async def submit_signal_feedback( + signal_id: int, + feedback: SignalFeedbackCreate, + db: AsyncSession = Depends(get_db), +): + """提交信号反馈""" + result = await db.execute(select(Signal).where(Signal.id == signal_id)) + signal = result.scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + + ai_predicted_strategic = False + ai_confidence = 0.0 + if signal.strategic_analysis: + try: + analysis = json.loads(signal.strategic_analysis) + ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9) + ai_confidence = analysis.get("confidence", 0.0) + except Exception: + ai_predicted_strategic = signal.impact_score >= 0.9 + + new_feedback = SignalFeedback( + signal_id=signal_id, + was_strategic=feedback.was_strategic, + reason=feedback.reason, + user_id=feedback.user_id, + ai_predicted_strategic=ai_predicted_strategic, + ai_confidence=ai_confidence, + ) + db.add(new_feedback) + await db.commit() + await db.refresh(new_feedback) + + is_correct = ai_predicted_strategic == feedback.was_strategic + + return SignalFeedbackResponse( + id=new_feedback.id, + signal_id=new_feedback.signal_id, + was_strategic=new_feedback.was_strategic, + ai_predicted_strategic=new_feedback.ai_predicted_strategic, + ai_confidence=new_feedback.ai_confidence, + is_correct=is_correct, + ) + + +@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse]) +async def get_signal_feedbacks( + signal_id: int, + db: AsyncSession = Depends(get_db), +): + """获取某信号的全部反馈""" + result = await db.execute( + select(SignalFeedback) + .where(SignalFeedback.signal_id == signal_id) + .order_by(SignalFeedback.created_at.desc()) + ) + feedbacks = result.scalars().all() + + return [ + SignalFeedbackResponse( + id=f.id, + signal_id=f.signal_id, + was_strategic=f.was_strategic, + ai_predicted_strategic=f.ai_predicted_strategic, + ai_confidence=f.ai_confidence or 0.0, + is_correct=f.ai_predicted_strategic == f.was_strategic, + ) + for f in feedbacks + ] diff --git a/backend/app/api/skill_tags.py b/backend/app/api/skill_tags.py new file mode 100644 index 00000000..8a156c20 --- /dev/null +++ b/backend/app/api/skill_tags.py @@ -0,0 +1,320 @@ +"""技能标签与用户技能绑定标准路由。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy import and_, delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..dependencies import get_current_user, get_tenant_id, require_roles +from ..models import SkillTag, User, UserSkillBinding +from ..services import skill_service +from ..services.auth import has_any_role + +router = APIRouter(tags=["skill-tags"]) + + +class SkillTagCreateRequest(BaseModel): + name: str + slug: str + category: Optional[str] = None + description: Optional[str] = None + sort_order: int = 0 + + +class SkillTagUpdateRequest(BaseModel): + name: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + + +class UserSkillCreateRequest(BaseModel): + skill_tag_id: int + level: str = "beginner" + proficiency: int = Field(default=50, ge=0, le=100) + quality_score: float = 0.0 + efficiency_score: float = 0.0 + + +class UserSkillUpdateRequest(BaseModel): + level: Optional[str] = None + proficiency: Optional[int] = Field(default=None, ge=0, le=100) + quality_score: Optional[float] = None + efficiency_score: Optional[float] = None + + +def _to_tag_payload(tag: SkillTag) -> dict: + return { + "id": int(tag.id), + "tenant_id": int(tag.tenant_id), + "name": tag.name, + "slug": tag.slug, + "category": tag.category, + "description": tag.description, + "is_system": bool(tag.is_system), + "sort_order": int(tag.sort_order or 0), + "created_at": tag.created_at.isoformat() if tag.created_at else None, + "updated_at": tag.updated_at.isoformat() if tag.updated_at else None, + } + + +@router.get("/skill-tags") +async def list_skill_tags( + category: Optional[str] = Query(None), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + tags = await skill_service.list_skill_tags( + db, + tenant_id=tenant_id, + category=category, + include_system=True, + ) + count_rows = await db.execute( + select(UserSkillBinding.skill_tag_id, func.count(UserSkillBinding.id)) + .where(UserSkillBinding.tenant_id == tenant_id) + .group_by(UserSkillBinding.skill_tag_id) + ) + count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()} + items = [] + for t in tags: + payload = _to_tag_payload(t) + payload["linked_user_count"] = count_map.get(int(t.id), 0) + items.append(payload) + return {"items": items, "total": len(items)} + + +@router.post("/skill-tags") +async def create_skill_tag( + body: SkillTagCreateRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + tag = await skill_service.create_skill_tag( + db, + tenant_id=tenant_id, + name=body.name, + slug=body.slug, + category=body.category, + description=body.description, + sort_order=body.sort_order, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + return _to_tag_payload(tag) + + +@router.put("/skill-tags/{tag_id}") +async def update_skill_tag( + tag_id: int, + body: SkillTagUpdateRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + tag = await skill_service.get_skill_tag(db, tag_id, tenant_id) + if not tag: + raise HTTPException(status_code=404, detail="Skill tag not found") + + # 系统标签仅允许改分类,禁止改名。 + if tag.is_system and body.name is not None and body.name != tag.name: + raise HTTPException(status_code=400, detail="System tag name cannot be changed") + + if body.name is not None and not tag.is_system: + tag.name = body.name + if body.category is not None: + tag.category = body.category + if body.description is not None and not tag.is_system: + tag.description = body.description + if body.sort_order is not None: + tag.sort_order = body.sort_order + tag.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(tag) + return _to_tag_payload(tag) + + +@router.delete("/skill-tags/{tag_id}") +async def delete_skill_tag( + tag_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + tag = await skill_service.get_skill_tag(db, tag_id, tenant_id) + if not tag: + raise HTTPException(status_code=404, detail="Skill tag not found") + if tag.is_system: + raise HTTPException(status_code=400, detail="System tag cannot be deleted") + + await db.execute( + delete(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.skill_tag_id == tag_id, + ) + ) + ) + await db.delete(tag) + await db.commit() + return {"message": "Skill tag deleted"} + + +@router.get("/users/{user_id}/skills") +async def get_user_skills( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + bindings = await skill_service.get_user_skill_bindings(db, tenant_id=tenant_id, user_id=user_id) + items = [] + for row in bindings: + items.append( + { + "skill_tag_id": int(row.skill_tag_id), + "skill_name": row.skill_tag.name if row.skill_tag else "", + "category": row.skill_tag.category if row.skill_tag else None, + "level": row.level or row.proficiency_level, + "proficiency": int(row.proficiency or 0), + "quality_score": float(row.quality_score or 0.0), + "efficiency_score": float(row.efficiency_score or 0.0), + "last_used_at": row.last_used_at.isoformat() if row.last_used_at else None, + "is_system": bool(row.skill_tag.is_system) if row.skill_tag else False, + } + ) + return {"items": items, "total": len(items)} + + +@router.post("/users/{user_id}/skills") +async def bind_user_skill( + user_id: int, + body: UserSkillCreateRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + exists = ( + await db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id == body.skill_tag_id, + ) + ) + ) + ).scalar_one_or_none() + if exists: + raise HTTPException(status_code=400, detail="Skill already bound") + + row = UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=body.skill_tag_id, + level=body.level, + proficiency_level=body.level, + proficiency=body.proficiency, + quality_score=body.quality_score, + efficiency_score=body.efficiency_score, + ) + db.add(row) + await db.commit() + return {"message": "Skill bound"} + + +@router.put("/users/{user_id}/skills/{skill_id}") +async def update_user_skill( + user_id: int, + skill_id: int, + body: UserSkillUpdateRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + current_role = str(current_user.role or "") + is_manager = has_any_role(current_role, ["BOSS", "ADMIN"]) + is_self = int(current_user.id) == user_id + if not is_manager and not is_self: + raise HTTPException(status_code=403, detail="No permission") + + if is_self and not is_manager: + if body.quality_score is not None or body.efficiency_score is not None: + raise HTTPException( + status_code=403, + detail="Members can only update own proficiency", + ) + + row = ( + await db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id == skill_id, + ) + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Binding not found") + + if body.level is not None: + row.level = body.level + row.proficiency_level = body.level + if body.proficiency is not None: + row.proficiency = body.proficiency + if body.quality_score is not None: + row.quality_score = body.quality_score + if body.efficiency_score is not None: + row.efficiency_score = body.efficiency_score + row.updated_at = datetime.utcnow() + await db.commit() + return {"message": "Skill updated"} + + +@router.delete("/users/{user_id}/skills/{skill_id}") +async def unbind_user_skill( + user_id: int, + skill_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + row = ( + await db.execute( + select(UserSkillBinding) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id == skill_id, + ) + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Binding not found") + if row.skill_tag and row.skill_tag.is_system: + raise HTTPException(status_code=400, detail="System bound skill cannot be removed") + + await db.delete(row) + await db.commit() + return {"message": "Skill unbound"} diff --git a/backend/app/api/sstc.py b/backend/app/api/sstc.py new file mode 100644 index 00000000..79e233e2 --- /dev/null +++ b/backend/app/api/sstc.py @@ -0,0 +1,3934 @@ +""" +SSTC 唯一事实链路 API + +覆盖模块: +- 项目概览/链路导航/风险预警 +- 信号源 +- 需求宪章(含对齐) +- 任务拆解与执行 +- 发布验证 +- 复盘沉淀 +""" + +from __future__ import annotations + +import asyncio +import json +import re +from datetime import datetime, timedelta, timezone +from io import BytesIO +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel +from sqlalchemy import and_, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import error_response, success_response +from ..database import get_db +from ..models import ( + AICallLog, + AlignmentRecord, + Integration, + ModelRequirementBinding, + ModelVersion, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserProject, +) +from ..schemas.sstc import ( + AlignmentConfirmRequest, + AlignmentStartRequest, + CharterUpdateRequest, + ReleaseVerificationItemPatchRequest, + ReleaseCreateRequest, + ReleaseStatusPatchRequest, + ReviewArchiveRequest, + ReviewContentPatchRequest, + ReviewCreateRequest, + ReviewImprovementToTaskRequest, + WorkloadOptimizeRequest, + TaskStatusPatchRequest, +) +from ..services.llm import llm_service +from ..services.ai_service import AIService +from ..services.operation_log_service import log_operation +from ..services.auth import has_any_role +from ..services.skill_service import recommend_personnel +from ..services import skill_score_service +from ..services.sstc_service import ( + ALIGNED_STATUSES, + DONE_STATUSES, + RELEASE_VERIFIED_STATUSES, + SSTCService, +) +from ..services import workload_service +from ..services.budget_enforcement_service import BudgetEnforcementService +from ..services.feishu_integration import get_feishu_service +from ..services.adaptive_rag_service import AdaptiveRAGService +from ..services.context_cache import context_cache_service +from ..services.task_persistence import build_task_create_draft, persist_task +from ..dependencies import get_current_user, require_project_member_or_manager +from ..utils.title import compact_title + + +router = APIRouter(tags=["sstc"]) +_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$") +_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*") + +DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 +DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 +DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 + +_SIGNAL_STATUS_CODE_PENDING = "pending" +_SIGNAL_STATUS_CODE_LINKED = "linked" +_SIGNAL_STATUS_CODE_CLOSED = "closed" +_SIGNAL_STATUS_LABEL_BY_CODE = { + _SIGNAL_STATUS_CODE_PENDING: "待处理", + _SIGNAL_STATUS_CODE_LINKED: "已关联宪章", + _SIGNAL_STATUS_CODE_CLOSED: "已关闭", +} +_SIGNAL_STATUS_FILTER_ALIAS = { + "pending": _SIGNAL_STATUS_CODE_PENDING, + "待处理": _SIGNAL_STATUS_CODE_PENDING, + "linked": _SIGNAL_STATUS_CODE_LINKED, + "已关联宪章": _SIGNAL_STATUS_CODE_LINKED, + "closed": _SIGNAL_STATUS_CODE_CLOSED, + "archived": _SIGNAL_STATUS_CODE_CLOSED, + "已关闭": _SIGNAL_STATUS_CODE_CLOSED, + "已归档": _SIGNAL_STATUS_CODE_CLOSED, +} +_SIGNAL_LOCK_REASON = "信号已关联宪章,禁止重复新建" +_BEIJING_UTC_OFFSET = timedelta(hours=8) +_ALLOWED_ATTACHMENT_TYPES = {"image", "file", "media", "document"} +_SIGNAL_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 +_SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS = { + ".jpg": "image", + ".jpeg": "image", + ".png": "image", + ".gif": "image", + ".pdf": "document", + ".doc": "document", + ".docx": "document", + ".zip": "file", +} +_SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/gif", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/zip", + "application/x-zip-compressed", + "multipart/x-zip", + "application/octet-stream", +} + +_CHARTER_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "module_1": ("problem_statement", "goal_statement", "kpis"), + "module_2": ("in_scope", "out_of_scope", "alignment_qa"), + "module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map"), + "module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment"), + "module_5": ("risk_register", "trigger_rules", "execution_rule"), + "module_6": ("attachment_metadata", "attachment_summary", "search_tags"), +} + +RELEASE_STATUS_TRANSITIONS: dict[str, set[str]] = { + "pending": {"releasing", "online"}, + "releasing": {"online", "rolled_back"}, + "online": {"rolled_back"}, + "rolled_back": {"releasing"}, + "verified": set(), +} + + +def _sync_pmflow_rag_async( + *, + tenant_id: int, + record_type: str, + record_id: int, + project_id: int, + title: str, + content: dict[str, Any] | str | None, +) -> None: + try: + if isinstance(content, str): + payload = content + else: + payload = json.dumps(content or {}, ensure_ascii=False) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type=record_type, + record_id=record_id, + project_id=project_id, + title=title, + content=payload, + ) + ) + except Exception: + return + + +def _parse_project_id(project_id: str) -> int: + try: + return int(project_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid project_id") from exc + + +def _parse_node_id(node_id: str, label: str) -> int: + try: + return int(node_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc + + +def _as_dict(value: Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + import json + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return {} + + +def _signal_ai_analysis(signal: Signal) -> dict: + payload = _as_dict(signal.strategic_analysis) + ai_payload = payload.get("ai_analysis") + return ai_payload if isinstance(ai_payload, dict) else {} + + +def _signal_project_scope(signal: Signal) -> Optional[str]: + scope = str(_signal_ai_analysis(signal).get("requirement_project_scope") or "").strip().lower() + if scope in {"new_project", "existing_project", "pending_classification", "non_requirement"}: + return scope + return None + + +def _signal_display_type(signal: Signal) -> str: + scope = _signal_project_scope(signal) + if scope: + return scope + return "pending_classification" + + +def _signal_source_context(signal: Signal) -> dict: + payload = _as_dict(signal.strategic_analysis) + source_context = payload.get("source_context") + return source_context if isinstance(source_context, dict) else {} + + +def _safe_int(value: Any) -> Optional[int]: + try: + return int(str(value).strip()) + except (TypeError, ValueError, AttributeError): + return None + + +def _signal_attachment_storage_root() -> Path: + root = Path(__file__).resolve().parents[2] / "storage" / "signal_attachments" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _build_signal_attachment_download_url(project_id: int, signal_id: int, attachment_id: str) -> str: + return f"/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download" + + +def _resolve_local_attachment_path(file_key: str) -> Optional[Path]: + raw = str(file_key or "").strip() + if not raw.startswith("local://"): + return None + relative = raw.removeprefix("local://").strip().lstrip("/") + if not relative: + return None + storage_root = _signal_attachment_storage_root().resolve() + candidate = (storage_root / relative).resolve() + try: + candidate.relative_to(storage_root) + except ValueError: + return None + return candidate + + +def _error_json(status_code: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status_code, content=error_response(status_code, message)) + + +def _normalize_attachment_type(value: Any) -> str: + raw = str(value or "").strip().lower() + alias = { + "img": "image", + "picture": "image", + "attachment": "file", + "doc": "document", + "docs": "document", + } + mapped = alias.get(raw, raw) + return mapped if mapped in _ALLOWED_ATTACHMENT_TYPES else "file" + + +def _normalize_signal_attachment(item: Any, *, index: int) -> Optional[dict[str, Any]]: + if not isinstance(item, dict): + return None + attachment_type = _normalize_attachment_type(item.get("attachment_type") or item.get("type")) + attachment_id = ( + str(item.get("attachment_id") or item.get("id") or item.get("file_key") or item.get("image_key") or item.get("media_id") or "") + .strip() + ) + if not attachment_id: + attachment_id = f"attachment-{index + 1}" + file_name = ( + str(item.get("file_name") or item.get("name") or item.get("title") or "").strip() + or f"{attachment_type}-{attachment_id}" + ) + file_size = _safe_int(item.get("file_size") or item.get("size")) + mime_type = str(item.get("mime_type") or item.get("mime") or "").strip() or None + occurred_at = str(item.get("occurred_at") or "").strip() or None + download_url = str(item.get("download_url") or item.get("url") or "").strip() or None + file_key = str(item.get("file_key") or "").strip() or None + image_key = str(item.get("image_key") or "").strip() or None + media_id = str(item.get("media_id") or "").strip() or None + source = str(item.get("source") or "").strip() or None + if not any([file_key, image_key, media_id, download_url]): + return None + return { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": file_name, + "mime_type": mime_type, + "file_size": file_size, + "file_key": file_key, + "image_key": image_key, + "media_id": media_id, + "source": source, + "occurred_at": occurred_at, + "download_url": download_url, + } + + +def _extract_source_attachments(source_context: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = source_context.get("attachments") + if not isinstance(raw_items, list): + return [] + items: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for idx, raw in enumerate(raw_items): + normalized = _normalize_signal_attachment(raw, index=idx) + if normalized is None: + continue + aid = str(normalized.get("attachment_id") or "").strip() + if not aid or aid in seen_ids: + continue + seen_ids.add(aid) + items.append(normalized) + return items + + +def _extract_signal_attachments(signal: Signal) -> list[dict[str, Any]]: + source_context = _signal_source_context(signal) + return _extract_source_attachments(source_context) + + +def _normalize_signal_status_code( + *, + linked_state: Optional[str], + signal_status: Optional[str], + processed: bool, +) -> str: + if linked_state in {_SIGNAL_STATUS_CODE_LINKED, _SIGNAL_STATUS_CODE_CLOSED}: + return linked_state + raw = str(signal_status or "").strip().lower() + if "关联" in raw or raw == _SIGNAL_STATUS_CODE_LINKED: + return _SIGNAL_STATUS_CODE_LINKED + if "关闭" in raw or "归档" in raw or raw in {"closed", "archived"}: + return _SIGNAL_STATUS_CODE_CLOSED + if processed: + return _SIGNAL_STATUS_CODE_CLOSED + return _SIGNAL_STATUS_CODE_PENDING + + +def _signal_status_label_from_code(status_code: str, fallback_status: Optional[str] = None) -> str: + fallback = str(fallback_status or "").strip() + normalized_code = str(status_code or "").strip().lower() + if ( + normalized_code == _SIGNAL_STATUS_CODE_PENDING + and fallback + and all(token not in fallback for token in ["关联", "关闭", "归档"]) + ): + return fallback + label = _SIGNAL_STATUS_LABEL_BY_CODE.get(normalized_code) + if label: + return label + return fallback or _SIGNAL_STATUS_LABEL_BY_CODE[_SIGNAL_STATUS_CODE_PENDING] + + +def _extract_charter_related_signal_ids(charter: TruthNode) -> list[int]: + payload = _as_dict(charter.content) + signal_ids: list[int] = [] + raw_ids = payload.get("signal_ids") + if isinstance(raw_ids, list): + for raw_id in raw_ids: + sid = _safe_int(raw_id) + if sid is not None: + signal_ids.append(sid) + charter_signal_id = _safe_int(charter.signal_id) + if charter_signal_id is not None: + signal_ids.append(charter_signal_id) + return sorted(set(signal_ids)) + + +def _collect_locked_signal_conflicts( + *, + charters: list[TruthNode], + candidate_signal_ids: list[int], +) -> tuple[list[int], dict[int, list[dict[str, Any]]]]: + candidate_set = set(int(sid) for sid in candidate_signal_ids) + conflicts: dict[int, list[dict[str, Any]]] = {} + for charter in charters: + charter_signal_ids = _extract_charter_related_signal_ids(charter) + if not charter_signal_ids: + continue + for sid in charter_signal_ids: + if sid not in candidate_set: + continue + conflicts.setdefault(sid, []).append( + { + "charter_id": int(charter.id), + "charter_title": str(charter.title or "").strip() or f"宪章#{int(charter.id)}", + } + ) + conflict_ids = sorted(conflicts.keys()) + return conflict_ids, conflicts + + +def _parse_beijing_day_start(raw: str, *, label: str) -> datetime: + text = str(raw or "").strip() + if not text: + raise HTTPException(status_code=400, detail=f"{label} 不能为空") + if "T" in text: + text = text.split("T")[0] + try: + local_day = datetime.strptime(text, "%Y-%m-%d") + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc + return local_day - _BEIJING_UTC_OFFSET + + +def _build_signal_created_at_range( + *, + created_from: Optional[str], + created_to: Optional[str], +) -> tuple[Optional[datetime], Optional[datetime]]: + start_utc = _parse_beijing_day_start(created_from, label="created_from") if created_from else None + end_utc = None + if created_to: + end_day_start_utc = _parse_beijing_day_start(created_to, label="created_to") + end_utc = end_day_start_utc + timedelta(days=1) + if start_utc and end_utc and start_utc >= end_utc: + raise HTTPException(status_code=400, detail="created_from 不能晚于 created_to") + return start_utc, end_utc + + +def _normalize_signal_status_filter(value: Optional[str]) -> Optional[str]: + raw = str(value or "").strip() + if not raw: + return None + return _SIGNAL_STATUS_FILTER_ALIAS.get(raw.lower(), _SIGNAL_STATUS_FILTER_ALIAS.get(raw, raw.lower())) + + +def _match_signal_filters( + *, + row: dict[str, Any], + status_filter: Optional[str], + keyword: Optional[str], + source: Optional[str], + priority: Optional[str], + created_from_utc: Optional[datetime], + created_to_utc: Optional[datetime], + created_at_dt: Optional[datetime], +) -> bool: + if status_filter: + if str(row.get("status_code") or "").strip().lower() != str(status_filter).strip().lower(): + return False + + keyword_text = str(keyword or "").strip().lower() + if keyword_text: + haystack = " ".join( + [ + str(row.get("id") or ""), + str(row.get("title") or ""), + str(row.get("content") or ""), + str(row.get("proposer_name") or ""), + str(row.get("charter_name") or ""), + ] + ).lower() + if keyword_text not in haystack: + return False + + source_text = str(source or "").strip().lower() + if source_text and source_text != "all": + row_source = str(row.get("source") or "").strip().lower() + if row_source != source_text: + return False + + priority_text = str(priority or "").strip().lower() + if priority_text and priority_text != "all": + row_priority = str(row.get("priority") or "").strip().lower() + if row_priority != priority_text: + return False + + if created_from_utc or created_to_utc: + if created_at_dt is None: + return False + if created_from_utc and created_at_dt < created_from_utc: + return False + if created_to_utc and created_at_dt >= created_to_utc: + return False + + return True + + +def _strip_signal_sender_prefix(content: str) -> str: + text = str(content or "") + return _SIGNAL_PREFIX_RE.sub("", text, count=1).strip() or text + + +def _extract_sender_from_content(content: str) -> Optional[str]: + text = str(content or "") + matched = _SIGNAL_PREFIX_RE.match(text) + if not matched: + return None + sender = (matched.group(1) or "").strip() + return sender or None + + +def _signal_title_text(signal: Signal) -> str: + return _strip_signal_sender_prefix(str(signal.content or "")) + + +def _signal_proposer_name(signal: Signal) -> str: + source_context = _signal_source_context(signal) + sender = str(source_context.get("sender") or "").strip() + if sender: + return sender + sender_from_content = _extract_sender_from_content(str(signal.content or "")) + return sender_from_content or "未知用户" + + +def _replace_signal_sender_in_content(content: str, sender_name: str) -> str: + text = str(content or "") + if not text: + return text + if _SIGNAL_PREFIX_RE.match(text): + return _SIGNAL_PREFIX_RE.sub(f"[{sender_name}]: ", text, count=1) + return f"[{sender_name}]: {text}" + + +def _safe_threshold(value: Any, default: float) -> float: + try: + number = float(value) + except (TypeError, ValueError): + number = float(default) + return min(1.0, max(0.0, number)) + + +def _parse_json_object_text(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if not text: + return {} + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].strip() + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _seed_text_snapshot(seed: dict[str, Any]) -> str: + pieces: list[str] = [] + for value in seed.values(): + if isinstance(value, str): + pieces.append(value) + elif isinstance(value, (list, tuple)): + pieces.extend([str(item) for item in value if item is not None]) + return "\n".join([part for part in pieces if str(part).strip()]).strip() + + +def _needs_seed_localize_zh(seed: dict[str, Any]) -> bool: + text = _seed_text_snapshot(seed) + if not text: + return False + zh_chars = len(re.findall(r"[\u4e00-\u9fff]", text)) + latin_chars = len(re.findall(r"[A-Za-z]", text)) + return zh_chars < 20 and latin_chars > 40 + + +async def _localize_seed_to_zh( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + seed: dict[str, Any], +) -> dict[str, Any]: + if not seed: + return seed + prompt = ( + "将下列 JSON 草稿的自然语言内容翻译为简体中文。" + "要求:仅返回 JSON;键名与结构保持不变;模型名/专有技术名词可保留英文。\n\n" + f"{json.dumps(seed, ensure_ascii=False)}" + ) + try: + raw = await llm_service.generate( + prompt, + function_name="sstc_charter_localize_zh", + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + localized = _parse_json_object_text(raw) + return localized if localized else seed + except Exception: + return seed + + +def _to_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + lines = [line.strip(" -\t") for line in value.replace("\r", "").split("\n")] + return [line for line in lines if line] + if isinstance(value, (list, tuple)): + output: list[str] = [] + for item in value: + text = str(item or "").strip(" -\t") + if text: + output.append(text) + return output + text = str(value).strip() + return [text] if text else [] + + +def _to_bullet_text(items: list[str]) -> str: + cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] + if not cleaned: + return "" + return "\n".join([f"- {item}" for item in cleaned]) + + +def _extract_charter_signal_ids(content: dict[str, Any], charter_signal_id: Optional[int] = None) -> list[int]: + signal_ids: list[int] = [] + raw_ids = content.get("signal_ids") + if isinstance(raw_ids, list): + for sid in raw_ids: + try: + signal_ids.append(int(sid)) + except Exception: + continue + if charter_signal_id: + try: + cid = int(charter_signal_id) + if cid not in signal_ids: + signal_ids.append(cid) + except Exception: + pass + return sorted(set(signal_ids)) + + +def _build_signal_context_text( + signal_rows: list[Signal], + extra_text: str = "", + raw_text: str = "", +) -> str: + parts: list[str] = [] + for idx, signal in enumerate(signal_rows, start=1): + title = _signal_title_text(signal) or f"信号{idx}" + parts.append( + "\n".join( + [ + f"[信号{idx}] 标题:{title}", + f"来源:{signal.source or '-'}", + f"优先级:{signal.priority or '-'}", + f"内容:{str(signal.content or '').strip()}", + ] + ).strip() + ) + extra_blocks: list[str] = [] + for block in (extra_text, raw_text): + text = str(block or "").strip() + if text and text not in extra_blocks: + extra_blocks.append(text) + for text in extra_blocks: + parts.append(f"[补充上下文]\n{text}") + return "\n\n".join([part for part in parts if part]).strip() + + +def _has_non_empty_module_data(content: dict[str, Any]) -> bool: + modules = content.get("modules") + if not isinstance(modules, dict): + return False + for module in modules.values(): + if not isinstance(module, dict): + continue + for value in module.values(): + if str(value or "").strip(): + return True + return False + + +def _has_complete_module_data(content: dict[str, Any]) -> bool: + modules = content.get("modules") + if not isinstance(modules, dict): + return False + for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items(): + module_payload = modules.get(module_key) + if not isinstance(module_payload, dict): + return False + for field_key in required_fields: + if not str(module_payload.get(field_key) or "").strip(): + return False + return True + + +def _merge_prefill_content( + existing: dict[str, Any], incoming: dict[str, Any], *, overwrite_existing: bool +) -> dict[str, Any]: + merged: dict[str, Any] = dict(existing) + for key, value in incoming.items(): + if overwrite_existing or key not in merged: + merged[key] = value + continue + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = _merge_prefill_content( + current, value, overwrite_existing=overwrite_existing + ) + continue + if isinstance(current, list): + if not current and value: + merged[key] = value + continue + if current is None or not str(current).strip(): + merged[key] = value + return merged + + +def _default_ai_validations(module_name: str) -> str: + return _to_bullet_text( + [ + f"校验「{module_name}」与信号源证据链一致", + "校验字段可验证、可执行、可追踪", + "发现冲突时自动标注并等待人工否决/确认", + ] + ) + + +def _default_escalation_trigger() -> str: + return _to_bullet_text( + [ + "置信度低于 0.75", + "关键字段缺失或来源证据冲突", + "超过模型能力边界或预算/时限约束", + ] + ) + + +async def _generate_charter_seed( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + signal_context: str, +) -> dict[str, Any]: + if not signal_context.strip(): + return {} + + prompt = ( + "你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。" + "所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。" + "只输出 JSON,不要 markdown。\n" + "字段:\n" + "{\n" + ' "summary": "一句话问题摘要",\n' + ' "background": "背景与动机",\n' + ' "objectives": ["目标1","目标2"],\n' + ' "scope": "范围说明",\n' + ' "in_scope": ["范围内事项1","范围内事项2"],\n' + ' "out_of_scope": ["范围外事项1","范围外事项2"],\n' + ' "success_metrics": ["指标1","指标2"],\n' + ' "risks": ["风险1","风险2"],\n' + ' "dependencies": ["依赖1","依赖2"],\n' + ' "primary_model": "模型名",\n' + ' "fallback_model": "备用模型名"\n' + "}\n\n" + f"信号输入:\n{signal_context[:6000]}" + ) + try: + raw = await llm_service.generate( + prompt, + function_name="sstc_charter_prefill", + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + parsed = _parse_json_object_text(raw) + if parsed: + if _needs_seed_localize_zh(parsed): + return await _localize_seed_to_zh( + db=db, tenant_id=tenant_id, project_id=project_id, seed=parsed + ) + return parsed + except Exception: + pass + + try: + ai_seed = await AIService().generate_charter_from_signal( + signal_context, + tenant_id=tenant_id, + db=db, + ) + if isinstance(ai_seed, dict): + if _needs_seed_localize_zh(ai_seed): + return await _localize_seed_to_zh( + db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed + ) + return ai_seed + return {} + except Exception: + return {} + + +async def _build_ai_first_charter_content( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + signal_rows: list[Signal], + signal_ids: list[int], + raw_text: str = "", +) -> dict[str, Any]: + signal_context = _build_signal_context_text(signal_rows, raw_text=raw_text) + seed = await _generate_charter_seed( + db=db, + tenant_id=tenant_id, + project_id=project_id, + signal_context=signal_context, + ) + + summary = str(seed.get("summary") or "").strip() + background = str(seed.get("background") or "").strip() + objectives = _to_string_list(seed.get("objectives")) + scope = str(seed.get("scope") or "").strip() + in_scope_items = _to_string_list(seed.get("in_scope")) + out_of_scope_items = _to_string_list(seed.get("out_of_scope")) + success_metrics = _to_string_list(seed.get("success_metrics")) + risks = _to_string_list(seed.get("risks")) + dependencies = _to_string_list(seed.get("dependencies")) + primary_model = str(seed.get("primary_model") or "").strip() + fallback_model = str(seed.get("fallback_model") or "").strip() + + if not summary: + summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。" + if not background: + background = signal_context[:500] or "基于已绑定信号源自动生成。" + if not objectives: + objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"] + if not success_metrics: + success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"] + if not in_scope_items: + in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"] + if not out_of_scope_items: + out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"] + if not risks: + risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"] + if not dependencies: + dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"] + if not primary_model: + primary_model = "待 AI 评估后绑定" + if not fallback_model: + fallback_model = "同供应商低成本模型" + + source_refs = [f"signal:{sid}" for sid in signal_ids] + now_iso = datetime.now(timezone.utc).isoformat() + validations_m1 = _default_ai_validations("模块1:触发与目标") + validations_m2 = _default_ai_validations("模块2:范围与对齐") + validations_m3 = _default_ai_validations("模块3:模型与依赖") + validations_m4 = _default_ai_validations("模块4:计划与资源") + validations_m5 = _default_ai_validations("模块5:风险与兜底") + validations_m6 = _default_ai_validations("模块6:附件与治理") + escalation = _default_escalation_trigger() + + alignment_qa = _to_bullet_text( + [ + f"核心用户与痛点:{summary}", + "上线时间与硬约束:由里程碑计划自动回填并持续校验", + f"明确不做项:{'; '.join(out_of_scope_items[:3])}", + f"关键依赖:{'; '.join(dependencies[:4])}", + f"核心风险与缓解:{'; '.join(risks[:3])}", + ] + ) + + modules = { + "module_1": { + "problem_statement": summary, + "goal_statement": _to_bullet_text(objectives), + "kpis": _to_bullet_text(success_metrics), + "ai_validations": validations_m1, + "escalation_trigger": escalation, + }, + "module_2": { + "in_scope": _to_bullet_text(in_scope_items), + "out_of_scope": _to_bullet_text(out_of_scope_items), + "alignment_qa": alignment_qa, + "ai_validations": validations_m2, + "escalation_trigger": escalation, + }, + "module_3": { + "primary_model": primary_model, + "fallback_model": fallback_model, + "capability_table": _to_bullet_text( + [ + "上下文窗口/并发/延迟由模型能力中心实时读取", + "按 P50/P90 工作负载自动估算 token 与成本", + "超边界输入自动触发替代模型建议", + ] + ), + "dependency_map": _to_bullet_text(dependencies), + "business_view": "以业务术语展示能力上限、成本区间与上线风险。", + "engineering_view": "以工程参数展示 context window、QPS、成本与错误预算。", + "ai_validations": validations_m3, + "escalation_trigger": escalation, + }, + "module_4": { + "milestones": _to_bullet_text( + [ + "需求冻结 -> 技术评审 -> 开发完成 -> QA 验收 -> 发布上线", + "关键依赖阻塞超过阈值自动重排计划", + ] + ), + "role_effort_estimates": _to_bullet_text( + [ + "产品/PM:需求澄清与验收口径", + "前后端工程:核心功能开发与联调", + "AI 工程:模型评估、提示词与推理链路优化", + ] + ), + "capability_owner_assignment": "负责人由能力图谱自动推荐并可人工否决。", + "buffer_policy": "默认 15% 进度缓冲,关键路径超阈值触发自动预警。", + "ai_validations": validations_m4, + "escalation_trigger": escalation, + }, + "module_5": { + "risk_register": _to_bullet_text(risks), + "trigger_rules": _to_bullet_text( + [ + "进度偏差 > 15%", + "关键接口错误率连续超过阈值", + "模型调用成本超预算预警线", + ] + ), + "execution_rule": _to_bullet_text( + [ + "触发后优先执行预设 fallback", + "自动通知责任人并写入审计日志", + "连续失败时升级到人工决策", + ] + ), + "ai_validations": validations_m5, + "escalation_trigger": escalation, + }, + "module_6": { + "attachment_metadata": _to_bullet_text( + [ + "原始信号记录", + "需求讨论记录", + "设计与评审文档", + "发布与复盘材料", + ] + ), + "attachment_summary": "AI 自动提炼附件决策结论并关联到对应模块字段。", + "search_tags": _to_bullet_text(["需求宪章", "AI First", "SSTC", "对齐记录"]), + "ai_validations": validations_m6, + "governance_rule": "附件仅作证据引用,核心事实字段以模块1-5为准。", + }, + } + + return { + "schema_version": "charter_ai_first_v1", + "auto_generated_by_ai": True, + "metadata": { + "status": "draft", + "confidence_score": 0.82 if seed else 0.72, + "last_validated_at": now_iso, + "evidence_refs": source_refs, + "escalation_reason": None, + }, + "modules": modules, + "signal_ids": signal_ids, + "summary": summary, + "background": background, + "objectives": objectives, + "scope": scope, + "success_metrics": success_metrics, + "core_metrics": success_metrics, + "rationale": summary, + "decision": _to_bullet_text(objectives), + "in_scope": in_scope_items, + "out_of_scope": out_of_scope_items, + "risks": risks, + "raw_source_text": raw_text.strip() or None, + } + + +def _extract_feishu_routing_config(integration: Optional[Integration]) -> dict[str, Any]: + config: dict[str, Any] = {} + if integration is not None: + if isinstance(integration.config, dict): + config = integration.config + elif isinstance(integration.config, str): + try: + parsed = json.loads(integration.config) + if isinstance(parsed, dict): + config = parsed + except (TypeError, ValueError): + config = {} + + cold_start_enabled = bool(config.get("cold_start_enabled")) + signal_threshold = _safe_threshold( + config.get("signal_auto_enter_threshold"), + DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, + ) + project_threshold = _safe_threshold( + config.get("project_auto_create_threshold"), + DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, + ) + uncertain_threshold = min( + project_threshold, + _safe_threshold( + config.get("project_uncertain_threshold"), + DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, + ), + ) + return { + "raw": config, + "cold_start_enabled": cold_start_enabled, + "signal_auto_enter_threshold": signal_threshold, + "project_auto_create_threshold": project_threshold, + "project_uncertain_threshold": uncertain_threshold, + } + + +async def _backfill_feishu_sender_names( + db: AsyncSession, + *, + tenant_id: int, + signals: list[Signal], +) -> None: + pending: dict[int, dict[str, str]] = {} + for signal in signals: + if str(signal.source or "").lower() != "feishu": + continue + source_context = _signal_source_context(signal) + open_id = str(source_context.get("sender_open_id") or "").strip() + sender = _signal_proposer_name(signal).strip() + if not open_id or not sender: + continue + if _FEISHU_PLACEHOLDER_RE.match(sender): + pending[int(signal.id)] = { + "open_id": open_id, + "current_sender": sender, + } + if not pending: + return + + service = get_feishu_service(db) + await service.load_config(tenant_id=tenant_id) + if not service.app_id or not service.app_secret: + return + + resolved_name_by_open_id: dict[str, str] = {} + for open_id in sorted({meta["open_id"] for meta in pending.values()})[:20]: + try: + remote_name = await service._fetch_user_name_by_open_id(open_id) # noqa: SLF001 + except Exception: + remote_name = None + normalized = (remote_name or "").strip() + if not normalized or _FEISHU_PLACEHOLDER_RE.match(normalized): + continue + resolved_name_by_open_id[open_id] = normalized + + if not resolved_name_by_open_id: + return + + changed = False + for signal in signals: + meta = pending.get(int(signal.id)) + if not meta: + continue + sender_name = resolved_name_by_open_id.get(meta["open_id"]) + if not sender_name: + continue + payload = _as_dict(signal.strategic_analysis) + source_context = payload.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + source_context["sender"] = sender_name + source_context["sender_open_id"] = meta["open_id"] + payload["source_context"] = source_context + signal.strategic_analysis = json.dumps(payload, ensure_ascii=False) + signal.content = _replace_signal_sender_in_content(str(signal.content or ""), sender_name) + changed = True + + if changed: + await db.commit() + + +def _parse_due_date(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid due_date") from exc + + +async def _ensure_project( + svc: SSTCService, + project_id: int, +): + project = await svc.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +async def _ensure_charter( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + charter_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == charter_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + ) + charter = result.scalar_one_or_none() + if not charter: + raise HTTPException(status_code=404, detail="Charter not found") + return charter + + +class AIRoutingConfigUpdateRequest(BaseModel): + cold_start_enabled: Optional[bool] = None + signal_auto_enter_threshold: Optional[float] = None + project_auto_create_threshold: Optional[float] = None + project_uncertain_threshold: Optional[float] = None + + +def _routing_payload(data: dict[str, Any]) -> dict[str, Any]: + return { + "cold_start_enabled": bool(data["cold_start_enabled"]), + "signal_auto_enter_threshold": round(float(data["signal_auto_enter_threshold"]), 2), + "project_auto_create_threshold": round(float(data["project_auto_create_threshold"]), 2), + "project_uncertain_threshold": round(float(data["project_uncertain_threshold"]), 2), + "tips": { + "cold_start_enabled": "开启后,所有飞书消息进入待处理,不会自动入库或自动建项。", + "signal_auto_enter_threshold": "建议范围 0.75-0.90,数值越高越保守。", + "project_auto_create_threshold": "建议范围 0.80-0.95,数值越高越严格。", + "project_uncertain_threshold": "低于该值视为低置信度,进入人工待处理。", + }, + } + + +@router.get("/projects/{project_id}/ai-routing-config") +async def get_ai_routing_config( + project_id: str, + current_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + integration = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + return success_response(_routing_payload(_extract_feishu_routing_config(integration))) + + +@router.put("/projects/{project_id}/ai-routing-config") +async def update_ai_routing_config( + project_id: str, + request: AIRoutingConfigUpdateRequest, + current_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + integration = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + if integration is None: + integration = Integration( + tenant_id=tenant_id, + type="feishu", + config={}, + is_active=True, + ) + db.add(integration) + await db.flush() + + current = _extract_feishu_routing_config(integration) + config = dict(current["raw"]) + + if request.cold_start_enabled is not None: + enabled = bool(request.cold_start_enabled) + config["cold_start_enabled"] = enabled + + if request.signal_auto_enter_threshold is not None: + config["signal_auto_enter_threshold"] = _safe_threshold( + request.signal_auto_enter_threshold, + current["signal_auto_enter_threshold"], + ) + + if request.project_auto_create_threshold is not None: + config["project_auto_create_threshold"] = _safe_threshold( + request.project_auto_create_threshold, + current["project_auto_create_threshold"], + ) + + if request.project_uncertain_threshold is not None: + config["project_uncertain_threshold"] = _safe_threshold( + request.project_uncertain_threshold, + current["project_uncertain_threshold"], + ) + + project_threshold = _safe_threshold( + config.get("project_auto_create_threshold"), + current["project_auto_create_threshold"], + ) + uncertain_threshold = min( + project_threshold, + _safe_threshold( + config.get("project_uncertain_threshold"), + current["project_uncertain_threshold"], + ), + ) + config["project_auto_create_threshold"] = project_threshold + config["project_uncertain_threshold"] = uncertain_threshold + + integration.config = config + await db.commit() + await db.refresh(integration) + return success_response(_routing_payload(_extract_feishu_routing_config(integration))) + + +async def _ensure_task( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + task_id: int, +) -> Task: + result = await db.execute( + select(Task).where( + and_( + Task.id == task_id, + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + ) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +async def _ensure_release( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + release_node_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == release_node_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "release", + ) + ) + ) + release_node = result.scalar_one_or_none() + if not release_node: + raise HTTPException(status_code=404, detail="Release plan not found") + return release_node + + +async def _ensure_review( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + review_node_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == review_node_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "review", + ) + ) + ) + review_node = result.scalar_one_or_none() + if not review_node: + raise HTTPException(status_code=404, detail="Review not found") + return review_node + + +def _normalize_review_content(raw: dict[str, Any] | None) -> dict[str, Any]: + payload = dict(raw or {}) + summary = str(payload.get("summary") or "").strip() + goals = payload.get("goals") + if not isinstance(goals, list): + goals = [] + what_went_well = payload.get("what_went_well") + if not isinstance(what_went_well, list): + what_went_well = [] + root_causes = payload.get("root_causes") + if not isinstance(root_causes, list): + root_causes = [] + improvements = payload.get("improvements") + if not isinstance(improvements, list): + improvements = [] + + normalized_improvements: list[dict[str, Any]] = [] + def _optional_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + for idx, item in enumerate(improvements): + if isinstance(item, str): + title = item.strip() + if not title: + continue + normalized_improvements.append( + { + "id": f"imp-{idx+1}", + "title": title, + "status": "pending", + "owner_id": None, + "due_date": None, + "task_id": None, + } + ) + continue + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").strip() + if not title: + continue + normalized_improvements.append( + { + "id": str(item.get("id") or f"imp-{idx+1}"), + "title": title, + "status": str(item.get("status") or "pending"), + "owner_id": _optional_int(item.get("owner_id")), + "due_date": item.get("due_date"), + "task_id": _optional_int(item.get("task_id")), + "description": str(item.get("description") or "").strip() or None, + } + ) + + return { + "summary": summary, + "goals": [str(x).strip() for x in goals if str(x).strip()], + "what_went_well": [str(x).strip() for x in what_went_well if str(x).strip()], + "root_causes": [str(x).strip() for x in root_causes if str(x).strip()], + "improvements": normalized_improvements, + "last_updated_at": datetime.now(timezone.utc).isoformat(), + } + + +async def _project_task_metrics( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> dict[str, int]: + result = await db.execute( + select(Task.status).where( + and_( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + ) + statuses = [str(row[0] or "").lower() for row in result.all()] + total = len(statuses) + done = len([s for s in statuses if s in DONE_STATUSES]) + return {"total": total, "done": done} + + +async def _build_governance_snapshot( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + charter_id: int | None = None, +) -> dict[str, Any]: + charter: TruthNode | None = None + if charter_id: + charter = ( + await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == charter_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + ) + ).scalar_one_or_none() + if charter is None: + charter = ( + await db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + confidence_score = None + confidence_risk = False + charter_id_value = int(charter.id) if charter else None + if charter: + charter_content = _as_dict(charter.content) + metadata = charter_content.get("metadata") + if isinstance(metadata, dict): + raw_score = metadata.get("confidence_score") + try: + confidence_score = float(raw_score) + except (TypeError, ValueError): + confidence_score = None + confidence_risk = confidence_score is not None and confidence_score < 0.8 + + binding = None + if charter_id_value: + binding = ( + await db.execute( + select(ModelRequirementBinding, ModelVersion) + .join( + ModelVersion, + ModelVersion.id == ModelRequirementBinding.model_version_id, + isouter=True, + ) + .where( + and_( + ModelRequirementBinding.tenant_id == tenant_id, + ModelRequirementBinding.truth_node_id == charter_id_value, + ) + ) + .order_by(ModelRequirementBinding.created_at.desc()) + .limit(1) + ) + ).first() + + model_block = False + model_info: dict[str, Any] = { + "bound": False, + "compatibility_score": None, + "model_version_id": None, + "model_name": None, + "provider": None, + } + est_cost = 0.01 + if binding: + binding_row, model_row = binding + score = int(binding_row.compatibility_score or 0) + model_block = score < 70 + model_info = { + "bound": True, + "compatibility_score": score, + "model_version_id": int(binding_row.model_version_id), + "model_name": getattr(model_row, "name", None), + "provider": getattr(model_row, "provider", None), + } + try: + est_cost = max(float(binding_row.estimated_cost or 0.01), 0.01) + except (TypeError, ValueError): + est_cost = 0.01 + + budget_service = BudgetEnforcementService(db) + budget_check = await budget_service.check_budget_before_ai_call( + tenant_id=tenant_id, + estimated_cost_usd=est_cost, + project_id=project_id, + ) + + budget_block = not bool(budget_check.get("allowed", True)) + intervention_required = confidence_risk or model_block or budget_block + blockers: list[str] = [] + warnings: list[str] = [] + if confidence_risk: + warnings.append("宪章 AI 置信度低于 0.80,建议补充关键事实后再推进。") + if model_block: + blockers.append("模型匹配分低于 70,禁止自动推进发布验证。") + elif not model_info["bound"]: + warnings.append("当前宪章未绑定模型版本,建议先完成模型能力矩阵校验。") + if budget_block: + blockers.append(f"预算管控阻断:{budget_check.get('reason') or 'budget_blocked'}") + elif str(budget_check.get("reason") or "") == "within_budget": + usage = budget_check.get("usage_percent") + if isinstance(usage, (int, float)) and float(usage) >= 80: + warnings.append("预算已接近阈值,建议执行成本优化方案。") + + return { + "project_id": project_id, + "charter_id": charter_id_value, + "intervention_required": intervention_required, + "blockers": blockers, + "warnings": warnings, + "confidence": { + "score": confidence_score, + "risk": confidence_risk, + }, + "model_binding": model_info, + "budget": budget_check, + } + + +async def _assert_release_transition_allowed( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + release_node: TruthNode, + target_status: str, +) -> None: + current = str(release_node.status or "pending").lower() + target = str(target_status or "").lower() + if target == "verified": + raise HTTPException(status_code=409, detail="请通过验证完成接口标记为已验证") + if target not in RELEASE_STATUS_TRANSITIONS.get(current, set()): + raise HTTPException( + status_code=409, + detail=f"非法状态流转:{current} -> {target}", + ) + + content = _as_dict(release_node.content) + if target in {"releasing", "online"}: + task_ids = [int(tid) for tid in (content.get("task_ids") or []) if str(tid).isdigit()] + if not task_ids: + raise HTTPException(status_code=409, detail="发布计划未绑定任务") + task_result = await db.execute( + select(Task.id, Task.status).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + Task.id.in_(task_ids), + ) + ) + ) + task_rows = list(task_result.all()) + if len(task_rows) != len(task_ids): + raise HTTPException(status_code=409, detail="发布计划存在无效任务绑定") + not_done = [row for row in task_rows if str(row[1] or "").lower() not in DONE_STATUSES] + if not_done: + raise HTTPException(status_code=409, detail="存在未完成任务,禁止进入发布流程") + + governance = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=project_id, + charter_id=int(content.get("charter_id") or 0) or None, + ) + if governance.get("blockers"): + raise HTTPException( + status_code=409, + detail=";".join([str(item) for item in governance.get("blockers", [])]), + ) + + verification_items = content.get("verification_items") + if not isinstance(verification_items, list) or len(verification_items) == 0: + raise HTTPException(status_code=409, detail="请先生成发布验证清单") + +def _assert_charter_aligned(charter: TruthNode) -> None: + if (charter.status or "").lower() not in ALIGNED_STATUSES: + raise HTTPException( + status_code=409, + detail="宪章未对齐,任务环节未解锁", + ) + + +def _is_manager(user: User) -> bool: + return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]) + + +def _require_manager(user: User) -> None: + if not _is_manager(user): + raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作") + + +@router.get("/projects/{project_id}/sstc/overview") +async def get_board_overview( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + overview = await svc.build_overview(pid) + stages = await svc.build_stages(pid) + risks = await svc.build_risks(pid) + return success_response( + { + "overview": overview, + "stages": stages, + "risks": risks, + } + ) + + +@router.get("/projects/{project_id}/sstc/stages") +async def get_stages( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_stages(pid)) + + +@router.get("/projects/{project_id}/sstc/risks") +async def get_risks( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_risks(pid)) + + +@router.get("/projects/{project_id}/sstc/governance") +async def get_governance_snapshot( + project_id: str, + charter_id: Optional[int] = Query(default=None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + data = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + return success_response(data) + + +@router.get("/projects/{project_id}/sstc-chain") +async def get_sstc_chain( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_chain(pid)) + + +@router.get("/projects/{project_id}/signals") +async def list_signals( + project_id: str, + status: Optional[str] = Query(default=None, description="pending/linked/closed/archived"), + keyword: Optional[str] = Query(default=None, description="按信号标题/内容/ID/提报人/关联宪章搜索"), + source: Optional[str] = Query(default=None, description="来源过滤"), + priority: Optional[str] = Query(default=None, description="优先级过滤"), + created_from: Optional[str] = Query(default=None, description="创建时间起始(北京时间 YYYY-MM-DD)"), + created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"), + limit: int = Query(default=10, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + project = await _ensure_project(svc, pid) + project_creator_name = "-" + if project and getattr(project, "owner_id", None): + owner = ( + await db.execute( + select(User.name).where( + and_( + User.tenant_id == tenant_id, + User.id == int(project.owner_id), + ) + ).limit(1) + ) + ).scalars().first() + if owner: + project_creator_name = str(owner) + signals = await svc.get_signals(pid) + await _backfill_feishu_sender_names( + db, + tenant_id=tenant_id, + signals=signals, + ) + charters = await svc.get_charters(pid) + linked_signal_states: dict[int, str] = {} + linked_signal_charter_name: dict[int, str] = {} + for c in charters: + signal_ids = _extract_charter_related_signal_ids(c) + charter_status = (c.status or "").lower() + state = ( + _SIGNAL_STATUS_CODE_CLOSED + if charter_status in {"aligned", "active", "online", "archived", "已对齐", "已上线", "已归档"} + else _SIGNAL_STATUS_CODE_LINKED + ) + for sid in signal_ids: + if linked_signal_states.get(sid) == _SIGNAL_STATUS_CODE_CLOSED: + continue + linked_signal_states[sid] = state + linked_signal_charter_name[sid] = c.title + normalized_status_filter = _normalize_signal_status_filter(status) + created_from_utc, created_to_utc = _build_signal_created_at_range( + created_from=created_from, + created_to=created_to, + ) + rows = [] + for s in signals: + ai_analysis = _signal_ai_analysis(s) + source_context = _signal_source_context(s) + project_scope = _signal_project_scope(s) + proposer_name = _signal_proposer_name(s) + title_text = _signal_title_text(s) + normalized_status = str(s.status or "").strip() + sid = int(s.id) + status_code = _normalize_signal_status_code( + linked_state=linked_signal_states.get(sid), + signal_status=normalized_status, + processed=bool(s.processed), + ) + row_status = _signal_status_label_from_code(status_code, normalized_status) + locked_by_charter = sid in linked_signal_states or ("关联" in normalized_status) + can_create_charter = not locked_by_charter + charter_lock_reason = None if can_create_charter else _SIGNAL_LOCK_REASON + row = { + "id": str(s.id), + "title": title_text[:80] or "未命名信号", + "content": s.content, + "priority": s.priority, + "signal_type": _signal_display_type(s), + "source": s.source, + "project_creator_name": project_creator_name, + "proposer_name": proposer_name, + "proposer_open_id": source_context.get("sender_open_id"), + "project_scope": project_scope, + "scope_reason": ai_analysis.get("reason"), + "target_project_hints": ai_analysis.get("target_project_hints", []), + "status": row_status, + "status_code": status_code, + "can_create_charter": can_create_charter, + "charter_lock_reason": charter_lock_reason, + "charter_name": linked_signal_charter_name.get(sid), + "created_at": s.created_at.isoformat() if s.created_at else None, + "attachments": _extract_signal_attachments(s), + } + created_at_dt = s.created_at + if created_at_dt is not None and created_at_dt.tzinfo is not None: + created_at_dt = created_at_dt.astimezone(timezone.utc).replace(tzinfo=None) + if not _match_signal_filters( + row=row, + status_filter=normalized_status_filter, + keyword=keyword, + source=source, + priority=priority, + created_from_utc=created_from_utc, + created_to_utc=created_to_utc, + created_at_dt=created_at_dt, + ): + continue + rows.append(row) + total = len(rows) + paged_rows = rows[offset : offset + limit] + return success_response( + { + "items": paged_rows, + "total": total, + "limit": limit, + "offset": offset, + "has_more": (offset + len(paged_rows)) < total, + } + ) + + +@router.post("/projects/{project_id}/signals/{signal_id}/attachments") +async def upload_signal_attachment( + project_id: str, + signal_id: str, + file: UploadFile | None = File(default=None), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + try: + pid = _parse_project_id(project_id) + except HTTPException: + return _error_json(status_code=400, message="project_id 不合法") + try: + sid = _parse_node_id(signal_id, "signal_id") + except HTTPException: + return _error_json(status_code=400, message="signal_id 不合法") + if file is None: + return _error_json(status_code=400, message="请选择要上传的附件") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + + project = await svc.get_project(pid) + if project is None: + return _error_json(status_code=404, message="项目不存在") + + signals = await svc.get_signals(pid) + signal = next((item for item in signals if int(item.id) == sid), None) + if signal is None: + return _error_json(status_code=404, message="信号不存在或不属于当前项目") + + original_file_name = Path(str(file.filename or "")).name.strip() + if not original_file_name: + return _error_json(status_code=400, message="文件名不能为空") + + extension = Path(original_file_name).suffix.lower() + attachment_type = _SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS.get(extension) + if attachment_type is None: + return _error_json(status_code=400, message="仅支持 jpg/png/gif/pdf/doc/docx/zip 文件") + + mime_type = str(file.content_type or "").strip().lower() or None + if mime_type and mime_type not in _SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES: + return _error_json(status_code=400, message="文件类型不支持,请上传 jpg/png/gif/pdf/doc/docx/zip") + + try: + content = await file.read() + except Exception: + return _error_json(status_code=500, message="读取上传文件失败") + + if not content: + return _error_json(status_code=400, message="上传文件为空") + if len(content) > _SIGNAL_ATTACHMENT_MAX_BYTES: + return _error_json(status_code=400, message="单文件大小不能超过 20MB") + + attachment_id = uuid4().hex + relative_dir = Path(f"tenant-{tenant_id}") / f"project-{pid}" / f"signal-{sid}" + storage_root = _signal_attachment_storage_root() + save_dir = storage_root / relative_dir + save_dir.mkdir(parents=True, exist_ok=True) + saved_file_name = f"{attachment_id}{extension}" + saved_path = save_dir / saved_file_name + try: + saved_path.write_bytes(content) + except Exception: + return _error_json(status_code=500, message="保存附件失败,请稍后重试") + + file_key = f"local://{(relative_dir / saved_file_name).as_posix()}" + attachment_payload = { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": original_file_name, + "mime_type": mime_type or "application/octet-stream", + "file_size": len(content), + "file_key": file_key, + "image_key": None, + "media_id": None, + "source": "manual_upload", + "occurred_at": datetime.now(timezone.utc).isoformat(), + "download_url": _build_signal_attachment_download_url(pid, sid, attachment_id), + } + + raw_payload = _as_dict(signal.strategic_analysis) + source_context = raw_payload.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + attachments = source_context.get("attachments") + if not isinstance(attachments, list): + attachments = [] + attachments.append(attachment_payload) + source_context["attachments"] = attachments + raw_payload["source_context"] = source_context + signal.strategic_analysis = json.dumps(raw_payload, ensure_ascii=False) + + try: + await db.commit() + await db.refresh(signal) + except Exception: + await db.rollback() + try: + saved_path.unlink() + except Exception: + pass + return _error_json(status_code=500, message="上传附件失败,请稍后重试") + + return success_response(attachment_payload) + + +@router.get("/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download") +async def download_signal_attachment( + project_id: str, + signal_id: str, + attachment_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + sid = _parse_node_id(signal_id, "signal_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + signals = await svc.get_signals(pid) + signal = next((item for item in signals if int(item.id) == sid), None) + if signal is None: + raise HTTPException(status_code=404, detail="Signal not found") + attachments = _extract_signal_attachments(signal) + target = next( + ( + item + for item in attachments + if str(item.get("attachment_id") or "").strip() == attachment_id + or str(item.get("id") or "").strip() == attachment_id + ), + None, + ) + if target is None: + raise HTTPException(status_code=404, detail="Attachment not found") + + local_path = _resolve_local_attachment_path(str(target.get("file_key") or "")) + if local_path is not None: + if not local_path.exists() or not local_path.is_file(): + raise HTTPException(status_code=404, detail="附件文件不存在") + try: + content = local_path.read_bytes() + except Exception as exc: + raise HTTPException(status_code=500, detail=f"附件读取失败: {str(exc)}") from exc + if not content: + raise HTTPException(status_code=500, detail="附件内容为空") + file_name = str(target.get("file_name") or f"attachment-{attachment_id}").strip().replace('"', "'") + media_type = str(target.get("mime_type") or "application/octet-stream") + return StreamingResponse( + BytesIO(content), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + + feishu_service = get_feishu_service(db) + await feishu_service.load_config(tenant_id) + if not feishu_service.is_configured(): + raise HTTPException(status_code=503, detail="飞书集成未配置,暂无法下载附件") + + try: + payload = await feishu_service.download_attachment_bytes(target) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"附件下载失败: {str(exc)}") from exc + + file_name = str(payload.get("file_name") or target.get("file_name") or f"attachment-{attachment_id}").strip() + file_name = file_name.replace('"', "'") + media_type = str(payload.get("content_type") or target.get("mime_type") or "application/octet-stream") + content = payload.get("content") + if not isinstance(content, (bytes, bytearray)): + raise HTTPException(status_code=500, detail="附件内容为空") + return StreamingResponse( + BytesIO(bytes(content)), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + + +@router.post("/projects/{project_id}/signals/{signal_id}/archive") +async def archive_signal( + project_id: str, + signal_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + sid = _parse_node_id(signal_id, "signal_id") + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + result = await db.execute( + select(Signal).where( + and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id)) + ) + ) + signal = result.scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + before = {"processed": signal.processed} + signal.processed = True + signal.status = "已关闭" + await log_operation( + db, + tenant_id=int(current_user.tenant_id), + user_id=int(current_user.id), + project_id=pid, + entity_type="signal", + entity_id=str(sid), + action="archive", + before_data=before, + after_data={"processed": True}, + ) + await db.commit() + return success_response({"id": str(signal.id), "status": "已关闭"}) + + +class CharterCreateRequest(BaseModel): + title: str + content: dict[str, Any] | None = None + signal_ids: list[int] + skip_ai_prefill: bool = False + + +class CharterAIPrefillRequest(BaseModel): + force: bool = False + raw_text: Optional[str] = None + + +@router.get("/projects/{project_id}/charters") +async def list_charters( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + charters = await svc.get_charters(pid) + payload = [ + { + "id": str(c.id), + "title": c.title, + "content": _as_dict(c.content), + "signal_ids": _as_dict(c.content).get("signal_ids", []), + "status": c.status, + "version": c.version, + "parent_id": str(c.parent_id) if c.parent_id else None, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in charters + ] + return success_response(payload) + + +@router.post("/projects/{project_id}/charters") +async def create_charter( + project_id: str, + body: CharterCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + if not body.signal_ids: + raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号") + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + link_result = await db.execute( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.project_id == pid, + ProjectSignalLink.signal_id.in_(body.signal_ids), + ProjectSignalLink.tenant_id == tenant_id, + ) + ) + ) + valid_signal_ids = {int(v) for v in link_result.scalars().all()} + if set(body.signal_ids) - valid_signal_ids: + raise HTTPException(status_code=400, detail="部分信号未关联到当前项目") + + signal_ids = sorted(set(body.signal_ids)) + existing_charters = await svc.get_charters(pid) + conflict_signal_ids, conflict_charters = _collect_locked_signal_conflicts( + charters=existing_charters, + candidate_signal_ids=signal_ids, + ) + if conflict_signal_ids: + raise HTTPException( + status_code=409, + detail={ + "message": _SIGNAL_LOCK_REASON, + "conflict_signal_ids": conflict_signal_ids, + "conflict_charters": conflict_charters, + }, + ) + signal_rows = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + + content = dict(body.content or {}) + raw_text = str(content.get("raw_source_text") or "").strip() + if not body.skip_ai_prefill and not _has_non_empty_module_data(content): + ai_prefill = await _build_ai_first_charter_content( + db=db, + tenant_id=tenant_id, + project_id=pid, + signal_rows=list(signal_rows), + signal_ids=signal_ids, + raw_text=raw_text, + ) + content.update(ai_prefill) + content["signal_ids"] = signal_ids + primary_signal_id = content["signal_ids"][0] if content["signal_ids"] else None + charter = TruthNode( + node_type="charter", + parent_id=None, + project_id=pid, + signal_id=primary_signal_id, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=content, + version=1, + status="draft", + tenant_id=tenant_id, + ) + db.add(charter) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id="new", + action="create", + after_data={"title": charter.title, "signal_ids": content["signal_ids"]}, + ) + + for signal in signal_rows: + signal.status = "已关联宪章" + signal.processed = False + + await db.commit() + await db.refresh(charter) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": _as_dict(charter.content), + "signal_ids": _as_dict(charter.content).get("signal_ids", []), + "status": charter.status, + "version": charter.version, + } + ) + + +@router.get("/projects/{project_id}/charters/{charter_id}") +async def get_charter_detail( + project_id: str, + charter_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + content = _as_dict(charter.content) + signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] + signal_rows = [] + if signal_ids: + signal_result = await db.execute( + select(Signal).where( + and_(Signal.id.in_(signal_ids), Signal.tenant_id == tenant_id) + ) + ) + signal_rows = list(signal_result.scalars().all()) + await _backfill_feishu_sender_names( + db, + tenant_id=tenant_id, + signals=signal_rows, + ) + + task_result = await db.execute( + select(Task).where( + and_( + Task.truth_node_id == cid, + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(task_result.scalars().all()) + + align_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.tenant_id == tenant_id, + ) + ) + ) + alignments = list(align_result.scalars().all()) + source_signal = None + if charter.signal_id: + for signal in signal_rows: + if int(signal.id) == int(charter.signal_id): + source_signal = signal + break + if source_signal is None and signal_rows: + source_signal = signal_rows[0] + + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": content, + "signal_ids": signal_ids, + "signals": [ + { + "id": str(s.id), + "source": s.source, + "signal_type": _signal_display_type(s), + "project_scope": _signal_project_scope(s), + "proposer_name": _signal_proposer_name(s), + "title": _signal_title_text(s)[:80] or "未命名信号", + "content": s.content, + "priority": s.priority, + "impact_score": s.impact_score, + "attachments": _extract_signal_attachments(s), + } + for s in signal_rows + ], + "source_signal": ( + { + "id": str(source_signal.id), + "title": _signal_title_text(source_signal)[:80] or "未命名信号", + "signal_type": _signal_display_type(source_signal), + "priority": source_signal.priority, + "content": source_signal.content, + "attachments": _extract_signal_attachments(source_signal), + } + if source_signal + else None + ), + "tasks": [ + { + "id": str(t.id), + "title": t.title, + "description": t.description, + "status": t.status, + "priority": t.priority, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + "due_date": t.due_date.isoformat() if t.due_date else None, + } + for t in tasks + ], + "alignment_records": [ + { + "user_id": str(r.user_id), + "score": r.alignment_score, + "confirmed": bool(r.is_confirmed), + "comments": r.comments, + } + for r in alignments + ], + "status": charter.status, + "version": charter.version, + } + ) + + +@router.patch("/projects/{project_id}/charters/{charter_id}") +async def update_charter( + project_id: str, + charter_id: str, + body: CharterUpdateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + before = { + "title": charter.title, + "content": _as_dict(charter.content), + "version": charter.version, + "status": charter.status, + } + aligned = (charter.status or "").lower() in ALIGNED_STATUSES + if aligned and body.force_new_version: + new_node = TruthNode( + node_type="charter", + parent_id=int(charter.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title((body.title or charter.title), max_len=24), + content=body.content if body.content is not None else _as_dict(charter.content), + version=int(charter.version or 1) + 1, + status="draft", + tenant_id=tenant_id, + ) + db.add(new_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(charter.id), + action="new_version", + before_data=before, + after_data={"new_charter_id": "pending"}, + ) + await db.commit() + await db.refresh(new_node) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(new_node.id), + project_id=pid, + title=new_node.title, + content=_as_dict(new_node.content), + ) + return success_response( + { + "id": str(new_node.id), + "version": new_node.version, + "status": new_node.status, + "title": new_node.title, + "content": _as_dict(new_node.content), + "new_version_created": True, + } + ) + + if body.title is not None: + charter.title = compact_title(body.title, max_len=24) + if body.content is not None: + charter.content = body.content + charter.status = "draft" + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(charter.id), + action="update", + before_data=before, + after_data={ + "title": charter.title, + "content": _as_dict(charter.content), + "version": charter.version, + "status": charter.status, + }, + ) + await db.commit() + await db.refresh(charter) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": _as_dict(charter.content), + "status": charter.status, + "version": charter.version, + "new_version_created": False, + } + ) + + +@router.post("/projects/{project_id}/charters/{charter_id}/ai-prefill") +async def ai_prefill_charter( + project_id: str, + charter_id: str, + body: CharterAIPrefillRequest = Body(default_factory=CharterAIPrefillRequest), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + before_content = _as_dict(charter.content) + before_status = str(charter.status or "") + + if not body.force and _has_complete_module_data(before_content): + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": before_content, + "status": charter.status, + "version": charter.version, + "prefilled": False, + "skipped": True, + } + ) + + signal_ids = _extract_charter_signal_ids(before_content, charter.signal_id) + signal_rows: list[Signal] = [] + if signal_ids: + signal_rows = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + + raw_text = str(body.raw_text or before_content.get("raw_source_text") or "").strip() + if not signal_rows and not raw_text: + raise HTTPException(status_code=400, detail="未找到可用于预填的信号源或原始文本") + + generated = await _build_ai_first_charter_content( + db=db, + tenant_id=tenant_id, + project_id=pid, + signal_rows=signal_rows, + signal_ids=signal_ids, + raw_text=raw_text, + ) + + merged = _merge_prefill_content( + before_content, generated, overwrite_existing=bool(body.force) + ) + merged["signal_ids"] = signal_ids + if raw_text: + merged["raw_source_text"] = raw_text + charter.content = merged + charter.status = "draft" + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(cid), + action="ai_prefill", + before_data={"content": before_content, "status": before_status}, + after_data={"content": merged, "status": "draft"}, + ) + + await db.commit() + await db.refresh(charter) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": _as_dict(charter.content), + "status": charter.status, + "version": charter.version, + "prefilled": True, + "skipped": False, + } + ) + + +@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start") +async def start_alignment( + project_id: str, + charter_id: str, + body: AlignmentStartRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + before = {"status": charter.status} + charter.status = "aligning" + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(cid), + action="start_alignment", + before_data=before, + after_data={"status": charter.status, "note": body.note}, + ) + await db.commit() + return success_response({"id": str(cid), "status": charter.status}) + + +@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm") +async def confirm_alignment( + project_id: str, + charter_id: str, + body: AlignmentConfirmRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + row_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.user_id == int(current_user.id), + AlignmentRecord.tenant_id == tenant_id, + ) + ) + ) + row = row_result.scalar_one_or_none() + if row is None: + row = AlignmentRecord( + truth_node_id=cid, + user_id=int(current_user.id), + alignment_score=body.score, + is_confirmed=body.confirmed, + comments=body.comments, + tenant_id=tenant_id, + ) + db.add(row) + else: + row.alignment_score = body.score + row.is_confirmed = body.confirmed + row.comments = body.comments + + member_ids = await svc.get_project_member_ids(pid) + confirmed_map: set[int] = set() + rows_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.tenant_id == tenant_id, + AlignmentRecord.is_confirmed.is_(True), + ) + ) + ) + for r in rows_result.scalars().all(): + confirmed_map.add(int(r.user_id)) + all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False + if all_confirmed: + charter.status = "aligned" + elif (charter.status or "").lower() not in {"aligned"}: + charter.status = "aligning" + + if all_confirmed: + content = _as_dict(charter.content) + signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] + if charter.signal_id and int(charter.signal_id) not in signal_ids: + signal_ids.append(int(charter.signal_id)) + if signal_ids: + signal_rows = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + for signal in signal_rows: + signal.status = "已关闭" + signal.processed = True + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="alignment", + entity_id=f"{cid}:{current_user.id}", + action="confirm", + after_data={ + "score": body.score, + "confirmed": body.confirmed, + "all_confirmed": all_confirmed, + "charter_status": charter.status, + }, + ) + await db.commit() + return success_response( + { + "charter_id": str(cid), + "charter_status": charter.status, + "all_confirmed": all_confirmed, + "confirmed_count": len(confirmed_map), + "member_count": len(member_ids), + } + ) + + +class TaskCreateRequest(BaseModel): + title: str + description: Optional[str] = None + required_skill_tag_ids: Optional[list[int]] = None + priority: str = "medium" + estimated_hours: Optional[float] = None + due_date: Optional[str] = None + + +class TaskUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + assignee_id: Optional[int] = None + required_skill_tag_ids: Optional[list[int]] = None + priority: Optional[str] = None + estimated_hours: Optional[float] = None + due_date: Optional[str] = None + + +@router.post("/projects/{project_id}/charters/{charter_id}/tasks") +async def create_task( + project_id: str, + charter_id: str, + body: TaskCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + _assert_charter_aligned(charter) + + due_date = _parse_due_date(body.due_date) + + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=cid, + project_id=pid, + draft=build_task_create_draft( + title=body.title, + description=body.description, + required_skill_tag_ids=body.required_skill_tag_ids or [], + priority=body.priority, + status="todo", + estimated_hours=body.estimated_hours, + due_date=due_date, + ), + actor_user_id=int(current_user.id), + action="task.create", + remark="sstc.create_task", + commit=False, + refresh_workload=False, + ) + await db.commit() + await db.refresh(task) + if task.assignee_id is not None: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + return success_response( + { + "id": str(task.id), + "title": task.title, + "status": task.status, + "priority": task.priority, + "charter_id": str(cid), + } + ) + + +@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") +async def ai_breakdown_tasks( + project_id: str, + charter_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + _assert_charter_aligned(charter) + + prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组: +[ + {{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}} +] + +宪章标题:{charter.title} +宪章内容:{_as_dict(charter.content)} +""" + try: + rows = await llm_service.generate_task_breakdown_async( + prompt, + db=db, + tenant_id=tenant_id, + project_id=pid, + ) + except RuntimeError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc + + created = [] + prompt_tokens = max(len(prompt) // 4, 1) + completion_tokens = 0 + for item in rows or []: + title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) + if not title: + continue + priority = str(item.get("priority") or "P1").lower() + mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=cid, + project_id=pid, + draft=build_task_create_draft( + title=title, + description=item.get("description"), + required_skill_tag_ids=[], + priority=mapped_priority, + status="todo", + estimated_hours=float(item.get("estimated_hours") or 0) or None, + ), + actor_user_id=int(current_user.id), + action="task.create", + remark="sstc.ai_breakdown", + commit=False, + refresh_workload=False, + ) + created.append(task) + completion_tokens += max(len(title) // 2, 1) + + db.add( + AICallLog( + tenant_id=tenant_id, + user_id=int(current_user.id), + function_name="sstc_ai_breakdown", + prompt=prompt, + response=f"created_count={len(created)}", + tokens_used=prompt_tokens + completion_tokens, + cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), + status="success", + ) + ) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(cid), + action="ai_breakdown", + after_data={"created_count": len(created)}, + ) + await db.commit() + for task in created: + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return success_response( + { + "created_count": len(created), + "items": [ + {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} + for t in created + ], + } + ) + + +@router.get("/projects/{project_id}/tasks/board") +async def get_task_board( + project_id: str, + assignee_id: Optional[int] = Query(default=None), + priority: Optional[str] = Query(default=None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + result = await db.execute( + select(Task).where( + and_( + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(result.scalars().all()) + if assignee_id is not None: + tasks = [t for t in tasks if t.assignee_id == assignee_id] + if priority: + tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()] + + rows = [] + for t in tasks: + risk_count = 0 + if (t.status or "").lower() == "blocked": + risk_count += 1 + if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES: + risk_count += 1 + rows.append( + { + "id": str(t.id), + "title": t.title, + "description": t.description, + "status": t.status, + "priority": t.priority, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + "due_date": t.due_date.isoformat() if t.due_date else None, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, + "risk_count": risk_count, + "truth_node_id": str(t.truth_node_id), + } + ) + return success_response(rows) + + +@router.patch("/projects/{project_id}/tasks/{task_id}/status") +async def patch_task_status( + project_id: str, + task_id: str, + body: TaskStatusPatchRequest, + background_tasks: BackgroundTasks, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id): + raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态") + + before_status = (task.status or "").lower() + before = {"status": task.status} + task.status = body.status + if body.status in DONE_STATUSES: + task.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="status_change", + before_data=before, + after_data={"status": body.status}, + ) + if task.assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if task.assignee_id: + await context_cache_service.invalidate_user_context( + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES + if became_done and task.assignee_id: + background_tasks.add_task( + skill_score_service.process_task_completion, + task_id=int(task.id), + tenant_id=tenant_id, + operator_user_id=int(current_user.id), + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=(task.description or "") + f"\n状态: {task.status}", + ) + + return success_response({"id": str(task.id), "status": task.status}) + + +@router.patch("/projects/{project_id}/tasks/{task_id}") +async def patch_task( + project_id: str, + task_id: str, + body: TaskUpdateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + + before = { + "title": task.title, + "description": task.description, + "assignee_id": task.assignee_id, + "priority": task.priority, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + } + + old_assignee_id = int(task.assignee_id) if task.assignee_id else None + if body.title is not None: + task.title = compact_title(body.title, max_len=32) + if body.description is not None: + task.description = body.description + if body.assignee_id is not None: + task.assignee_id = body.assignee_id + if body.required_skill_tag_ids is not None: + task.required_skill_tag_ids = body.required_skill_tag_ids + if body.priority is not None: + task.priority = body.priority + if body.estimated_hours is not None: + task.estimated_hours = body.estimated_hours + if body.due_date is not None: + task.due_date = _parse_due_date(body.due_date) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="update", + before_data=before, + after_data={ + "title": task.title, + "description": task.description, + "assignee_id": task.assignee_id, + "priority": task.priority, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + }, + ) + + touched_user_ids: set[int] = set() + if old_assignee_id: + touched_user_ids.add(old_assignee_id) + if task.assignee_id: + touched_user_ids.add(int(task.assignee_id)) + + if touched_user_ids: + for uid in touched_user_ids: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=uid, + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for uid in touched_user_ids: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + + return success_response({"id": str(task.id), "status": task.status}) + + +@router.delete("/projects/{project_id}/tasks/{task_id}") +async def delete_task( + project_id: str, + task_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + assignee_id = int(task.assignee_id) if task.assignee_id else None + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="delete", + before_data={ + "assignee_id": assignee_id, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours else None, + "status": task.status, + }, + ) + await db.execute( + delete(Task).where( + and_( + Task.id == tid, + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + + if assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=assignee_id, + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if assignee_id: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id) + + return success_response({"id": str(tid), "deleted": True}) + + +@router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee") +async def recommend_assignee_for_task( + project_id: str, + task_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + + required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] + recommendations = await recommend_personnel( + db=db, + tenant_id=tenant_id, + required_skills=required_skills, + task_description=task.description or task.title, + task_id=str(task.id), + limit=5, + ) + + project_user_ids_result = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == pid, + ) + ) + ) + project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()} + + active_users_result = await db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + active_user_ids = {int(uid) for uid in active_users_result.scalars().all()} + + filtered = [] + for item in recommendations.get("recommendations", []): + try: + uid = int(item.get("user_id")) + except (TypeError, ValueError): + continue + if uid in project_user_ids and uid in active_user_ids: + filtered.append(item) + + recommendations["recommendations"] = filtered + recommendations["total_count"] = len(filtered) + return success_response(recommendations) + + +@router.post("/projects/{project_id}/tasks/optimize-workload") +async def optimize_task_workload( + project_id: str, + body: WorkloadOptimizeRequest = Body(default_factory=WorkloadOptimizeRequest), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + proposals = await workload_service.build_ai_optimize_preview( + db, + tenant_id=tenant_id, + project_id=pid, + max_moves=body.max_moves, + unassigned_only=body.mode == "unassigned", + ) + if not proposals: + return success_response( + { + "updated": 0, + "message": "no tasks to optimize", + "mode": body.mode, + } + ) + + optimize_result = await workload_service.apply_ai_optimize_plan( + db, + tenant_id=tenant_id, + proposals=proposals, + ) + updated = int(optimize_result.get("applied_count") or 0) + affected_users = int(optimize_result.get("affected_users") or 0) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(pid), + action="optimize_workload", + after_data={ + "updated": updated, + "affected_users": affected_users, + "proposals": len(proposals), + "mode": body.mode, + }, + ) + await db.commit() + return success_response( + { + "updated": updated, + "affected_users": affected_users, + "proposals": len(proposals), + "mode": body.mode, + } + ) + + +@router.get("/projects/{project_id}/releases") +async def list_releases( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + releases = await svc.get_releases(pid) + return success_response( + [ + { + "id": str(r.id), + "title": r.title, + "status": r.status, + "content": _as_dict(r.content), + "version": r.version, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in releases + ] + ) + + +@router.post("/projects/{project_id}/releases") +async def create_release_plan( + project_id: str, + body: ReleaseCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=int(body.charter_id), + ) + + if not body.task_ids: + raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务") + + task_result = await db.execute( + select(Task).where( + and_( + Task.id.in_(body.task_ids), + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(task_result.scalars().all()) + if len(tasks) != len(body.task_ids): + raise HTTPException(status_code=400, detail="部分任务不存在") + not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES] + if not_done: + raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务") + + governance = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=int(charter.id), + ) + content = { + "charter_id": int(charter.id), + "task_ids": [int(t.id) for t in tasks], + "planned_at": body.planned_at, + "verification_items": [], + "verification_done": False, + "verification_progress": {"done": 0, "total": 0}, + "governance_snapshot": governance, + } + release_node = TruthNode( + tenant_id=tenant_id, + node_type="release", + parent_id=int(charter.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=content, + version=1, + status="pending", + ) + db.add(release_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="release", + entity_id="new", + action="create", + after_data={"title": release_node.title, "task_ids": body.task_ids}, + ) + await db.commit() + await db.refresh(release_node) + return success_response( + { + "id": str(release_node.id), + "title": release_node.title, + "status": release_node.status, + "content": _as_dict(release_node.content), + } + ) + + +@router.patch("/projects/{project_id}/releases/{release_node_id}/status") +async def patch_release_status( + project_id: str, + release_node_id: str, + body: ReleaseStatusPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + await _assert_release_transition_allowed( + db, + tenant_id=tenant_id, + project_id=pid, + release_node=release_node, + target_status=body.status, + ) + before = {"status": release_node.status} + release_node.status = body.status + content = _as_dict(release_node.content) + content["status_note"] = body.note + content["last_status_changed_at"] = datetime.now(timezone.utc).isoformat() + release_node.content = content + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="release", + entity_id=str(rid), + action="status_change", + before_data=before, + after_data={"status": body.status, "note": body.note}, + ) + await db.commit() + return success_response({"id": str(release_node.id), "status": release_node.status}) + + +@router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate") +async def generate_release_verification( + project_id: str, + release_node_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + + content = _as_dict(release_node.content) + charter_id = int(content.get("charter_id") or 0) + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + charter_content = _as_dict(charter.content) + success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] + if not success_metrics: + success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] + + items = [ + { + "id": f"check-{i+1}", + "title": str(metric), + "expected": "达到预期", + "status": "pending", + "required": True, + "source": "charter_success_metric", + "verified_by": None, + "verified_at": None, + "note": None, + } + for i, metric in enumerate(success_metrics) + ] + content["verification_items"] = items + content["verification_progress"] = {"done": 0, "total": len(items)} + content["verification_done"] = False + release_node.content = content + await db.commit() + return success_response({"release_id": str(release_node.id), "verification_items": items}) + + +@router.patch("/projects/{project_id}/releases/{release_node_id}/verification/items/{item_id}") +async def patch_release_verification_item( + project_id: str, + release_node_id: str, + item_id: str, + body: ReleaseVerificationItemPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + content = _as_dict(release_node.content) + items = content.get("verification_items") + if not isinstance(items, list) or len(items) == 0: + raise HTTPException(status_code=404, detail="验证清单不存在,请先生成") + + target = None + for item in items: + if str(item.get("id")) == item_id: + target = item + break + if target is None: + raise HTTPException(status_code=404, detail="验证项不存在") + + target["status"] = body.status + if body.status == "done": + target["verified_by"] = str(current_user.id) + target["verified_at"] = datetime.now(timezone.utc).isoformat() + else: + target["verified_by"] = None + target["verified_at"] = None + target["note"] = body.note + + done_count = len([item for item in items if str(item.get("status") or "").lower() == "done"]) + content["verification_items"] = items + content["verification_progress"] = {"done": done_count, "total": len(items)} + content["verification_done"] = done_count == len(items) and len(items) > 0 + release_node.content = content + await db.commit() + return success_response( + { + "release_id": str(release_node.id), + "item_id": item_id, + "verification_progress": content["verification_progress"], + "verification_done": content["verification_done"], + } + ) + + +@router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete") +async def complete_release_verification( + project_id: str, + release_node_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + status_raw = str(release_node.status or "").lower() + if status_raw not in {"online", "releasing"}: + raise HTTPException(status_code=409, detail="仅上线中的发布计划可完成验证") + content = _as_dict(release_node.content) + items = content.get("verification_items", []) + if not isinstance(items, list) or len(items) == 0: + raise HTTPException(status_code=409, detail="验证清单为空,请先生成并完成验证项") + incomplete = [item for item in items if str(item.get("status") or "").lower() != "done"] + if incomplete: + raise HTTPException(status_code=409, detail="仍有未完成验证项,请先逐项完成") + now_str = datetime.now(timezone.utc).isoformat() + content["verification_items"] = items + content["verification_done"] = True + content["verification_progress"] = {"done": len(items), "total": len(items)} + content["verified_at"] = now_str + release_node.content = content + release_node.status = "verified" + await db.commit() + return success_response({"id": str(release_node.id), "status": release_node.status}) + + +@router.get("/projects/{project_id}/reviews") +async def list_reviews( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + reviews = await svc.get_reviews(pid) + return success_response( + [ + { + "id": str(r.id), + "title": r.title, + "status": r.status, + "content": _as_dict(r.content), + "version": r.version, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in reviews + ] + ) + + +@router.post("/projects/{project_id}/reviews") +async def create_review( + project_id: str, + body: ReviewCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, + tenant_id=tenant_id, + project_id=pid, + release_node_id=int(body.release_node_id), + ) + if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES: + raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁") + + normalized_content = _normalize_review_content(body.content) + if not normalized_content.get("summary"): + normalized_content["summary"] = "AI 自动生成:请补充目标达成、核心问题与改进项。" + normalized_content["release_node_id"] = int(release_node.id) + normalized_content["charter_id"] = int(release_node.parent_id) if release_node.parent_id else None + + review_node = TruthNode( + tenant_id=tenant_id, + node_type="review", + parent_id=int(release_node.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=normalized_content, + version=1, + status="draft", + ) + db.add(review_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id="new", + action="create", + after_data={"title": review_node.title, "release_node_id": body.release_node_id}, + ) + await db.commit() + await db.refresh(review_node) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "id": str(review_node.id), + "title": review_node.title, + "status": review_node.status, + "content": _as_dict(review_node.content), + } + ) + + +@router.patch("/projects/{project_id}/reviews/{review_node_id}/content") +async def patch_review_content( + project_id: str, + review_node_id: str, + body: ReviewContentPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + + existing = _as_dict(review_node.content) + merged = dict(existing) + merged.update(body.content or {}) + normalized = _normalize_review_content(merged) + normalized["release_node_id"] = existing.get("release_node_id") + normalized["charter_id"] = existing.get("charter_id") + review_node.content = normalized + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id=str(rid), + action="update_content", + after_data={ + "improvement_count": len(normalized.get("improvements") or []), + "summary_len": len(str(normalized.get("summary") or "")), + }, + ) + await db.commit() + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "id": str(review_node.id), + "status": review_node.status, + "content": _as_dict(review_node.content), + } + ) + + +@router.post("/projects/{project_id}/reviews/{review_node_id}/improvements/convert-task") +async def convert_review_improvement_to_task( + project_id: str, + review_node_id: str, + body: ReviewImprovementToTaskRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + review_content = _normalize_review_content(_as_dict(review_node.content)) + improvements = review_content.get("improvements") or [] + if body.improvement_index >= len(improvements): + raise HTTPException(status_code=400, detail="改进项索引超出范围") + item = improvements[body.improvement_index] + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail="改进项数据无效") + + release_node_id = int(review_node.parent_id or review_content.get("release_node_id") or 0) + if release_node_id <= 0: + raise HTTPException(status_code=409, detail="复盘未关联发布计划,无法生成任务") + release_node = await _ensure_release( + db, + tenant_id=tenant_id, + project_id=pid, + release_node_id=release_node_id, + ) + + release_content = _as_dict(release_node.content) + charter_id = ( + int(body.charter_id) + if body.charter_id + else int(release_content.get("charter_id") or release_node.parent_id or 0) + ) + if charter_id <= 0: + raise HTTPException(status_code=409, detail="无法定位宪章,改进项无法转任务") + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + _assert_charter_aligned(charter) + + title = compact_title( + body.title or str(item.get("title") or "").strip() or "复盘改进项任务", + max_len=32, + ) + if not title: + raise HTTPException(status_code=400, detail="改进项标题为空") + desc_parts = [ + str(body.description or "").strip(), + str(item.get("description") or "").strip(), + f"来源复盘: {review_node.title}", + ] + description = "\n".join([part for part in desc_parts if part]) + due_date = _parse_due_date(body.due_date) + + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=charter_id, + project_id=pid, + draft=build_task_create_draft( + title=title, + description=description or None, + assignee_id=item.get("owner_id"), + required_skill_tag_ids=[], + status="todo", + priority=body.priority, + estimated_hours=body.estimated_hours, + due_date=due_date, + ), + actor_user_id=int(current_user.id), + action="create_from_review_improvement", + remark="sstc.review_improvement_to_task", + commit=False, + refresh_workload=False, + ) + + item["task_id"] = int(task.id) + item["status"] = "converted" + review_content["improvements"] = improvements + review_content["release_node_id"] = release_node_id + review_content["charter_id"] = charter_id + review_node.content = review_content + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(task.id), + action="create_from_review_improvement", + after_data={ + "review_id": rid, + "improvement_index": body.improvement_index, + "charter_id": charter_id, + }, + ) + await db.commit() + await db.refresh(task) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "task": { + "id": str(task.id), + "title": task.title, + "status": task.status, + "priority": task.priority, + "charter_id": str(charter_id), + }, + "review_id": str(review_node.id), + "improvement_index": body.improvement_index, + } + ) + + +@router.post("/projects/{project_id}/reviews/{review_node_id}/archive") +async def archive_review( + project_id: str, + review_node_id: str, + body: ReviewArchiveRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + + before = {"status": review_node.status} + review_node.status = "archived" if body.archive else "draft" + content = _as_dict(review_node.content) + content["archive_note"] = body.note + content["archived_at"] = datetime.now(timezone.utc).isoformat() if body.archive else None + review_node.content = content + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id=str(rid), + action="archive" if body.archive else "unarchive", + before_data=before, + after_data={"status": review_node.status}, + ) + await db.commit() + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response({"id": str(review_node.id), "status": review_node.status}) diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 00000000..2626e886 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,1085 @@ +"""用户管理 API。""" + +from __future__ import annotations + +import csv +from datetime import datetime +from io import BytesIO, StringIO +from typing import Any, Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from pydantic import BaseModel +from sqlalchemy import and_, delete, func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..dependencies import ( + get_current_active_user, + get_current_user, + get_tenant_id, + require_roles, + require_self_or_manager, +) +from ..models import ( + SkillTag, + Task, + User, + UserProject, + UserRole, + UserSkillBinding, + UserStatus, + UserWorkload, +) +from ..schemas import ( + UserCreate, + UserResetPasswordRequest, + UserResponse, + UserStatusPatchRequest, + UserUpdate, +) +from ..services.auth import AuthService +from ..services.operation_log_service import log_operation +from ..services import skill_service + +router = APIRouter(prefix="/users", tags=["users"]) + +COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} +MANAGER_ROLE_SET = {"BOSS", "ADMIN"} +VALID_ROLE_VALUES = {r.value for r in UserRole} + + +class BulkDeleteUsersRequest(BaseModel): + user_ids: list[int] + + +class BulkAssignUsersRequest(BaseModel): + user_ids: list[int] + role: Optional[str] = None + project_ids: Optional[list[int]] = None + + +def _enum_value(value: Any) -> Any: + return value.value if hasattr(value, "value") else value + + +def _parse_role_values(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + values = [str(_enum_value(item)).strip().upper() for item in raw] + else: + values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")] + deduped: list[str] = [] + for value in values: + if value and value not in deduped: + deduped.append(value) + return deduped + + +def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]: + values = _parse_role_values(raw) + if not values and not allow_empty: + raise ValueError("role required") + invalid = [value for value in values if value not in VALID_ROLE_VALUES] + if invalid: + raise ValueError(f"Invalid role: {','.join(invalid)}") + return values + + +def _pack_roles(raw: Any) -> str: + return ",".join(_normalize_role_input(raw)) + + +def _has_any_role(raw: Any, *roles: str) -> bool: + values = set(_parse_role_values(raw)) + expected = {str(role).upper() for role in roles} + return bool(values.intersection(expected)) + + +def _build_role_filter(column: Any, roles: list[str]) -> Any: + filters = [] + for role in roles: + filters.extend( + [ + column == role, + column.like(f"{role},%"), + column.like(f"%,{role},%"), + column.like(f"%,{role}"), + ] + ) + return or_(*filters) + + +def _parse_int_list(raw: Optional[str]) -> list[int]: + if not raw: + return [] + ids: list[int] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + try: + ids.append(int(token)) + except ValueError: + continue + return ids + + +def _translate_user_integrity_error(exc: IntegrityError) -> str: + message = str(getattr(exc, "orig", exc)).lower() + if "users.username" in message or "username" in message: + return "用户名已存在" + if "users.email" in message or "email" in message: + return "邮箱已存在" + return "用户数据冲突,请检查用户名或邮箱是否重复" + + +async def _collect_user_enrichment( + db: AsyncSession, + tenant_id: int, + users: list[User], +) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]: + if not users: + return {}, {}, {} + + user_ids = [int(u.id) for u in users] + + workloads_result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + workload_map: dict[int, float] = { + int(w.user_id): float(w.workload_ratio or 0.0) + for w in workloads_result.scalars().all() + } + + skill_rows = await db.execute( + select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id.in_(user_ids), + ) + ) + .order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc()) + ) + top_skills: dict[int, list[str]] = {} + for uid, skill_name, _prof in skill_rows.all(): + key = int(uid) + top_skills.setdefault(key, []) + if len(top_skills[key]) < 3: + top_skills[key].append(skill_name) + + project_rows = await db.execute( + select(UserProject.user_id, UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id.in_(user_ids), + ) + ) + ) + project_map: dict[int, list[int]] = {} + for uid, pid in project_rows.all(): + project_map.setdefault(int(uid), []).append(int(pid)) + + return workload_map, top_skills, project_map + + +def _serialize_user( + user: User, + workload_map: dict[int, float] | None = None, + top_skills: dict[int, list[str]] | None = None, + project_map: dict[int, list[int]] | None = None, +) -> dict: + payload = user.to_dict() + uid = int(user.id) + payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0)) + payload["top_skills"] = (top_skills or {}).get(uid, []) + payload["project_ids"] = (project_map or {}).get(uid, []) + return payload + + +async def _replace_user_projects( + db: AsyncSession, + tenant_id: int, + user_id: int, + project_ids: list[int], +) -> None: + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + unique_project_ids = sorted(set(project_ids)) + for pid in unique_project_ids: + db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid)) + + # bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。 + cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation") + if not isinstance(cache_payload, dict): + cache_payload = { + "tenant_ids": set(), + "invalidate_system": False, + "invalidate_project_list": False, + "invalidate_signal_pending": False, + "user_ids": set(), + } + db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload + cache_payload["tenant_ids"].add(int(tenant_id)) + cache_payload["invalidate_system"] = True + cache_payload["invalidate_project_list"] = True + cache_payload["user_ids"].add(int(user_id)) + + +async def _create_user_with_defaults( + db: AsyncSession, + tenant_id: int, + user_data: UserCreate, + actor_id: int, + *, + commit: bool, +) -> User: + username = user_data.username.strip() + if not username: + raise ValueError("用户名不能为空") + + existing_username = await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.username == username, + ) + ) + ) + if existing_username.scalar_one_or_none(): + raise ValueError("用户名已存在") + + if user_data.email: + existing_email = await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.email == user_data.email.strip(), + ) + ) + ) + if existing_email.scalar_one_or_none(): + raise ValueError("邮箱已存在") + + user = User( + tenant_id=tenant_id, + username=username, + password_hash=AuthService.hash_password(user_data.password), + name=(user_data.name or username).strip(), + email=(user_data.email or f"{username}@placeholder.local").strip(), + role=_pack_roles(user_data.role), + assigned_domains=user_data.assigned_domains or [], + workload=0, + status=UserStatus.ACTIVE.value, + is_active=True, + ) + db.add(user) + await db.flush() + + db.add( + UserWorkload( + tenant_id=tenant_id, + user_id=int(user.id), + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + ) + await skill_service.init_user_skill_bindings( + db, + tenant_id=tenant_id, + user_id=int(user.id), + include_system_only=True, + commit=False, + ) + + if user_data.project_ids: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=int(user.id), + project_ids=user_data.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=actor_id, + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="create", + before_data=None, + after_data=user.to_dict(), + ) + + if commit: + await db.commit() + await db.refresh(user) + return user + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_active_user)): + """当前用户信息。""" + return UserResponse(**_serialize_user(current_user)) + + +@router.get("/") +async def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None, description="用户名/姓名/邮箱关键字"), + role: Optional[str] = Query(None), + status_value: Optional[str] = Query(None, alias="status"), + is_active: Optional[bool] = Query(None), + project_id: Optional[int] = Query(None), + workload_state: Optional[str] = Query(None, description="idle|normal|overloaded"), + skill_ids: Optional[str] = Query(None, description="逗号分隔技能ID"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """用户分页列表(支持多维过滤)。""" + _ = current_user + skill_id_list = _parse_int_list(skill_ids) + + query = select(User).where(User.tenant_id == tenant_id) + if role: + try: + role_values = _normalize_role_input(role, allow_empty=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if role_values: + query = query.where(_build_role_filter(User.role, role_values)) + if status_value: + query = query.where(User.status == status_value) + if is_active is not None: + query = query.where(User.is_active == is_active) + if search: + q = f"%{search.strip()}%" + query = query.where( + or_( + User.username.ilike(q), + User.name.ilike(q), + User.email.ilike(q), + ) + ) + + if project_id is not None: + query = query.join( + UserProject, + and_( + UserProject.user_id == User.id, + UserProject.tenant_id == tenant_id, + ), + ).where(UserProject.project_id == project_id) + + if skill_id_list: + query = query.join( + UserSkillBinding, + and_( + UserSkillBinding.user_id == User.id, + UserSkillBinding.tenant_id == tenant_id, + ), + ).where(UserSkillBinding.skill_tag_id.in_(skill_id_list)) + + if workload_state: + query = query.outerjoin( + UserWorkload, + and_( + UserWorkload.user_id == User.id, + UserWorkload.tenant_id == tenant_id, + ), + ) + if workload_state == "idle": + query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) < 70.0) + elif workload_state == "normal": + query = query.where( + and_( + func.coalesce(UserWorkload.workload_ratio, 0.0) >= 70.0, + func.coalesce(UserWorkload.workload_ratio, 0.0) <= 100.0, + ) + ) + elif workload_state == "overloaded": + query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0) + + total_result = await db.execute( + select(func.count()).select_from(query.distinct().subquery()) + ) + total = int(total_result.scalar() or 0) + + result = await db.execute( + query.distinct() + .order_by(User.updated_at.desc(), User.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + users = list(result.scalars().all()) + workload_map, top_skills, project_map = await _collect_user_enrichment(db, tenant_id, users) + + return { + "items": [ + _serialize_user( + u, + workload_map=workload_map, + top_skills=top_skills, + project_map=project_map, + ) + for u in users + ], + "page": page, + "page_size": page_size, + "total": total, + } + + +@router.get("/available") +async def get_available_staff( + role: Optional[str] = Query(None, description="按角色过滤"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取可分配任务成员(启用 + active)。""" + _ = current_user + query = select(User).where( + and_( + User.tenant_id == tenant_id, + User.status == UserStatus.ACTIVE.value, + User.is_active == True, + ) + ) + if role: + try: + role_values = _normalize_role_input(role, allow_empty=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if role_values: + query = query.where(_build_role_filter(User.role, role_values)) + + result = await db.execute(query.order_by(User.workload.asc(), User.id.asc())) + users = result.scalars().all() + return [_serialize_user(u) for u in users] + + +@router.get("/{user_id}") +async def get_user( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取单用户详情。""" + _ = current_user + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + + +@router.post("/") +async def create_user( + user_data: UserCreate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """创建用户(自动初始化 user_workloads)。""" + try: + user = await _create_user_with_defaults( + db, + tenant_id=tenant_id, + user_data=user_data, + actor_id=int(current_user.id), + commit=True, + ) + except ValueError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) + except Exception: + await db.rollback() + raise + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + + +@router.put("/{user_id}") +@router.patch("/{user_id}") +async def update_user( + user_id: int, + user_data: UserUpdate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新用户信息。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + current_role = _enum_value(current_user.role) + is_self = int(current_user.id) == user_id + is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) + if not is_self and not is_manager: + raise HTTPException(status_code=403, detail="No permission") + + if is_self and not is_manager: + # 普通成员仅可修改自身基础资料。 + if user_data.role is not None or user_data.status is not None or user_data.is_active is not None: + raise HTTPException(status_code=403, detail="Members can only edit own profile fields") + if user_data.project_ids is not None: + raise HTTPException(status_code=403, detail="Members cannot change project assignments") + + before_data = user.to_dict() + + if user_data.name is not None: + user.name = user_data.name.strip() + if user_data.email is not None: + user.email = user_data.email.strip() + if user_data.assigned_domains is not None: + user.assigned_domains = user_data.assigned_domains + if user_data.workload is not None: + user.workload = max(0, min(200, int(user_data.workload))) + if user_data.status is not None: + user.status = _enum_value(user_data.status) + user.is_active = user.status == UserStatus.ACTIVE.value + if user_data.is_active is not None: + user.is_active = bool(user_data.is_active) + if not user.is_active: + user.status = UserStatus.OFFLINE.value + elif user.status == UserStatus.OFFLINE.value: + user.status = UserStatus.ACTIVE.value + if user_data.role is not None and is_manager: + try: + user.role = _pack_roles(user_data.role) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if user_data.project_ids is not None: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=user_id, + project_ids=user_data.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="update", + before_data=before_data, + after_data=user.to_dict(), + ) + try: + await db.commit() + await db.refresh(user) + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + + +@router.post("/{user_id}/reset-password") +async def reset_password( + user_id: int, + body: UserResetPasswordRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """重置用户密码。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + current_role = _enum_value(current_user.role) + is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) + if not is_manager and int(current_user.id) != user_id: + raise HTTPException(status_code=403, detail="No permission") + + try: + user.password_hash = AuthService.hash_password(body.new_password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + user.updated_at = datetime.utcnow() + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="reset_password", + before_data=None, + after_data={"updated_at": user.updated_at.isoformat()}, + ) + await db.commit() + return {"message": "Password reset successfully"} + + +@router.patch("/{user_id}/status") +async def patch_user_status( + user_id: int, + body: UserStatusPatchRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """禁用/启用用户,禁用时清空任务指派并归零负载。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + before_data = user.to_dict() + user.is_active = body.is_active + user.status = UserStatus.ACTIVE.value if body.is_active else UserStatus.OFFLINE.value + + if not body.is_active: + user.workload = 0 + await db.execute( + update(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + ) + ) + .values(assignee_id=None, updated_at=datetime.utcnow()) + ) + workload_result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = workload_result.scalar_one_or_none() + if not workload: + workload = UserWorkload(tenant_id=tenant_id, user_id=user_id) + db.add(workload) + workload.current_assigned_hours = 0.0 + workload.workload_ratio = 0.0 + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="status_patch", + before_data=before_data, + after_data=user.to_dict(), + ) + await db.commit() + await db.refresh(user) + return {"message": "Status updated", "user": _serialize_user(user)} + + +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """删除用户(有未完成任务时禁止)。""" + if int(current_user.id) == user_id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + incomplete_count_result = await db.execute( + select(func.count(Task.id)).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), + ) + ) + ) + incomplete_count = int(incomplete_count_result.scalar() or 0) + if incomplete_count > 0: + raise HTTPException( + status_code=400, + detail=f"User has {incomplete_count} incomplete tasks", + ) + + before_data = user.to_dict() + await db.execute( + delete(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + await db.delete(user) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user_id), + action="delete", + before_data=before_data, + after_data=None, + ) + await db.commit() + return {"message": "User deleted successfully"} + + +@router.post("/bulk-delete") +async def bulk_delete_users( + body: BulkDeleteUsersRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量删除用户(带未完成任务保护)。""" + if not body.user_ids: + raise HTTPException(status_code=400, detail="user_ids required") + + deleted: list[int] = [] + skipped: list[dict[str, Any]] = [] + + for user_id in sorted(set(body.user_ids)): + if int(current_user.id) == user_id: + skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"}) + continue + + user = ( + await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if not user: + skipped.append({"user_id": user_id, "reason": "User not found"}) + continue + + incomplete_count_result = await db.execute( + select(func.count(Task.id)).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), + ) + ) + ) + incomplete_count = int(incomplete_count_result.scalar() or 0) + if incomplete_count > 0: + skipped.append( + { + "user_id": user_id, + "reason": f"User has {incomplete_count} incomplete tasks", + } + ) + continue + + await db.execute( + delete(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + await db.delete(user) + deleted.append(user_id) + + await db.commit() + return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped} + + +@router.post("/bulk-assign") +async def bulk_assign_users( + body: BulkAssignUsersRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量分配项目/角色。""" + user_ids = sorted(set(body.user_ids or [])) + if not user_ids: + raise HTTPException(status_code=400, detail="user_ids required") + if body.role is None and body.project_ids is None: + raise HTTPException(status_code=400, detail="role or project_ids required") + + updated = 0 + skipped: list[dict[str, Any]] = [] + + for user_id in user_ids: + user = ( + await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if not user: + skipped.append({"user_id": user_id, "reason": "User not found"}) + continue + + before_data = user.to_dict() + + if body.role is not None: + if not _has_any_role(_enum_value(current_user.role), "BOSS"): + skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"}) + continue + try: + user.role = _pack_roles(body.role) + except ValueError as exc: + skipped.append({"user_id": user_id, "reason": str(exc)}) + continue + + if body.project_ids is not None: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=user_id, + project_ids=body.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user_id), + action="bulk_assign", + before_data=before_data, + after_data=user.to_dict(), + remark=f"role={body.role}, projects={body.project_ids}", + ) + updated += 1 + + await db.commit() + return {"updated_count": updated, "skipped": skipped} + + +@router.put("/{user_id}/workload") +async def update_user_workload( + user_id: int, + workload: int = Query(..., ge=0, le=200, description="Workload value (0-200)"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_self_or_manager), + db: AsyncSession = Depends(get_db), +): + """手动更新用户 workload 百分比。""" + _ = current_user + + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.workload = max(0, min(200, int(workload))) + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + return _serialize_user(user) + + +def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]: + lower_map = {str(k).strip().lower(): v for k, v in raw.items()} + return { + "username": str(lower_map.get("username", "")).strip(), + "password": str(lower_map.get("password", "")).strip(), + "name": str(lower_map.get("name", "")).strip(), + "email": str(lower_map.get("email", "")).strip() or None, + "role": str(lower_map.get("role", "FE")).strip().upper(), + "project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))), + } + + +async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]: + filename = (file.filename or "").lower() + content = await file.read() + + if filename.endswith(".csv"): + text = content.decode("utf-8-sig") + reader = csv.DictReader(StringIO(text)) + return [dict(row) for row in reader] + + if filename.endswith(".xlsx") or filename.endswith(".xls"): + try: + from openpyxl import load_workbook # type: ignore + except Exception as exc: + raise HTTPException( + status_code=400, + detail=f"Excel import requires openpyxl: {exc}", + ) + + wb = load_workbook(filename=BytesIO(content), read_only=True) + sheet = wb.active + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + return [] + headers = [str(v).strip() if v is not None else "" for v in rows[0]] + data_rows: list[dict[str, Any]] = [] + for row in rows[1:]: + item = { + headers[idx]: row[idx] + for idx in range(min(len(headers), len(row))) + if headers[idx] + } + data_rows.append(item) + return data_rows + + raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported") + + +@router.post("/import") +async def import_users( + file: UploadFile = File(...), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量导入用户(最多 50 行)。""" + rows = await _iter_import_rows(file) + if not rows: + raise HTTPException(status_code=400, detail="Import file is empty") + if len(rows) > 50: + raise HTTPException(status_code=400, detail="Import rows exceed max limit: 50") + + results: list[dict[str, Any]] = [] + success_count = 0 + + for idx, raw in enumerate(rows, start=1): + row = _normalize_import_row(raw) + try: + if row["role"] not in {r.value for r in UserRole}: + raise ValueError(f"Invalid role: {row['role']}") + if not row["username"] or not row["password"]: + raise ValueError("username/password required") + + data = UserCreate( + username=row["username"], + password=row["password"], + name=row["name"] or row["username"], + email=row["email"], + role=UserRole(row["role"]), + assigned_domains=[], + project_ids=row["project_ids"], + ) + user = await _create_user_with_defaults( + db, + tenant_id=tenant_id, + user_data=data, + actor_id=int(current_user.id), + commit=True, + ) + success_count += 1 + results.append( + { + "row": idx, + "status": "success", + "user_id": str(user.id), + "message": "created", + } + ) + except Exception as exc: + await db.rollback() + results.append( + { + "row": idx, + "status": "failed", + "message": str(exc), + } + ) + + return { + "total": len(rows), + "success": success_count, + "failed": len(rows) - success_count, + "results": results, + } diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py new file mode 100644 index 00000000..b6cc25a2 --- /dev/null +++ b/backend/app/api/wecom.py @@ -0,0 +1,327 @@ +""" +WeChat Work (企业微信) Integration API - 企业微信集成接口 + +功能: +1. 配置企业微信应用凭证 +2. 验证 URL 和处理事件 +3. 发送消息到企业微信群 +4. 查询集成状态 +""" +from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any + +from ..database import get_db +from ..dependencies.auth import get_current_user +from ..dependencies.tenant import get_tenant_id +from ..models import User +from ..services.auth import has_any_role +from ..services.wecom_integration import get_wecom_service + + +router = APIRouter(prefix="/wecom", tags=["WeChat Work Integration"]) + + +# ==================== Request/Response Models ==================== + +class WeComConfigRequest(BaseModel): + """企业微信配置请求""" + corp_id: str = Field(..., description="企业 ID") + agent_id: str = Field(..., description="应用 ID") + app_secret: str = Field(..., description="应用 Secret") + token: str = Field(..., description="回调验证 Token") + encoding_aes_key: str = Field(..., description="消息加密密钥") + + +class WeComSendMessageRequest(BaseModel): + """发送消息请求""" + chat_id: str = Field(..., description="群聊 ID") + message: str = Field(..., description="消息内容") + + +# ==================== Configuration Endpoints ==================== + +@router.post("/config") +async def save_wecom_config( + request: WeComConfigRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 保存企业微信配置 + + 配置信息说明: + - corp_id: 企业 ID,从企业微信管理后台获取 + - agent_id: 应用 ID + - app_secret: 应用 Secret + - token: 事件订阅验证 Token + - encoding_aes_key: 消息加密密钥 + + 获取步骤: + 1. 访问 https://work.weixin.qq.com/ + 2. 进入应用管理 -> 自建应用 + 3. 在「开发配置」获取 Agent ID 和 Secret + 4. 在「接收消息」配置 Token 和 EncodingAESKey + """ + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成") + + try: + service = get_wecom_service(db) + await service.save_config( + corp_id=request.corp_id, + agent_id=request.agent_id, + app_secret=request.app_secret, + token=request.token, + encoding_aes_key=request.encoding_aes_key, + tenant_id=tenant_id + ) + + return { + 'status': 'success', + 'message': '企业微信配置已保存', + 'next_steps': [ + '1. 在企业微信管理平台配置接收消息 URL', + f'2. URL: https://your-domain.com/api/wecom/webhook', + '3. 点击验证按钮完成 URL 验证' + ] + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"保存配置失败: {str(e)}" + ) + + +@router.get("/config") +async def get_wecom_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """获取企业微信配置状态。管理员可获取完整配置用于回填修改表单。""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) + + return { + 'is_configured': service.is_configured(), + 'corp_id': service.corp_id, + 'agent_id': service.agent_id, + 'app_secret': service.app_secret if can_view_secrets else None, + 'has_token': bool(service.token), + 'has_encoding_key': bool(service.encoding_aes_key), + 'token_preview': f"{service.token[:4]}...{service.token[-4:]}" if service.token and len(service.token) > 8 else (service.token or None), + 'encoding_aes_key_preview': f"{service.encoding_aes_key[:4]}...{service.encoding_aes_key[-4:]}" if service.encoding_aes_key and len(service.encoding_aes_key) > 8 else (service.encoding_aes_key or None), + 'token': service.token if can_view_secrets else None, + 'encoding_aes_key': service.encoding_aes_key if can_view_secrets else None, + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"获取配置失败: {str(e)}" + ) + + +@router.delete("/config") +async def delete_wecom_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """删除企业微信配置""" + from ..models import Integration + from sqlalchemy.future import select + + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以删除配置") + + try: + result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'wecom' + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.is_active = False + await db.commit() + + return {'status': 'success', 'message': '企业微信配置已删除'} + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"删除配置失败: {str(e)}" + ) + + +# ==================== Webhook Endpoints ==================== + +@router.get("/webhook") +@router.get("/webhook/", include_in_schema=False) +async def verify_webhook_url( + msg_signature: str, + timestamp: str, + nonce: str, + echostr: str, + db: AsyncSession = Depends(get_db) +): + """ + 验证企业微信事件订阅 URL + + 企业微信会发送 GET 请求来验证 URL 有效性 + """ + try: + service = get_wecom_service(db) + await service.load_config(tenant_id=1) + + if not service.is_configured(): + raise HTTPException( + status_code=400, + detail="请先配置企业微信" + ) + + decrypted_echostr = service.verify_url(msg_signature, timestamp, nonce, echostr) + + return { + "errcode": 0, + "errmsg": "ok", + "echostr": decrypted_echostr + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/webhook") +@router.post("/webhook/", include_in_schema=False) +async def handle_wecom_event( + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 处理企业微信事件推送 + + 企业微信会发送 POST 请求推送事件 + 需要验证签名确保请求来自企业微信 + """ + try: + # 获取请求头 + msg_signature = request.headers.get('msg_signature', '') + timestamp = request.headers.get('timestamp', '') + nonce = request.headers.get('nonce', '') + + # 读取请求体 + body_bytes = await request.body() + body = body_bytes.decode('utf-8') + + # 加载企业微信配置 + service = get_wecom_service(db) + await service.load_config(tenant_id=1) + + # 验证签名 + if not service.verify_callback(msg_signature, timestamp, nonce, body): + raise HTTPException(status_code=401, detail="签名验证失败") + + # 解析事件 + from ..services.wecom_integration import WeChatWorkIntegrationService + decrypted = service._decrypt(body) + event_data = json.loads(decrypted) if decrypted else {} + + # 处理事件 + result = await service.handle_message_event(event_data) + + return { + "errcode": 0, + "errmsg": "ok" + } + + except HTTPException: + raise + except Exception as e: + print(f"[WeCom] Webhook error: {e}") + # 企业微信要求返回成功状态,避免重复推送 + return {'errcode': 0, 'errmsg': 'ok'} + + +# ==================== Message Endpoints ==================== + +@router.post("/send-message") +async def send_wecom_message( + request: WeComSendMessageRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 发送文本消息到企业微信群 + + 用于发送简报、通知等 + """ + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + + result = await service.send_message_to_group( + chat_id=request.chat_id, + message=request.message + ) + + return { + 'status': 'success', + 'result': result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"发送消息失败: {str(e)}" + ) + + +# ==================== Test Endpoints ==================== + +@router.post("/test-connection") +async def test_wecom_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """测试企业微信连接是否正常""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + + if not service.is_configured(): + return { + 'status': 'error', + 'message': '企业微信未配置' + } + + # 尝试获取 access_token 测试连接 + try: + access_token = await service._get_access_token() + return { + 'status': 'success', + 'message': '企业微信连接正常', + 'has_token': bool(access_token) + } + except Exception as token_error: + return { + 'status': 'error', + 'message': f'获取 Token 失败: {str(token_error)}', + 'hint': '请检查 Corp ID、Agent ID 和 App Secret 是否正确' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"测试连接失败: {str(e)}" + ) diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 00000000..3d60e8b9 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,73 @@ +"""数据库会话与引擎管理。""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy import event +from sqlalchemy.pool import NullPool + +from .core.config import settings + +DATABASE_URL = settings.database_url + + +def _is_sqlite() -> bool: + return DATABASE_URL.lower().startswith("sqlite") + + +engine_kwargs = { + "echo": settings.debug, + "future": True, + "pool_pre_ping": not _is_sqlite(), +} +if _is_sqlite(): + engine_kwargs["connect_args"] = { + "timeout": 30, + "check_same_thread": False, + } + engine_kwargs["poolclass"] = NullPool +if not _is_sqlite(): + engine_kwargs.update( + { + "pool_size": max(5, int(settings.db_pool_size)), + "max_overflow": max(0, int(settings.db_max_overflow)), + "pool_timeout": max(5, int(settings.db_pool_timeout)), + "pool_recycle": max(60, int(settings.db_pool_recycle)), + } + ) + +engine: AsyncEngine = create_async_engine( + DATABASE_URL, + **engine_kwargs, +) + +if _is_sqlite(): + @event.listens_for(engine.sync_engine, "connect") + def _configure_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # pragma: no cover + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA busy_timeout=30000") + finally: + cursor.close() + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """FastAPI 依赖:提供数据库会话。""" + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/dispatcher.py b/backend/app/dispatcher.py new file mode 100644 index 00000000..9515db88 --- /dev/null +++ b/backend/app/dispatcher.py @@ -0,0 +1,399 @@ +""" +Neural Sync - Role Dispatcher + +根据任务的技术指纹自动路由到对应角色的工作台。 +""" + +from enum import Enum +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any, Tuple +from datetime import datetime +import re +import logging +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class Role(str, Enum): + """系统角色定义""" + + BOSS = "boss" # 老板 - 战略决策 + AI_ENGINEER = "ai_engineer" # AI 工程师 - Prompt/模型/RAG + BACKEND = "backend" # 后端开发 - API/数据库/爬虫 + FRONTEND = "frontend" # 前端开发 - UI/组件/交互 + + +@dataclass +class RoutingRule: + """路由规则定义""" + + role: Role + name: str + description: str + triggers: List[str] = field(default_factory=list) # 触发关键词 + keywords: List[str] = field(default_factory=list) # 技术关键词 + patterns: List[str] = field(default_factory=list) # 正则模式 + threshold: Optional[Dict[str, Any]] = None # 阈值条件 + priority: int = 0 # 优先级,数值越大越优先 + + +class RoleRouter: + """ + 角色路由引擎 + + 根据信号的技术指纹自动路由到对应角色: + - Boss: 高成本、高风险、架构变更决策 + - AI Engineer: LLM、Prompt、RAG、数据处理 + - Backend: API、数据库、爬虫、性能优化 + - Frontend: UI、可视化、交互、响应式 + """ + + # 角色路由规则定义 + ROLE_RULES: List[RoutingRule] = [ + RoutingRule( + role=Role.BOSS, + name="战略决策", + description="需要老板决策的高影响任务", + triggers=["cost_estimate", "architecture_change", "high_risk", "security"], + keywords=["预算", "架构", "安全", "风险", "审批", "决策"], + threshold={"dev_days": 5, "risk_level": ["CRITICAL", "HIGH"]}, + priority=100, + ), + RoutingRule( + role=Role.AI_ENGINEER, + name="AI 工程", + description="AI 模型和数据处理相关任务", + triggers=["prompt", "model", "embedding", "rag", "llm"], + keywords=[ + "ollama", + "openai", + "claude", + "prompt", + "embedding", + "rag", + "llm", + "向量", + "模型", + "训练", + "数据清洗", + "token", + "context", + ], + patterns=[r"(prompt|embedding|rag|llm|vector|model)", r"(ai|machine.?learning|ml)"], + priority=50, + ), + RoutingRule( + role=Role.BACKEND, + name="后端开发", + description="API、数据库、爬虫等后端任务", + triggers=["api", "database", "crawler", "performance", "docker"], + keywords=[ + "fastapi", + "sqlalchemy", + "postgres", + "sql", + "async", + "docker", + "kubernetes", + "redis", + "celery", + "爬虫", + "api", + "接口", + "数据库", + "后端", + "性能", + "缓存", + ], + patterns=[ + r"(api|endpoint|route)", + r"(sql|database|db)", + r"(docker|k8s|kubernetes)", + r"(async|await|celery)", + ], + priority=30, + ), + RoutingRule( + role=Role.FRONTEND, + name="前端开发", + description="UI、可视化、交互等前端任务", + triggers=["ui", "visualization", "interaction", "responsive", "chart"], + keywords=[ + "vue", + "react", + "typescript", + "component", + "chart", + "echarts", + "d3", + "可视化", + "ui", + "ux", + "样式", + "动画", + "响应式", + "组件", + "交互", + "页面", + "布局", + ], + patterns=[ + r"(vue|react|tsx?|jsx?)", + r"(css|scss|tailwind)", + r"(component|props|emit)", + r"(chart|graph|visualization)", + ], + priority=30, + ), + ] + + # 技术栈关键词映射 + TECH_KEYWORDS = { + "frontend": [ + "vue", + "react", + "typescript", + "javascript", + "css", + "scss", + "tailwind", + "component", + "props", + "emit", + "template", + "style", + ], + "backend": [ + "fastapi", + "flask", + "django", + "sqlalchemy", + "postgres", + "mysql", + "redis", + "celery", + "async", + "await", + "docker", + "kubernetes", + "k8s", + "api", + "endpoint", + "route", + ], + "ai": [ + "llm", + "ollama", + "openai", + "claude", + "prompt", + "embedding", + "rag", + "vector", + "model", + "training", + "inference", + "token", + "context", + ], + } + + def __init__(self): + # 编译正则模式 + self._compiled_patterns: Dict[Role, List[re.Pattern]] = {} + for rule in self.ROLE_RULES: + patterns = [re.compile(p, re.IGNORECASE) for p in rule.patterns] + self._compiled_patterns[rule.role] = patterns + + def route( + self, + signal_content: str, + analysis: Optional[Dict[str, Any]] = None, + source: Optional[str] = None, + ) -> Tuple[Role, float, Dict[str, Any]]: + """ + 根据信号内容和分析结果决定路由角色 + + Args: + signal_content: 信号内容 + analysis: AI 分析结果(可选) + source: 信号来源(可选) + + Returns: + (角色, 置信度, 路由详情) + """ + analysis = analysis or {} + details = { + "matched_rules": [], + "scores": {}, + "reasons": [], + } + + # 1. 首先检查是否需要升级到老板决策 + if self._should_escalate_to_boss(analysis): + details["reasons"].append("触发老板决策阈值") + details["matched_rules"].append("threshold_check") + return Role.BOSS, 1.0, details + + # 2. 计算每个角色的匹配分数 + scores: Dict[Role, float] = {rule.role: 0.0 for rule in self.ROLE_RULES} + + # 基于关键词匹配 + content_lower = signal_content.lower() + for rule in self.ROLE_RULES: + keyword_score = sum( + 1 for kw in rule.keywords if kw.lower() in content_lower + ) + if keyword_score > 0: + scores[rule.role] += keyword_score * 0.3 + details["scores"][f"{rule.role}_keywords"] = keyword_score + + # 基于正则模式匹配 + for rule in self.ROLE_RULES: + if rule.role in self._compiled_patterns: + pattern_score = sum( + 1 for pattern in self._compiled_patterns[rule.role] + if pattern.search(signal_content) + ) + if pattern_score > 0: + scores[rule.role] += pattern_score * 0.5 + details["scores"][f"{rule.role}_patterns"] = pattern_score + + # 基于触发词匹配 + for rule in self.ROLE_RULES: + trigger_score = sum( + 1 for trigger in rule.triggers if trigger.lower() in content_lower + ) + if trigger_score > 0: + scores[rule.role] += trigger_score * 0.7 + details["scores"][f"{rule.role}_triggers"] = trigger_score + + # 基于优先级加权 + for rule in self.ROLE_RULES: + if scores[rule.role] > 0: + scores[rule.role] *= (1 + rule.priority * 0.01) + + # 3. 找出最高分的角色 + max_role = Role.AI_ENGINEER # 默认 AI 工程师 + max_score = 0.0 + + for role, score in scores.items(): + if score > max_score: + max_score = score + max_role = role + elif score == max_score and score > 0: + # 分数相同时,优先级高的胜出 + for rule in self.ROLE_RULES: + if rule.role == role: + role_priority = rule.priority + if rule.role == max_role: + max_role_priority = rule.priority + if role_priority > max_role_priority: + max_role = role + + # 归一化置信度 + confidence = min(max_score / 3.0, 1.0) if max_score > 0 else 0.5 + + # 记录详情 + details["matched_rules"] = [ + rule.name for rule in self.ROLE_RULES if scores[rule.role] > 0 + ] + details["final_scores"] = {role.value: score for role, score in scores.items()} + details["reasons"].append(f"基于关键词和模式匹配") + + return max_role, confidence, details + + def _should_escalate_to_boss(self, analysis: Dict[str, Any]) -> bool: + """检查是否需要升级到老板决策""" + # 检查开发成本 + dev_days = analysis.get("resource_cost", {}).get("dev_days", 0) + if dev_days >= 5: + return True + + # 检查风险等级 + risk_level = analysis.get("risk_level", "") + if risk_level in ["CRITICAL", "HIGH"]: + return True + + # 检查是否涉及架构变更 + is_architecture = analysis.get("is_architecture_change", False) + if is_architecture: + return True + + return False + + def get_role_info(self, role: Role) -> Dict[str, Any]: + """获取角色信息""" + for rule in self.ROLE_RULES: + if rule.role == role: + return { + "role": role.value, + "name": rule.name, + "description": rule.description, + "keywords": rule.keywords, + "priority": rule.priority, + } + return {} + + def get_all_roles(self) -> List[Dict[str, Any]]: + """获取所有角色信息""" + return [ + { + "role": rule.role.value, + "name": rule.name, + "description": rule.description, + "priority": rule.priority, + } + for rule in sorted(self.ROLE_RULES, key=lambda r: -r.priority) + ] + + +class RoutingResult(BaseModel): + """路由结果模型""" + + role: str + confidence: float + details: Dict[str, Any] + signal_id: Optional[str] = None + timestamp: float = Field(default_factory=lambda: datetime.utcnow().timestamp()) + + +# 全局路由器实例 +_global_router: Optional[RoleRouter] = None + + +def get_router() -> RoleRouter: + """获取全局路由器实例""" + global _global_router + if _global_router is None: + _global_router = RoleRouter() + return _global_router + + +# 便捷函数 +def route_signal( + signal_content: str, + analysis: Optional[Dict[str, Any]] = None, + signal_id: Optional[str] = None, +) -> RoutingResult: + """ + 路由信号到对应角色 + + Args: + signal_content: 信号内容 + analysis: AI 分析结果 + signal_id: 信号 ID + + Returns: + 路由结果 + """ + router = get_router() + role, confidence, details = router.route(signal_content, analysis) + + return RoutingResult( + role=role.value, + confidence=confidence, + details=details, + signal_id=signal_id, + ) diff --git a/backend/app/events.py b/backend/app/events.py new file mode 100644 index 00000000..fdf55e86 --- /dev/null +++ b/backend/app/events.py @@ -0,0 +1,372 @@ +""" +Neural Sync - Event Bus System + +实现事件驱动架构的核心组件,支持异步事件发布/订阅模式。 +""" + +from enum import Enum +from dataclasses import dataclass, field +from datetime import datetime +from typing import Callable, Dict, List, Any, Optional +import asyncio +import logging +from contextlib import asynccontextmanager + +logger = logging.getLogger(__name__) + + +class EventType(Enum): + """系统事件类型定义""" + + # 信号事件 + SIGNAL_INGESTED = "signal.ingested" + SIGNAL_ANALYZED = "signal.analyzed" + SIGNAL_FEEDBACK = "signal.feedback" + SIGNAL_PRIORITY_CHANGED = "signal.priority_changed" + + # 任务事件 + TASK_ASSIGNED = "task.assigned" + TASK_STARTED = "task.started" + TASK_COMPLETED = "task.completed" + TASK_FAILED = "task.failed" + + # 代码事件 + CODE_COMMIT = "code.commit" + CODE_REVIEW_REQUESTED = "code.review_requested" + CODE_DRAFT_GENERATED = "code.draft_generated" + + # 审计事件 + AUDIT_STARTED = "audit.started" + AUDIT_COMPLETED = "audit.completed" + AUDIT_FAILED = "audit.failed" + + # 合并事件 + AUTO_MERGE_REQUESTED = "auto_merge.requested" + AUTO_MERGE_SUCCESS = "auto_merge.success" + AUTO_MERGE_FAILED = "auto_merge.failed" + AUTO_MERGE_COOLDOWN = "auto_merge.cooldown" + + # 集成事件 + PLANE_SYNC_SUCCESS = "integration.plane.success" + PLANE_SYNC_FAILED = "integration.plane.failed" + LARK_NOTIFY_SUCCESS = "integration.lark.success" + LARK_NOTIFY_FAILED = "integration.lark.failed" + + # 项目域事件 + DOMAIN_CREATED = "domain.created" + DOMAIN_UPDATED = "domain.updated" + DOMAIN_DELETED = "domain.deleted" + DOMAIN_SWITCHED = "domain.switched" + DOMAIN_SYNCING = "domain.syncing" + DOMAIN_SYNCED = "domain.synced" + + # 项目事件 + PROJECT_CREATED = "project.created" + PROJECT_UPDATED = "project.updated" + PROJECT_DELETED = "project.deleted" + SIGNAL_LINKED = "signal.linked" + SIGNAL_UNLINKED = "signal.unlinked" + + +@dataclass +class Event: + """事件数据结构""" + + type: EventType + data: Dict[str, Any] + timestamp: float = field(default_factory=lambda: datetime.utcnow().timestamp()) + source: Optional[str] = None + correlation_id: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典格式""" + return { + "type": self.type.value, + "data": self.data, + "timestamp": self.timestamp, + "source": self.source, + "correlation_id": self.correlation_id, + "metadata": self.metadata, + } + + +class EventBus: + """ + 事件总线 - 核心事件分发系统 + + 特性: + - 异步事件发布 + - 多订阅者支持 + - 事件过滤 + - 错误处理 + - 性能监控 + """ + + def __init__(self): + self._subscribers: Dict[EventType, List[Callable]] = {} + self._wildcard_subscribers: List[Callable] = [] + self._event_history: List[Event] = [] + self._max_history = 1000 + self._metrics = { + "published": 0, + "delivered": 0, + "failed": 0, + } + self._lock = asyncio.Lock() + self._running = False + + @property + def metrics(self) -> Dict[str, int]: + """获取事件指标""" + return self._metrics.copy() + + @property + def history(self) -> List[Dict[str, Any]]: + """获取事件历史""" + return [e.to_dict() for e in self._event_history] + + async def start(self): + """启动事件总线""" + self._running = True + logger.info("EventBus started") + + async def stop(self): + """停止事件总线""" + self._running = False + logger.info("EventBus stopped") + + def subscribe( + self, + event_type: EventType, + handler: Callable, + filter_func: Optional[Callable[[Event], bool]] = None, + ) -> Callable: + """ + 订阅事件 + + Args: + event_type: 事件类型 + handler: 事件处理函数 (async def handler(event: Event) -> None) + filter_func: 事件过滤函数 (def filter(event: Event) -> bool) + + Returns: + 取消订阅的函数 + """ + + async def wrapped_handler(event: Event): + if filter_func and not filter_func(event): + return + try: + await handler(event) + self._metrics["delivered"] += 1 + except Exception as e: + logger.error( + f"Error in event handler for {event_type.value}: {e}", + exc_info=True, + ) + self._metrics["failed"] += 1 + + # 包装为原始 handler 用于取消订阅 + wrapped_handler.original = handler # type: ignore + + async def _subscribe(): + async with self._lock: + if event_type not in self._subscribers: + self._subscribers[event_type] = [] + self._subscribers[event_type].append(wrapped_handler) + + asyncio.create_task(_subscribe()) + + def unsubscribe(): + asyncio.create_task(self._unsubscribe(event_type, wrapped_handler)) + + return unsubscribe + + def subscribe_wildcard( + self, + handler: Callable, + filter_func: Optional[Callable[[Event], bool]] = None, + ) -> Callable: + """订阅所有事件(通配符订阅)""" + + async def wrapped_handler(event: Event): + if filter_func and not filter_func(event): + return + try: + await handler(event) + self._metrics["delivered"] += 1 + except Exception as e: + logger.error( + f"Error in wildcard event handler: {e}", + exc_info=True, + ) + self._metrics["failed"] += 1 + + wrapped_handler.original = handler # type: ignore + + async def _subscribe(): + async with self._lock: + self._wildcard_subscribers.append(wrapped_handler) + + asyncio.create_task(_subscribe()) + + def unsubscribe(): + asyncio.create_task(self._unsubscribe_wildcard(wrapped_handler)) + + return unsubscribe + + async def _unsubscribe(self, event_type: EventType, handler: Callable): + """取消订阅""" + async with self._lock: + if event_type in self._subscribers: + self._subscribers[event_type] = [ + h for h in self._subscribers[event_type] if h != handler + ] + + async def _unsubscribe_wildcard(self, handler: Callable): + """取消通配符订阅""" + async with self._lock: + self._wildcard_subscribers = [ + h for h in self._wildcard_subscribers if h != handler + ] + + async def publish(self, event: Event) -> bool: + """ + 发布事件 + + Args: + event: 事件对象 + + Returns: + 是否成功发布 + """ + if not self._running: + logger.warning(f"EventBus not running, discarding event: {event.type.value}") + return False + + self._metrics["published"] += 1 + + # 记录历史 + self._event_history.append(event) + if len(self._event_history) > self._max_history: + self._event_history.pop(0) + + # 通知订阅者 + tasks = [] + + # 特定事件订阅者 + if event.type in self._subscribers: + for handler in self._subscribers[event.type]: + tasks.append(handler(event)) + + # 通配符订阅者 + for handler in self._wildcard_subscribers: + tasks.append(handler(event)) + + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + failed = sum(1 for r in results if isinstance(r, Exception)) + if failed > 0: + self._metrics["failed"] += failed + + logger.debug( + f"Published event {event.type.value} to {len(tasks)} subscribers" + ) + return True + + async def publish_and_wait( + self, event: Event, timeout: float = 5.0 + ) -> List[Any]: + """ + 发布事件并等待所有处理完成 + + Args: + event: 事件对象 + timeout: 超时时间(秒) + + Returns: + 所有处理器的返回值列表 + """ + if not self._running: + raise RuntimeError("EventBus not running") + + self._metrics["published"] += 1 + self._event_history.append(event) + + tasks = [] + + if event.type in self._subscribers: + tasks.extend(self._subscribers[event.type]) + + for handler in self._wildcard_subscribers: + tasks.append(handler(event)) + + if not tasks: + return [] + + try: + results = await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=timeout, + ) + return results + except asyncio.TimeoutError: + logger.warning( + f"Event {event.type.value} processing timed out after {timeout}s" + ) + raise + + def clear_history(self): + """清空事件历史""" + self._event_history.clear() + + def get_subscriber_count(self, event_type: Optional[EventType] = None) -> int: + """获取订阅者数量""" + if event_type: + return len(self._subscribers.get(event_type, [])) + return sum(len(subs) for subs in self._subscribers.values()) + len( + self._wildcard_subscribers + ) + + +# 全局事件总线实例 +_global_event_bus: Optional[EventBus] = None + + +def get_event_bus() -> EventBus: + """获取全局事件总线实例""" + global _global_event_bus + if _global_event_bus is None: + _global_event_bus = EventBus() + return _global_event_bus + + +@asynccontextmanager +async def event_bus_context(): + """事件总线上下文管理器""" + bus = get_event_bus() + await bus.start() + try: + yield bus + finally: + await bus.stop() + + +# 便捷函数 +async def publish_event(event_type: EventType, data: Dict[str, Any], **kwargs) -> bool: + """发布事件的便捷函数""" + event = Event(type=event_type, data=data, **kwargs) + return await get_event_bus().publish(event) + + +def subscribe_event( + event_type: EventType, handler: Callable +) -> Callable: + """订阅事件的便捷函数""" + return get_event_bus().subscribe(event_type, handler) + + +def subscribe_all_events(handler: Callable) -> Callable: + """订阅所有事件的便捷函数""" + return get_event_bus().subscribe_wildcard(handler) diff --git a/backend/app/seed_projects.py b/backend/app/seed_projects.py new file mode 100644 index 00000000..9be6f4a0 --- /dev/null +++ b/backend/app/seed_projects.py @@ -0,0 +1,401 @@ +""" +Seed Projects and Signals + +初始化示例项目和信号数据(新模型:BigInteger id) +含 SSTC 宪章:Pixelle-Video 项目关联宪章与任务 +""" + +import asyncio +from datetime import datetime +from typing import Optional +from sqlalchemy import func, select, or_, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from app.database import _is_sqlite +from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User +from app.services.auth import AuthService + +PIXELLE_NAME = "Pixelle-Video 集成" + + +async def seed_pixelle_project(db: Optional[AsyncSession] = None): + """ + 创建或恢复 Pixelle-Video 示例项目:无项目则全量创建;有项目但无宪章/无信号链接则补全 SSTC 数据。 + """ + from app.database import engine + + if db is None: + async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with async_session_maker() as session: + await _seed_projects(session) + else: + await _seed_projects(db) + + +async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Project) -> bool: + """ + 若「Pixelle-Video 集成」已存在但无宪章或无项目-信号链接,则补全信号、链接、宪章与任务。返回是否做了变更。 + """ + import json + + pid = pixelle_project.id + # 是否已有项目-信号链接 + link_count = await session.execute( + select(func.count()).select_from(ProjectSignalLink).where(ProjectSignalLink.project_id == pid) + ) + has_links = (link_count.scalar() or 0) > 0 + # 是否已有宪章 + charter_count = await session.execute( + select(func.count()).select_from(TruthNode).where( + TruthNode.project_id == pid, TruthNode.node_type == "charter" + ) + ) + has_charters = (charter_count.scalar() or 0) > 0 + if has_links and has_charters: + return False + + changed = False + pixelle_signals = [] + if not has_links: + pixelle_signals = [ + Signal( + source="HackerNews", + content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation", + impact_score=9.2, + priority="critical", + processed=True, + ), + Signal( + source="ProductHunt", + content="Pixelle-Video launches API for programmatic video content generation", + impact_score=8.5, + priority="high", + processed=True, + ), + Signal( + source="TechCrunch", + content="Pixelle-Video raises Series A funding to scale AI video infrastructure", + impact_score=7.8, + priority="high", + processed=True, + ), + ] + for signal in pixelle_signals: + signal.strategic_analysis = json.dumps({ + "decision_proposal": { + "is_strategic": True, + "threat_level": "HIGH", + "lead_time_days": 14, + "impact_score": getattr(signal, "impact_score", 0), + "recommendation": "立即集成 Pixelle-Video API 到主平台" + }, + "competitiveness_radar": { + "innovation": 85, + "market_potential": 90, + "technical_complexity": 75, + "resource_intensity": 70 + }, + "detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"], + "related_knowledge_nodes": [], + "rag_context_used": 0 + }) + signal.tasks_generated = json.dumps([ + {"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0}, + {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, + {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, + ]) + if _is_sqlite(): + next_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 + for i, s in enumerate(pixelle_signals): + s.id = next_id + i + for signal in pixelle_signals: + session.add(signal) + await session.flush() + next_link_id = None + if _is_sqlite(): + next_link_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1 + for i, signal in enumerate(pixelle_signals): + link = ProjectSignalLink( + project_id=pid, + signal_id=signal.id, + link_type="related", + relevance_score=1.0, + ) + if _is_sqlite() and next_link_id is not None: + link.id = next_link_id + i + session.add(link) + pixelle_project.signal_count = len(pixelle_signals) + changed = True + await session.flush() + + if not has_charters: + user_result = await session.execute(select(User).limit(1)) + seed_user = user_result.scalar_one_or_none() + if not seed_user: + seed_user = User( + username="boss", + email="boss@pmflow.local", + password_hash=AuthService.hash_password("boss123"), + name="负责人", + role="BOSS", + status="active", + ) + session.add(seed_user) + await session.flush() + if pixelle_signals: + signal_ids = [s.id for s in pixelle_signals] + else: + link_rows = await session.execute( + select(ProjectSignalLink.signal_id).where(ProjectSignalLink.project_id == pid) + ) + signal_ids = [r[0] for r in link_rows.fetchall()] + charter = TruthNode( + node_type="charter", + parent_id=None, + project_id=pid, + created_by=seed_user.id, + title="Pixelle-Video 集成需求宪章", + content={ + "rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台", + "signal_ids": signal_ids, + "decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成", + }, + version=1, + status="aligned", + ) + if _is_sqlite(): + charter.id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM truth_nodes"))).scalar() or 1 + session.add(charter) + await session.flush() + next_task_id = None + if _is_sqlite(): + next_task_id = (await session.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 + for idx, task_item in enumerate([ + {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, + {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, + {"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0}, + ]): + task = Task( + truth_node_id=charter.id, + project_id=pid, + title=task_item["title"], + required_skill_tag_ids=[], + status="todo", + priority=task_item["priority"], + estimated_hours=task_item["hours"], + ) + if _is_sqlite() and next_task_id is not None: + task.id = next_task_id + idx + session.add(task) + changed = True + print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个") + return changed + + +async def _seed_projects(session: AsyncSession): + import json + + result = await session.execute( + select(Project).where( + or_(Project.title == PIXELLE_NAME, Project.name == PIXELLE_NAME) + ) + ) + existing = result.scalar_one_or_none() + if existing: + restored = await _ensure_pixelle_sstc_data(session, existing) + if restored: + await session.commit() + else: + print("示例项目已存在且 SSTC 数据完整,跳过") + return + + pixelle_signals = [ + Signal( + source="HackerNews", + content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation", + impact_score=9.2, + priority="critical", + processed=True, + ), + Signal( + source="ProductHunt", + content="Pixelle-Video launches API for programmatic video content generation", + impact_score=8.5, + priority="high", + processed=True, + ), + Signal( + source="TechCrunch", + content="Pixelle-Video raises Series A funding to scale AI video infrastructure", + impact_score=7.8, + priority="high", + processed=True, + ), + ] + + for signal in pixelle_signals: + signal.strategic_analysis = json.dumps({ + "decision_proposal": { + "is_strategic": True, + "threat_level": "HIGH", + "lead_time_days": 14, + "impact_score": signal.impact_score, + "recommendation": "立即集成 Pixelle-Video API 到主平台" + }, + "competitiveness_radar": { + "innovation": 85, + "market_potential": 90, + "technical_complexity": 75, + "resource_intensity": 70 + }, + "detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"], + "related_knowledge_nodes": [], + "rag_context_used": 0 + }) + signal.tasks_generated = json.dumps([ + {"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0}, + {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, + {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, + ]) + session.add(signal) + + await session.flush() + + pixelle_project = Project( + name="Pixelle-Video 集成", + title="Pixelle-Video 集成", + description="集成 Pixelle-Video AI 视频生成平台到主系统", + status="active", + assigned_role="backend", + signal_count=len(pixelle_signals), + ) + session.add(pixelle_project) + await session.flush() + + for signal in pixelle_signals: + link = ProjectSignalLink( + project_id=pixelle_project.id, + signal_id=signal.id, + link_type="related", + relevance_score=1.0, + ) + session.add(link) + + ai_analyst_project = Project( + name="AI 分析引擎优化", + title="AI 分析引擎优化", + description="优化 LLM 服务性能,实现多模型投票机制和 RAG 检索增强", + status="active", + assigned_role="ai_engineer", + signal_count=1, + ) + session.add(ai_analyst_project) + await session.flush() + + ai_signal = Signal( + source="ArXiv", + content="New paper: Multi-Agent LLM Voting System improves accuracy by 23%", + impact_score=7.5, + priority="medium", + processed=True, + ) + ai_signal.strategic_analysis = json.dumps({ + "decision_proposal": { + "is_strategic": True, + "threat_level": "MEDIUM", + "lead_time_days": 30, + "impact_score": 7.5, + "recommendation": "实现多模型投票机制提升 AI 分析准确性" + }, + "detected_tech_stacks": ["LLM", "Voting", "Multi-Agent"] + }) + session.add(ai_signal) + await session.flush() + + ai_link = ProjectSignalLink( + project_id=ai_analyst_project.id, + signal_id=ai_signal.id, + link_type="related", + relevance_score=0.9, + ) + session.add(ai_link) + + await session.flush() + + # --- SSTC: 获取或创建默认用户,用于宪章 created_by --- + user_result = await session.execute(select(User).limit(1)) + seed_user = user_result.scalar_one_or_none() + if not seed_user: + seed_user = User( + username="boss", + email="boss@pmflow.local", + password_hash=AuthService.hash_password("boss123"), + name="负责人", + role="BOSS", + status="active", + ) + session.add(seed_user) + await session.flush() + + # --- SSTC: 为 Pixelle-Video 创建宪章(关联信号)--- + pixelle_signal_ids = [s.id for s in pixelle_signals] + charter = TruthNode( + node_type="charter", + parent_id=None, + project_id=pixelle_project.id, + created_by=seed_user.id, + title="Pixelle-Video 集成需求宪章", + content={ + "rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台", + "signal_ids": pixelle_signal_ids, + "decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成", + }, + version=1, + status="aligned", + ) + session.add(charter) + await session.flush() + + # --- SSTC: 创建任务(绑定到宪章)--- + for task_item in [ + {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, + {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, + {"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0}, + ]: + task = Task( + truth_node_id=charter.id, + project_id=pixelle_project.id, + title=task_item["title"], + required_skill_tag_ids=[], + status="todo", + priority=task_item["priority"], + estimated_hours=task_item["hours"], + ) + session.add(task) + + await session.commit() + + print(f"创建示例项目:") + print(f" - {pixelle_project.title} ({pixelle_project.assigned_role})") + print(f" - {ai_analyst_project.title} ({ai_analyst_project.assigned_role})") + print(f"\n关联信号: {len(pixelle_signals) + 1} 条") + print(f"[SSTC] Pixelle-Video 宪章 1 个,任务 3 个") + + +async def main(): + print("开始初始化示例数据...") + + from app.database import engine + from app.models import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await seed_pixelle_project() + + print("\n初始化完成!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/services/adaptive_rag_service.py b/backend/app/services/adaptive_rag_service.py new file mode 100644 index 00000000..e1fb526d --- /dev/null +++ b/backend/app/services/adaptive_rag_service.py @@ -0,0 +1,6598 @@ +"""全自适应 RAG 服务。 + +能力: +1. 意图识别(question / statement / mixed) +2. 多域路由(结构化 DB + 向量检索) +3. 回答生成与来源引用 +4. QA 记录、反馈、JSONL 导出 +5. 知识库文档/URL/飞书消息入库向量化 +""" + +from __future__ import annotations + +import ast +import asyncio +import hashlib +import json +import math +import os +from pathlib import Path +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +from sqlalchemy import and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_RAG_INTENT_CLASSIFICATION +from ..database import AsyncSessionLocal +from ..models import ( + KnowledgeBaseChunk, + KnowledgeBaseDoc, + KGEvidence, + KGEntity, + KGRelation, + Project, + ProjectSignalLink, + QAFeedback, + QARecord, + QASession, + QASessionState, + Signal, + Task, + TruthNode, + User, + UserProject, + UserWorkload, +) +from .context_cache import context_cache_service +from .llm import LLMService +from .signal_source_orchestration_service import SignalSourceOrchestrationService +from .symbol_index_service import SymbolIndexService + + +def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + raw = str(os.getenv(name, "") or "").strip() + if not raw: + value = int(default) + else: + try: + value = int(raw) + except Exception: + value = int(default) + return max(minimum, min(maximum, value)) + +QUESTION_HINTS = ( + "吗", + "么", + "呢", + "呀", + "啊", + "?", + "?", + "如何", + "怎么", + "为什么", + "为何", + "谁", + "哪里", + "有没有", + "有无", + "有吗", + "是否", + "是否有", + "是否存在", + "哪些", + "哪个", + "什么", + "是什么", + "多少", + "几", +) +PROJECT_EXISTENCE_RE = re.compile(r"(有没有|是否有|有).*?(项目|系统)|项目.*?(有没有|是否有|有).*(吗|\?)") +PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") +QUESTION_NOISE_RE = re.compile( + r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)" +) +STATEMENT_HINTS = ( + "需要", + "请", + "新增", + "优化", + "修复", + "安排", + "立项", + "开发", + "上线", +) +PROJECT_KEYWORDS = ( + "项目", + "信号", + "任务", + "宪章", + "进度", + "状态", + "工作量", + "负责人", +) +PEOPLE_KEYWORDS = ( + "成员", + "人员", + "同事", + "负责人", + "owner", + "assignee", + "谁负责", + "谁在做", + "名单", +) +DOC_KEYWORDS = ( + "文档", + "规范", + "spec", + "说明", + "流程", + "模板", + "手册", + "how to", + "怎么做", +) +FEATURE_QUERY_KEYWORDS = ( + "功能", + "用途", + "是干嘛", + "做什么", + "有什么用", + "页面", + "菜单", + "入口", + "在哪", + "ai配置", + "智能配置", + "配置中心", + "能力管理", + "用户管理", + "项目列表", + "团队负载", + "ai管家", +) +FEATURE_PURPOSE_HINTS = ( + "用途", + "作用", + "干嘛", + "做什么", + "是什么", + "入口", + "在哪", + "页面", +) +CORRECTION_CUE_KEYWORDS = ( + "我说的是", + "我说的不是", + "不是这个", + "不是系统架构", + "不是啊", + "不对", + "错了", + "答错", + "跑偏", + "偏了", + "不是这个意思", + "你理解错", + "你答偏了", + "答非所问", + "请纠正", + "纠正一下", + "请更正", + "上一条", + "上一个回答", + "不要模板", + "模板化", + "我要的是", + "我问的是", + "不是在问", +) +ENGINE_PROCESS_KEYWORDS = ( + "ai引擎", + "引擎驱动", + "驱动方式", + "调用链", + "调用流程", + "调用路径", + "模型路由", + "模型调用", + "路由策略", + "openclaw", + "网关", + "ai架构", + "ai 管家", + "ai管家", + "第一层", + "第二层", + "第三层", + "fallback", + "兜底", +) +DEEP_EXPLICIT_KEYWORDS = ( + "深入", + "深度", + "详细分析", + "证据链", + "调用链", + "逐层", + "逐步", + "详细展开", + "deep", +) +BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( + r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" +) +HISTORY_KEYWORDS = ("之前", "上次", "历史", "曾经", "之前说", "last time") +PMFLOW_KEYWORDS = ("复盘", "charter", "宪章", "task", "任务描述") +FOLLOWUP_REFERENTIAL_KEYWORDS = ( + "这些", + "它们", + "这个", + "那个", + "上面", + "前面", + "刚才", + "分别", + "具体", + "都有哪些", + "分别是什么", +) +FOLLOWUP_ENUMERATION_KEYWORDS = ( + "分别是什么", + "分别是啥", + "都有哪些", + "具体有哪些", + "列出来", + "清单", + "名单", +) +IDENTITY_KEYWORDS = ( + "你是谁", + "你是", + "自我介绍", + "介绍你自己", + "你能做什么", + "你会什么", + "你负责什么", + "你叫什么", +) +ARCHITECTURE_KEYWORDS = ( + "系统架构", + "架构", + "运行机制", + "技术栈", + "模块", + "服务组成", + "docker", + "数据库", + "redis", + "后端", + "前端", +) +CODE_KEYWORDS = ( + "代码", + "函数", + "方法", + "类", + "模块", + "文件", + "api", + "接口实现", + "service", + "model", + "schema", + "where defined", +) +URL_RE = re.compile(r"https?://\S+", flags=re.IGNORECASE) +SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?\n])") +PAGE_MARKER_RE = re.compile(r"\[\[PAGE:(\d{1,6})\]\]") + +MAX_CONTEXT_TOKENS = 8000 +DEFAULT_TOP_K = 5 +DB_QUERY_TIMEOUT_SECONDS = 5.0 +VECTOR_SEARCH_TIMEOUT_SECONDS = 10.0 +GRAPH_QUERY_TIMEOUT_SECONDS = 4.0 +AGENT_MAX_RETRIEVAL_STEPS = 3 +SESSION_GAP_MINUTES = 30 +SESSION_DEFAULT_TITLE = "新会话" +SESSION_TITLE_MIN_LEN = 5 + +THINKING_MODE_NORMAL = "normal" +THINKING_MODE_DEEP = "deep" +DEEP_RETRIEVAL_STEPS = _env_int("RAG_DEEP_RETRIEVAL_STEPS", 5, minimum=2, maximum=8) +DEEP_VECTOR_TOP_K = _env_int("RAG_DEEP_VECTOR_TOP_K", 12, minimum=6, maximum=40) +DEEP_CONTEXT_BLOCKS = _env_int("RAG_DEEP_CONTEXT_BLOCKS", 16, minimum=8, maximum=32) +DEEP_ANSWER_MAX_TOKENS = _env_int("RAG_DEEP_ANSWER_MAX_TOKENS", 1400, minimum=900, maximum=3000) + +QUERY_TYPE_COUNT = "count" +QUERY_TYPE_LIST = "list" +QUERY_TYPE_STATUS = "status" +QUERY_TYPE_EXPLAIN = "explain" +QUERY_TYPE_UNKNOWN = "unknown" + +ACTION_MODE_AUTO = "auto" +ACTION_MODE_READ = "read" +ACTION_MODE_WRITE = "write" +ACTION_MODE_READ_WRITE = "read_write" + +REQUIREMENT_WRITE_VERB_KEYWORDS = ( + "新增", + "增加", + "添加", + "接入", + "支持", + "实现", + "创建", + "新建", + "记录", + "入库", + "同步", + "补充", +) +REQUIREMENT_WRITE_OBJECT_KEYWORDS = ( + "信号", + "需求", + "任务", + "项目", + "功能", + "接口", + "页面", + "按钮", + "规则", + "模块", + "卡路里", + "食谱", +) +WRITE_EXPRESSIONS = ( + "给", + "帮我", + "请帮", + "请给", + "麻烦", +) + +COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") +LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些") +STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") + +ENTITY_ALIAS_MAP: dict[str, str] = { + "能力": "skill", + "技能": "skill", + "ability": "skill", + "abilities": "skill", + "skill": "skill", + "skills": "skill", + "负责人": "owner", + "owner": "owner", + "assignee": "owner", + "成员": "member", + "人员": "member", + "member": "member", + "任务": "task", + "task": "task", + "tasks": "task", + "项目": "project", + "project": "project", + "projects": "project", + "信号": "signal", + "signal": "signal", + "signals": "signal", + "函数": "function", + "function": "function", + "method": "function", + "类": "class", + "class": "class", + "模块": "module", + "module": "module", + "智能配置": "ai_config", + "ai配置": "ai_config", + "配置中心": "ai_config", + "能力管理": "skills", + "技能管理": "skills", + "用户管理": "users", + "项目列表": "projects", + "团队负载": "workloads", + "ai管家": "ai_steward", +} +TS_SYMBOL_RE = re.compile( + r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)" +) + + +@dataclass(slots=True) +class RetrievedItem: + source: str + content: str + score: float + source_label: str + metadata: dict[str, Any] + + +class AdaptiveRAGService: + """项目内统一 RAG 服务。""" + + _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.llm = LLMService() + self.context_cache = context_cache_service + + @staticmethod + def _normalize_thinking_mode(value: Optional[str]) -> str: + text = str(value or "").strip().lower() + if text == THINKING_MODE_DEEP: + return THINKING_MODE_DEEP + return THINKING_MODE_NORMAL + + @staticmethod + def _normalize_conversation_key(value: Optional[str]) -> str: + raw = str(value or "").strip() + if not raw: + return "" + cleaned = re.sub(r"\s+", "", raw.lower()) + cleaned = re.sub(r"[^a-z0-9:_\-\.]", "", cleaned) + return cleaned[:160] + + def _build_external_conversation_key( + self, + *, + asked_via: str, + source_chat_id: Optional[str], + conversation_key: Optional[str], + ) -> str: + explicit = self._normalize_conversation_key(conversation_key) + if explicit: + return explicit + channel = str(asked_via or "").strip().lower() + if channel == "feishu": + chat = str(source_chat_id or "").strip() + if chat: + return self._normalize_conversation_key(f"feishu:{chat}") + return "" + + @staticmethod + def _external_session_id_from_key(conversation_key: str) -> Optional[int]: + key = str(conversation_key or "").strip() + if not key: + return None + digest = hashlib.sha1(key.encode("utf-8")).hexdigest() + bucket = int(digest[:10], 16) % 500_000_000 + return 1_500_000_000 + bucket + + # ------------------------------------------------------------------ + # Intent + # ------------------------------------------------------------------ + async def classify_intent(self, message: str) -> dict[str, Any]: + text = str(message or "").strip() + if not text: + return { + "intent": "statement", + "confidence": 1.0, + "question_part": "", + "statement_part": "", + } + + heuristic = self._classify_intent_rule(text) + if heuristic["confidence"] >= 0.86: + return heuristic + + prompt = PROMPT_RAG_INTENT_CLASSIFICATION.format(message=text[:1200]).strip() + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_intent_classification", + temperature=0.0, + max_tokens=220, + use_prompt_profile=False, + ) + parsed = self._parse_json_object(raw) + intent = str(parsed.get("intent") or heuristic["intent"]).strip().lower() + if intent not in {"question", "statement", "mixed"}: + intent = heuristic["intent"] + confidence = self._to_float(parsed.get("confidence"), heuristic["confidence"]) + question_part = str(parsed.get("question_part") or "").strip() + statement_part = str(parsed.get("statement_part") or "").strip() + if not question_part and intent in {"question", "mixed"}: + question_part = heuristic["question_part"] + if not statement_part and intent in {"statement", "mixed"}: + statement_part = heuristic["statement_part"] + return { + "intent": intent, + "confidence": max(0.0, min(1.0, confidence)), + "question_part": question_part, + "statement_part": statement_part, + } + except Exception: + return heuristic + + def _classify_intent_rule(self, text: str) -> dict[str, Any]: + question_score = 0.0 + statement_score = 0.0 + lowered = text.lower() + + if "?" in text or "?" in text: + question_score += 0.35 + if lowered.endswith(("吗", "么", "呢", "呀", "啊")): + question_score += 0.24 + for hint in QUESTION_HINTS: + if hint in lowered: + question_score += 0.08 + if PROJECT_EXISTENCE_RE.search(text): + question_score += 0.26 + if re.search(r"(现在|当前|目前|请问).*(项目|任务|负责人|状态|进度|列表|清单)", text): + question_score += 0.18 + for hint in STATEMENT_HINTS: + if hint in lowered: + statement_score += 0.10 + + sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()] + question_sentences = [s for s in sentences if "?" in s or "?" in s or any(k in s.lower() for k in QUESTION_HINTS)] + statement_sentences = [s for s in sentences if s not in question_sentences] + + if question_sentences and statement_sentences: + return { + "intent": "mixed", + "confidence": 0.88, + "question_part": " ".join(question_sentences)[:1000], + "statement_part": " ".join(statement_sentences)[:1000], + } + + if question_score >= 0.28 and question_score >= statement_score: + return { + "intent": "question", + "confidence": min(0.9, 0.62 + question_score), + "question_part": text[:1000], + "statement_part": "", + } + + return { + "intent": "statement", + "confidence": min(0.9, 0.66 + statement_score), + "question_part": "", + "statement_part": text[:1000], + } + + # ------------------------------------------------------------------ + # Public ask flow + # ------------------------------------------------------------------ + async def answer_question( + self, + *, + question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int] = None, + source_chat_id: Optional[str] = None, + session_id: Optional[int] = None, + conversation_key: Optional[str] = None, + force_new_session: bool = False, + thinking_mode: str = THINKING_MODE_NORMAL, + action_mode: str = ACTION_MODE_AUTO, + sync_requirement: bool = True, + ) -> dict[str, Any]: + started = time.perf_counter() + clean_q = str(question or "").strip() + external_conversation_key = self._build_external_conversation_key( + asked_via=asked_via, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + active_thinking_mode = self._normalize_thinking_mode(thinking_mode) + deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + asked_via_lower = str(asked_via or "").strip().lower() + deep_retrieval_steps = DEEP_RETRIEVAL_STEPS + deep_vector_top_k = DEEP_VECTOR_TOP_K + if asked_via_lower == "feishu": + # 飞书链路优先低时延,保留 deep 能力但收紧预算。 + deep_retrieval_steps = min(DEEP_RETRIEVAL_STEPS, 3) + deep_vector_top_k = min(DEEP_VECTOR_TOP_K, 8) + if not clean_q: + return self._build_response_payload( + answer="问题为空,无法检索。", + sources=[], + domains_used=[], + response_time_ms=0, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=None, + session_id=None, + turn_index=1, + relevance_max=0.0, + query_type=QUERY_TYPE_UNKNOWN, + confidence=0.0, + missing_fields=["question"], + thinking_mode=active_thinking_mode, + ) + + session_id, turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=clean_q, + preferred_session_id=session_id, + force_new_session=bool(force_new_session), + ) + context_session_id: Optional[int] + if session_id is not None: + context_session_id = int(session_id) + elif asked_by is None: + context_session_id = self._external_session_id_from_key(external_conversation_key) + else: + context_session_id = None + conversation = await self._load_recent_conversation( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + external_session_id=context_session_id, + limit=10, + ) + state_row = await self._ensure_session_state(session_id=session_id) + effective_q, rewrite_meta = self._rewrite_question_with_state( + question=clean_q, + conversation=conversation, + session_state=state_row, + ) + query_type = self._classify_query_type(effective_q) + requested_action_mode = self._normalize_action_mode(action_mode) + effective_action_mode = self._resolve_action_mode( + requested_mode=requested_action_mode, + question=clean_q, + effective_question=effective_q, + ) + if ( + bool(sync_requirement) + and asked_via_lower != "feishu" + and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + ): + ack_result = await self._handle_ack_first_requirement( + question=clean_q, + effective_question=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + query_type=query_type, + action_mode=effective_action_mode, + started_at=started, + ) + if ack_result is not None: + return ack_result + forced_deep_reason = "" + if not deep_thinking: + forced_deep_reason = self._forced_deep_reason( + question=clean_q, + effective_question=effective_q, + conversation=conversation, + ) + if forced_deep_reason and self._should_force_deep_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=forced_deep_reason, + ): + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + else: + forced_deep_reason = "" + + clarification_answer = self._build_clarification_question( + original_question=clean_q, + effective_question=effective_q, + session_state=state_row, + ) + if clarification_answer: + elapsed = int((time.perf_counter() - started) * 1000) + clarify_sources = [ + { + "source": "system", + "label": "Session State", + "score": 0.95, + "metadata": {"mode": "clarification", "reason": "missing_subject"}, + } + ] + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=clarification_answer, + sources=clarify_sources, + intent="question", + domains_used=["system_context"], + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=clarification_answer, + query_type=query_type, + ambiguity="needs_clarification", + rewrite_meta=rewrite_meta, + sources=clarify_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=clarification_answer, + ) + return self._build_response_payload( + answer=clarification_answer, + sources=clarify_sources, + domains_used=["system_context"], + response_time_ms=elapsed, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=0.95, + query_type=query_type, + confidence=0.92, + missing_fields=["subject"], + thinking_mode=active_thinking_mode, + ) + + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + user_snapshot = await self.context_cache.get_user_snapshot( + db=self.db, + tenant_id=self.tenant_id, + user_id=asked_by, + ) + + if not deep_thinking: + deterministic_result = await self._run_deterministic_query( + question=effective_q, + project_id=project_id, + query_type=query_type, + ) + if deterministic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=deterministic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=str(deterministic_result.get("query_type") or query_type), + missing_fields=list(deterministic_result.get("missing_fields") or []), + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + direct_answer = None + if not deep_thinking: + direct_answer = self._build_snapshot_direct_answer( + effective_q, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + ) + if direct_answer: + elapsed = int((time.perf_counter() - started) * 1000) + direct_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": 0.99, + "metadata": {"mode": "snapshot_direct", "topic": "identity_or_architecture"}, + } + ] + if user_snapshot: + direct_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": 0.98, + "metadata": {"mode": "snapshot_direct", "topic": "current_user"}, + } + ) + + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=direct_answer, + sources=direct_sources, + intent="question", + domains_used=["system_context"], + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=direct_answer, + query_type=QUERY_TYPE_EXPLAIN, + ambiguity="clear", + rewrite_meta=rewrite_meta, + sources=direct_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=direct_answer, + ) + return self._build_response_payload( + answer=direct_answer, + sources=direct_sources, + domains_used=["system_context"], + response_time_ms=elapsed, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=1.0, + query_type=QUERY_TYPE_EXPLAIN, + confidence=0.99, + missing_fields=[], + thinking_mode=active_thinking_mode, + ) + + is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) + if not deep_thinking and not is_snapshot_direct_question: + glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) + if glm_semantic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=glm_semantic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + missing_fields=[], + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + domains = self._route_domains(effective_q) + if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: + domains.append("feishu") + retrieved, search_meta = await self._multi_step_search( + question=effective_q, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=state_row, + max_steps_override=deep_retrieval_steps if deep_thinking else None, + vector_top_k=deep_vector_top_k if deep_thinking else None, + ) + db_query_ms = int(search_meta.get("db_query_ms") or 0) + vector_search_ms = int(search_meta.get("vector_search_ms") or 0) + graph_query_ms = int(search_meta.get("graph_query_ms") or 0) + db_timeout = bool(search_meta.get("db_timeout")) + vector_timeout = bool(search_meta.get("vector_timeout")) + graph_timeout = bool(search_meta.get("graph_timeout")) + tool_trace = list(search_meta.get("tool_trace") or []) + tool_trace.insert( + 0, + { + "step": 0, + "tool": "thinking_mode", + "mode": active_thinking_mode, + "fast_path_bypassed": bool(deep_thinking), + "reason": forced_deep_reason or ("manual_deep" if self._normalize_thinking_mode(thinking_mode) == THINKING_MODE_DEEP else "normal"), + }, + ) + final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)] + + max_score = retrieved[0].score if retrieved else 0.0 + answer_text = await self._generate_answer( + effective_q, + retrieved, + conversation, + project_id, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + relevance_max=max_score, + thinking_mode=active_thinking_mode, + context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None, + answer_max_tokens=DEEP_ANSWER_MAX_TOKENS if deep_thinking else None, + ) + used_sources = self._dedupe_sources(retrieved) + if not used_sources: + used_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": round(max(max_score, 0.6), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, + } + ] + if user_snapshot: + used_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": round(max(max_score, 0.58), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, + } + ) + + auto_retry_reason = "" + if not deep_thinking: + auto_retry_reason = self._should_auto_deep_retry( + question=clean_q, + effective_question=effective_q, + sources=used_sources, + relevance_max=max_score, + ) + if auto_retry_reason and not self._allow_auto_deep_retry_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=auto_retry_reason, + ): + auto_retry_reason = "" + if auto_retry_reason: + deep_retrieved, deep_meta = await self._multi_step_search( + question=effective_q, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=state_row, + max_steps_override=deep_retrieval_steps, + vector_top_k=deep_vector_top_k, + ) + deep_max_score = deep_retrieved[0].score if deep_retrieved else 0.0 + deep_answer_text = await self._generate_answer( + effective_q, + deep_retrieved, + conversation, + project_id, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + relevance_max=deep_max_score, + thinking_mode=THINKING_MODE_DEEP, + context_block_limit=DEEP_CONTEXT_BLOCKS, + answer_max_tokens=DEEP_ANSWER_MAX_TOKENS, + ) + deep_sources = self._dedupe_sources(deep_retrieved) + if not deep_sources: + deep_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": round(max(deep_max_score, 0.6), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, + } + ] + if user_snapshot: + deep_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": round(max(deep_max_score, 0.58), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, + } + ) + + merged_metrics = self._merge_search_metrics( + { + "db_query_ms": db_query_ms, + "vector_search_ms": vector_search_ms, + "graph_query_ms": graph_query_ms, + "db_timeout": db_timeout, + "vector_timeout": vector_timeout, + "graph_timeout": graph_timeout, + }, + deep_meta, + ) + db_query_ms = int(merged_metrics.get("db_query_ms") or 0) + vector_search_ms = int(merged_metrics.get("vector_search_ms") or 0) + graph_query_ms = int(merged_metrics.get("graph_query_ms") or 0) + db_timeout = bool(merged_metrics.get("db_timeout")) + vector_timeout = bool(merged_metrics.get("vector_timeout")) + graph_timeout = bool(merged_metrics.get("graph_timeout")) + + deep_trace = list(deep_meta.get("tool_trace") or []) + tool_trace.append( + { + "step": 98, + "tool": "auto_deep_retry", + "reason": auto_retry_reason, + "prev_relevance_max": round(float(max_score or 0.0), 4), + "prev_snapshot_only": self._is_snapshot_only_sources(used_sources), + } + ) + for row in deep_trace: + tool_trace.append(row) + + final_domains = [str(x) for x in (deep_meta.get("domains_used") or final_domains or domains)] + retrieved = deep_retrieved + max_score = deep_max_score + answer_text = deep_answer_text + used_sources = deep_sources + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + + evidence_guard_triggered = self._should_block_answer_with_guard( + question=clean_q, + effective_question=effective_q, + sources=used_sources, + relevance_max=max_score, + ) + if evidence_guard_triggered: + answer_text = self._build_evidence_guard_answer(clean_q) + used_sources = [ + { + "source": "system", + "label": "Evidence Guard", + "score": 0.99, + "metadata": {"mode": "guard", "reason": "snapshot_only_process_query"}, + } + ] + max_score = min(float(max_score or 0.0), 0.35) + if "system_context" not in final_domains: + final_domains.append("system_context") + tool_trace.append( + { + "step": 99, + "tool": "answer_guard", + "reason": "insufficient_call_chain_evidence", + } + ) + + elapsed = int((time.perf_counter() - started) * 1000) + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=answer_text, + sources=used_sources, + intent="question", + domains_used=final_domains, + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) + if evidence_guard_triggered: + confidence = min(confidence, 0.35) + missing_fields = [] + if max_score < 0.45: + missing_fields = ["high_relevance_evidence"] + if evidence_guard_triggered: + missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"])) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=answer_text, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + ambiguity="clear" if not missing_fields else "low_evidence", + rewrite_meta=rewrite_meta, + sources=used_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=answer_text, + ) + + return self._build_response_payload( + answer=answer_text, + sources=used_sources, + domains_used=final_domains, + response_time_ms=elapsed, + db_query_ms=db_query_ms, + vector_search_ms=vector_search_ms, + graph_query_ms=graph_query_ms, + db_timeout=db_timeout, + vector_timeout=vector_timeout, + graph_timeout=graph_timeout, + tool_trace=tool_trace, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=round(max_score, 4), + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + confidence=confidence, + missing_fields=missing_fields, + thinking_mode=active_thinking_mode, + ) + + def _build_response_payload( + self, + *, + answer: str, + sources: list[dict[str, Any]], + domains_used: list[str], + response_time_ms: int, + db_query_ms: int, + vector_search_ms: int, + graph_query_ms: int = 0, + db_timeout: bool = False, + vector_timeout: bool = False, + graph_timeout: bool = False, + qa_id: Optional[int] = None, + session_id: Optional[int] = None, + turn_index: int = 1, + relevance_max: float = 0.0, + query_type: str = QUERY_TYPE_UNKNOWN, + confidence: float = 0.0, + missing_fields: Optional[list[str]] = None, + tool_trace: Optional[list[dict[str, Any]]] = None, + thinking_mode: str = THINKING_MODE_NORMAL, + action_taken: Optional[str] = None, + signal_id: Optional[int] = None, + reply_required: bool = False, + reply_text: Optional[str] = None, + ack_latency_ms: Optional[int] = None, + trace_id: Optional[str] = None, + processing_stage: Optional[str] = None, + ) -> dict[str, Any]: + evidence: list[dict[str, Any]] = [] + for item in sources[:8]: + if not isinstance(item, dict): + continue + evidence.append( + { + "source": str(item.get("source") or "unknown"), + "label": str(item.get("label") or "unknown"), + "score": round(self._to_float(item.get("score"), 0.0), 4), + "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, + } + ) + return { + "answer": str(answer or "").strip(), + "evidence": evidence, + "confidence": round(max(0.0, min(1.0, self._to_float(confidence, 0.0))), 4), + "thinking_mode": self._normalize_thinking_mode(thinking_mode), + "missing_fields": [str(x) for x in (missing_fields or []) if str(x).strip()], + "query_type": str(query_type or QUERY_TYPE_UNKNOWN), + "sources": sources, + "domains_used": domains_used, + "response_time_ms": int(response_time_ms or 0), + "retrieval_metrics": { + "db_query_ms": int(db_query_ms or 0), + "vector_search_ms": int(vector_search_ms or 0), + "graph_query_ms": int(graph_query_ms or 0), + "db_timeout": bool(db_timeout), + "vector_timeout": bool(vector_timeout), + "graph_timeout": bool(graph_timeout), + }, + "tool_trace": tool_trace if isinstance(tool_trace, list) else [], + "qa_id": int(qa_id) if qa_id is not None else None, + "session_id": int(session_id) if session_id is not None else None, + "turn_index": int(turn_index or 1), + "relevance_max": round(max(0.0, self._to_float(relevance_max, 0.0)), 4), + "action_taken": str(action_taken).strip() if action_taken else None, + "signal_id": int(signal_id) if signal_id is not None else None, + "reply_required": bool(reply_required), + "reply_text": str(reply_text or "").strip() if reply_text else None, + "ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None, + "trace_id": str(trace_id or "").strip() or None, + "processing_stage": str(processing_stage or "").strip() or None, + } + + @staticmethod + def _extract_ack_metadata_from_sources( + sources: Any, + *, + answer: str = "", + ) -> dict[str, Any]: + rows = sources if isinstance(sources, list) else [] + action_taken = "" + signal_id: Optional[int] = None + reply_required = False + ack_latency_ms: Optional[int] = None + trace_id = "" + processing_stage = "" + for source in rows: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + candidate_action = str(meta.get("action_taken") or "").strip() + if not candidate_action: + continue + action_taken = candidate_action + try: + signal_id = int(meta.get("signal_id")) if meta.get("signal_id") is not None else None + except Exception: + signal_id = None + trace_id = str(meta.get("trace_id") or "").strip() + processing_stage = str(meta.get("processing_stage") or meta.get("action_mode") or "").strip() + try: + ack_latency_ms = ( + int(meta.get("ack_latency_ms")) if meta.get("ack_latency_ms") is not None else None + ) + except Exception: + ack_latency_ms = None + reply_required = True + break + reply_text = str(answer or "").strip() if reply_required else "" + normalized_stage = processing_stage + if normalized_stage == "write": + normalized_stage = "ack_only" + return { + "action_taken": action_taken or None, + "signal_id": signal_id, + "reply_required": bool(reply_required), + "reply_text": reply_text or None, + "ack_latency_ms": ack_latency_ms, + "trace_id": trace_id or None, + "processing_stage": normalized_stage or None, + } + + @staticmethod + def _normalize_action_mode(value: Optional[str]) -> str: + text = str(value or "").strip().lower() + if text in {ACTION_MODE_READ, ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}: + return text + return ACTION_MODE_AUTO + + @classmethod + def _looks_like_write_intent(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS) + has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS) + if not has_verb or not has_object: + return False + if any(prefix in text for prefix in WRITE_EXPRESSIONS): + return True + if "新增" in text or "增加" in text or "记录" in text or "入库" in text: + return True + return False + + @classmethod + def _resolve_action_mode( + cls, + *, + requested_mode: str, + question: str, + effective_question: str, + ) -> str: + mode = cls._normalize_action_mode(requested_mode) + if mode != ACTION_MODE_AUTO: + return mode + + merged = f"{question} {effective_question}".strip() + if not cls._looks_like_write_intent(merged): + return ACTION_MODE_READ + has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS) + if has_question_form: + return ACTION_MODE_READ_WRITE + return ACTION_MODE_WRITE + + @staticmethod + def _normalize_requirement_summary(text: str) -> str: + normalized = re.sub(r"\s+", " ", str(text or "").strip()) + normalized = normalized.strip(",,。.!!??::;;") + return normalized[:300] + + @staticmethod + def _build_ack_trace_id(question: str) -> str: + base = str(question or "").strip() + digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:10] if base else "empty" + return f"rag-ack-{int(time.time() * 1000)}-{digest}" + + async def _ensure_project_signal_link( + self, + *, + signal_id: int, + project_id: int, + link_type: str = "related", + ) -> None: + existing = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return + self.db.add( + ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal_id), + link_type=link_type, + relevance_score=1.0, + ) + ) + + async def _create_or_reuse_requirement_signal( + self, + *, + question: str, + effective_question: str, + asked_by: Optional[int], + project_id: Optional[int], + ) -> tuple[str, Optional[int], Optional[int], str]: + summary = self._normalize_requirement_summary(effective_question or question) + resolved_project_id: Optional[int] = project_id + if resolved_project_id is None: + scoped_id, _, _ = await self._resolve_project_scope_for_question( + question=effective_question or question, + project_id=project_id, + ) + resolved_project_id = scoped_id + + existing_stmt = select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.source == "PMFlow", + Signal.summary == summary, + ) + ) + if resolved_project_id is None: + existing_stmt = existing_stmt.where(Signal.matched_project_id.is_(None)) + else: + existing_stmt = existing_stmt.where(Signal.matched_project_id == int(resolved_project_id)) + existing = ( + await self.db.execute( + existing_stmt.order_by(Signal.created_at.desc(), Signal.id.desc()).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + if resolved_project_id is not None: + try: + await self._ensure_project_signal_link( + signal_id=int(existing.id), + project_id=int(resolved_project_id), + ) + await self.db.commit() + except Exception as exc: + await self.db.rollback() + return ("signal_failed", None, resolved_project_id, str(exc)[:240]) + return ("signal_deduped", int(existing.id), resolved_project_id, "") + + actor = f"PMFlow用户#{int(asked_by)}" if asked_by is not None else "PMFlow用户" + content = f"[{actor}]: {summary}" + strategic_payload = { + "source": "pmflow_qa", + "original_question": str(question or "")[:4000], + "normalized_question": summary, + "write_mode": "ack_first", + "created_at": datetime.now(timezone.utc).isoformat(), + } + signal = Signal( + source="PMFlow", + content=content[:2000], + impact_score=0.65, + priority="medium", + processed=False, + strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), + tasks_generated=json.dumps([], ensure_ascii=False), + matched_project_id=int(resolved_project_id) if resolved_project_id is not None else None, + title=summary[:120] or "PMFlow 新需求信号", + signal_type="新需求", + summary=summary, + reasoning=json.dumps(["来自 PMFlow 问答写入意图,按 ACK-first 策略快速入库。"], ensure_ascii=False), + confidence=0.74, + recommended_action="enter_only", + status="待处理", + tenant_id=self.tenant_id, + ) + try: + self.db.add(signal) + await self.db.flush() + if resolved_project_id is not None: + await self._ensure_project_signal_link( + signal_id=int(signal.id), + project_id=int(resolved_project_id), + ) + await self.db.commit() + await self.db.refresh(signal) + return ("signal_created", int(signal.id), resolved_project_id, "") + except Exception as exc: + await self.db.rollback() + return ("signal_failed", None, resolved_project_id, str(exc)[:240]) + + @staticmethod + def _build_requirement_ack_text( + *, + action_taken: str, + signal_id: Optional[int], + project_id: Optional[int], + error_reason: str, + ) -> str: + project_text = f"项目#{int(project_id)}" if project_id is not None else "未指定项目" + if action_taken == "signal_created" and signal_id is not None: + return f"已入库:已为{project_text}创建信号 #{int(signal_id)},并进入待处理队列。" + if action_taken == "signal_deduped" and signal_id is not None: + return f"已入库:检测到重复需求,已复用{project_text}的信号 #{int(signal_id)}。" + if action_taken == "signal_failed": + reason = error_reason or "写入异常" + return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。" + return "已收到需求,正在处理中。" + + async def _handle_ack_first_requirement( + self, + *, + question: str, + effective_question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + turn_index: int, + state_row: Optional[QASessionState], + rewrite_meta: dict[str, Any], + context_session_id: Optional[int], + query_type: str, + action_mode: str, + started_at: float, + ) -> Optional[dict[str, Any]]: + action_taken, linked_signal_id, resolved_project_id, error_reason = await self._create_or_reuse_requirement_signal( + question=question, + effective_question=effective_question, + asked_by=asked_by, + project_id=project_id, + ) + if not action_taken: + return None + + ack_latency_ms = int((time.perf_counter() - started_at) * 1000) + trace_id = self._build_ack_trace_id(question) + reply_text = self._build_requirement_ack_text( + action_taken=action_taken, + signal_id=linked_signal_id, + project_id=resolved_project_id, + error_reason=error_reason, + ) + source_orchestration_result: Optional[dict[str, Any]] = None + if ( + linked_signal_id is not None + and resolved_project_id is not None + and action_taken in {"signal_created", "signal_deduped"} + and SignalSourceOrchestrationService.looks_like_signal_source_request(effective_question or question) + ): + try: + source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=self.tenant_id, + project_id=int(resolved_project_id), + signal_id=int(linked_signal_id), + initiating_text=effective_question or question, + requested_via="pmflow", + operator_user_id=asked_by, + dry_run=False, + ) + except Exception as exc: + source_orchestration_result = { + "status": "failed", + "reason": str(exc), + } + orchestration_status = str(source_orchestration_result.get("status") or "").lower() + if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: + source_payload = source_orchestration_result.get("signal_source") if isinstance(source_orchestration_result.get("signal_source"), dict) else {} + worker_executions = ( + source_orchestration_result.get("worker_executions") + if isinstance(source_orchestration_result.get("worker_executions"), list) + else [] + ) + if orchestration_status == "pending_manual": + reply_text = ( + f"{reply_text}" + f" 已记录信号源“{str(source_payload.get('source_name') or '未命名信号源')}”的接入请求," + "但当前仍缺少可直接执行的接入配置,已转待确认。" + ) + else: + reply_text = ( + f"{reply_text}" + f" 已登记信号源“{str(source_payload.get('source_name') or '未命名信号源')}”," + f"并已下发 {len(worker_executions)} 个 AI worker 执行。" + ) + answer_text = reply_text + sources = [ + { + "source": "pmflow", + "label": "Signal Queue", + "score": 0.98 if action_taken != "signal_failed" else 0.42, + "metadata": { + "action_taken": action_taken, + "signal_id": linked_signal_id, + "project_id": resolved_project_id, + "action_mode": action_mode, + "processing_stage": "ack_only", + "ack_latency_ms": ack_latency_ms, + "trace_id": trace_id, + "source_orchestration": source_orchestration_result, + }, + } + ] + + qa_id: Optional[int] = None + try: + qa = QARecord( + tenant_id=self.tenant_id, + question=question, + answer=answer_text, + sources=sources, + intent="statement", + domains_used=["signal_write"], + response_time_ms=ack_latency_ms, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=resolved_project_id if resolved_project_id is not None else project_id, + session_id=session_id, + turn_index=int(turn_index or 1), + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=question, + effective_question=effective_question, + answer=answer_text, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, + ambiguity="clear" if action_taken != "signal_failed" else "low_evidence", + rewrite_meta=rewrite_meta, + sources=sources, + ) + await self.db.commit() + await self.db.refresh(qa) + qa_id = int(qa.id) + except Exception: + await self.db.rollback() + + try: + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=question, + answer=answer_text, + ) + except Exception: + pass + + confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35 + missing_fields = [] if resolved_project_id is not None else ["project_id"] + return self._build_response_payload( + answer=answer_text, + sources=sources, + domains_used=["signal_write"], + response_time_ms=ack_latency_ms, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=qa_id, + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index or 1), + relevance_max=0.99 if action_taken != "signal_failed" else 0.30, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, + confidence=confidence, + missing_fields=missing_fields, + thinking_mode=THINKING_MODE_NORMAL, + action_taken=action_taken, + signal_id=linked_signal_id, + reply_required=True, + reply_text=reply_text, + ack_latency_ms=ack_latency_ms, + trace_id=trace_id, + processing_stage="ack_only", + ) + + def _classify_query_type(self, question: str) -> str: + text = str(question or "").strip().lower() + if not text: + return QUERY_TYPE_UNKNOWN + # 若同一句同时出现“多少/几个”和“列出/分别”,优先按 list 处理,避免追问被误判为 count。 + if any(k in text for k in LIST_HINTS): + return QUERY_TYPE_LIST + if any(k in text for k in COUNT_HINTS): + return QUERY_TYPE_COUNT + if any(k in text for k in STATUS_HINTS): + return QUERY_TYPE_STATUS + if "为什么" in text or "如何" in text or "怎么" in text: + return QUERY_TYPE_EXPLAIN + return QUERY_TYPE_UNKNOWN + + async def _run_deterministic_query( + self, + *, + question: str, + project_id: Optional[int], + query_type: str, + ) -> Optional[dict[str, Any]]: + q_type = str(query_type or QUERY_TYPE_UNKNOWN) + if q_type == QUERY_TYPE_COUNT: + return await self._run_count_query(question=question, project_id=project_id) + if q_type == QUERY_TYPE_LIST: + return await self._run_list_query(question=question, project_id=project_id) + if q_type == QUERY_TYPE_STATUS: + progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id) + if progress: + progress["query_type"] = QUERY_TYPE_STATUS + progress.setdefault("confidence", 0.98) + progress.setdefault("missing_fields", []) + return progress + existence = await self._try_fast_project_existence_answer(clean_q=question, project_id=project_id) + if existence: + existence["query_type"] = QUERY_TYPE_STATUS + existence.setdefault("confidence", 0.96) + existence.setdefault("missing_fields", []) + return existence + return None + + async def _resolve_project_scope_for_question( + self, + *, + question: str, + project_id: Optional[int], + ) -> tuple[Optional[int], Optional[str], str]: + if project_id is not None: + row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return (None, None, "") + return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "") + + direct_project_id, direct_project_name = await self._match_project_name_in_question(question=question) + if direct_project_id is not None and direct_project_name: + return (int(direct_project_id), str(direct_project_name), str(direct_project_name)[:24]) + + keyword = self._extract_project_lookup_keyword(question) + terms = self._extract_query_terms(question, preferred=keyword) + for term in terms: + matched = await self._search_projects_by_keyword(keyword=term, project_id=None, limit=5) + if matched: + target = matched[0] + return (int(target.id), str(target.title or target.name or f"项目#{int(target.id)}"), term) + return (None, None, keyword[:24] if keyword else "") + + @staticmethod + def _normalize_project_subject_text(text: str) -> str: + clean = str(text or "").strip().lower() + if not clean: + return "" + clean = clean.replace("—", "-").replace("_", " ") + clean = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", clean) + clean = re.sub( + r"(我说的是|我说的|请问|请|帮我|一下|这个里面|这里面|里面|这个|那个|一下子|目前|当前|现在|有多少个|多少个|信号源|信号|项目|系统)", + " ", + clean, + ) + clean = re.sub(r"[-]+", " ", clean) + clean = re.sub(r"\s+", " ", clean).strip() + return clean + + async def _match_project_name_in_question(self, *, question: str) -> tuple[Optional[int], Optional[str]]: + normalized_question = self._normalize_project_subject_text(question) + if not normalized_question: + return (None, None) + + projects = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == self.tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + .limit(200) + ) + ).scalars().all() + + best_id: Optional[int] = None + best_name: Optional[str] = None + best_score = 0 + for row in projects: + candidates = [ + str(row.title or "").strip(), + str(row.name or "").strip(), + ] + for name in candidates: + if len(name) < 2: + continue + normalized_name = self._normalize_project_subject_text(name) + if len(normalized_name) < 2: + continue + + score = 0 + if normalized_name in normalized_question: + score = len(normalized_name) + else: + tokens = [t for t in normalized_name.split(" ") if len(t) >= 2] + if tokens and all(t in normalized_question for t in tokens): + score = sum(len(t) for t in tokens) + + if score > best_score: + best_score = score + best_id = int(row.id) + best_name = str(row.title or row.name or f"项目#{int(row.id)}") + + return (best_id, best_name) + + async def _run_count_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + text = str(question or "").lower() + started = time.perf_counter() + has_project = "项目" in text + has_task = "任务" in text + has_signal = "信号" in text or "信号源" in text + has_people = self._is_people_query(text) + + # 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。 + if has_signal: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计信号数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(func.count(Signal.id)).where(Signal.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前关联信号总数为 {total} 条。" + else: + answer = f"当前可见信号总数为 {total} 条。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "signals", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_task: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计任务数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(func.count(Task.id)).where(Task.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Task.project_id == int(scoped_project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前任务总数为 {total} 条。" + else: + answer = f"当前可见任务总数为 {total} 条。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "tasks", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_people: + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + scoped_keyword = "" + if has_project or project_id is not None: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计成员人数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + user_stmt = select(func.count(User.id)).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + total = int((await self.db.execute(user_stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前活跃成员总数为 {total} 人。" + else: + answer = f"当前系统里活跃成员总数为 {total} 人。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.99, + "metadata": { + "table": "users", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_project: + stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"当前系统里项目总数为 {total} 个。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "count": total}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + return None + + async def _run_list_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + text = str(question or "").lower() + started = time.perf_counter() + if self._is_people_query(text): + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + scoped_keyword = "" + if "项目" in text or project_id is not None: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if "项目" in text and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出成员清单。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + user_stmt = select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + members = ( + await self.db.execute( + user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(30) + ) + ).scalars().all() + elapsed = int((time.perf_counter() - started) * 1000) + if not members: + answer = ( + f"项目「{scoped_project_name}」当前没有可列举的成员。" + if scoped_project_name + else "当前没有可列举的成员。" + ) + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.95, + "metadata": { + "table": "users", + "count": 0, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + member_ids = [int(row.id) for row in members] + workload_rows = ( + await self.db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(member_ids), + ) + ) + ) + ).all() + workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} + + project_count_rows = ( + await self.db.execute( + select(UserProject.user_id, func.count(UserProject.project_id)) + .where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.user_id.in_(member_ids), + ) + ) + .group_by(UserProject.user_id) + ) + ).all() + project_count_map = {int(uid): int(cnt or 0) for uid, cnt in project_count_rows} + + lines = [] + for row in members: + uid = int(row.id) + ratio = workload_map.get(uid) + if ratio is None: + ratio = round(float(row.workload or 0) / 100.0, 2) + role = str(row.role or "unknown") + project_count = project_count_map.get(uid, 0) + lines.append( + f"#{uid} {str(row.name or f'User#{uid}')}(角色:{role},负载:{float(ratio):.2f},关联项目:{project_count})" + ) + elapsed = int((time.perf_counter() - started) * 1000) + header = f"项目「{scoped_project_name}」成员清单如下:" if scoped_project_name else "当前团队成员清单如下:" + return { + "answer": header + "\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.99, + "metadata": { + "table": "users", + "count": len(members), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + + if "信号" in text or "信号源" in text: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if "项目" in text and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出信号清单。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) + signals = ( + await self.db.execute(stmt.order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(30)) + ).scalars().all() + elapsed = int((time.perf_counter() - started) * 1000) + if not signals: + answer = ( + f"项目「{scoped_project_name}」当前没有可列举的信号。" + if scoped_project_name + else "当前没有可列举的信号。" + ) + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.95, + "metadata": { + "table": "signals", + "count": 0, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + lines = [] + for row in signals: + title = str(row.title or row.summary or row.content or f"Signal#{int(row.id)}").replace("\n", " ").strip() + status = str(row.status or "unknown") + lines.append(f"#{int(row.id)} {title[:90]}({status})") + header = f"项目「{scoped_project_name}」信号清单如下:" if scoped_project_name else "当前信号清单如下:" + return { + "answer": header + "\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "signals", + "count": len(signals), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + + if "项目" in text: + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = ( + await self.db.execute(stmt.order_by(Project.updated_at.desc(), Project.id.desc()).limit(20)) + ).scalars().all() + if not projects: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": "当前没有可列举的项目。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.95, + "metadata": {"table": "projects", "count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + lines = [f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}({str(p.status or 'unknown')})" for p in projects] + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": "当前项目列表如下:\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "count": len(projects)}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + return None + + async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]: + if session_id is None: + return None + row = ( + await self.db.execute( + select(QASessionState).where( + and_( + QASessionState.tenant_id == self.tenant_id, + QASessionState.session_id == int(session_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = QASessionState( + tenant_id=self.tenant_id, + session_id=int(session_id), + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + ) + self.db.add(row) + await self.db.flush() + return row + + def _rewrite_question_with_state( + self, + *, + question: str, + conversation: list[dict[str, str]], + session_state: Optional[QASessionState], + ) -> tuple[str, dict[str, Any]]: + expanded = self._expand_followup_question(question, conversation) + current = str(expanded or question or "").strip() + if not current: + return current, {"mode": "empty"} + + lower = current.lower() + has_referential = any(k in lower for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not has_referential: + return current, {"mode": "direct"} + + state_subject = "" + state_entity_ids: list[int] = [] + if session_state is not None: + state_subject = str(session_state.last_subject or "").strip() + if isinstance(session_state.last_entity_ids, list): + for value in session_state.last_entity_ids: + try: + state_entity_ids.append(int(value)) + except Exception: + continue + if state_subject: + rewritten = f"围绕“{state_subject}”回答:{current}" + return rewritten[:1200], {"mode": "state_subject", "subject": state_subject} + if state_entity_ids: + anchor = ",".join(str(x) for x in state_entity_ids[:6]) + rewritten = f"围绕会话实体ID[{anchor}]回答:{current}" + return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]} + return current, {"mode": "referential_without_subject"} + + def _build_clarification_question( + self, + *, + original_question: str, + effective_question: str, + session_state: Optional[QASessionState], + ) -> Optional[str]: + text = str(original_question or "").strip().lower() + if not text: + return None + if self._is_correction_query(text): + if self._has_explicit_target_scope(text): + return None + return ( + "收到,你在纠正上一条回答。请直接说你要的目标范围,例如:" + "`我问的是 AI 引擎驱动方式,第一层是否调用 openclaw?`" + ) + has_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not has_referential: + return None + state_subject = str(getattr(session_state, "last_subject", "") or "").strip() + state_entity_ids = getattr(session_state, "last_entity_ids", None) + has_state_entities = isinstance(state_entity_ids, list) and any(str(x).strip() for x in state_entity_ids) + if state_subject or has_state_entities: + return None + if str(effective_question or "").strip() != text and "补充问题" in str(effective_question): + return None + return "你这句缺少明确主语。请先指明对象,例如:`分别是哪些项目?` 或 `请列出这些项目的名称和ID`。" + + async def _update_session_state_after_turn( + self, + *, + session_state: Optional[QASessionState], + question: str, + effective_question: str, + answer: str, + query_type: str, + ambiguity: str, + rewrite_meta: dict[str, Any], + sources: list[dict[str, Any]], + ) -> None: + if session_state is None: + return + subject = self._extract_state_subject(question=question, answer=answer, sources=sources) + if subject: + session_state.last_subject = subject[:255] + session_state.last_intent = "question" + session_state.ambiguity = str(ambiguity or "clear")[:20] + session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources) + + history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else [] + history.append( + { + "at": datetime.now(timezone.utc).isoformat(), + "question": question[:500], + "effective_question": effective_question[:500], + "query_type": str(query_type or QUERY_TYPE_UNKNOWN), + "ambiguity": str(ambiguity or "clear"), + "rewrite_meta": rewrite_meta if isinstance(rewrite_meta, dict) else {}, + } + ) + session_state.rewrite_history = history[-20:] + await self.db.flush() + + def _collect_entity_ids_from_sources(self, sources: list[dict[str, Any]]) -> list[int]: + ids: list[int] = [] + for source in sources: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + for key in ( + "project_id", + "task_id", + "signal_id", + "user_id", + "entity_id", + "subject_entity_id", + "object_entity_id", + ): + value = meta.get(key) + try: + if value is not None: + ids.append(int(value)) + except Exception: + continue + matched = meta.get("matched_project_ids") + if isinstance(matched, list): + for value in matched: + try: + ids.append(int(value)) + except Exception: + continue + uniq: list[int] = [] + for value in ids: + if value not in uniq: + uniq.append(value) + return uniq[:20] + + def _extract_state_subject( + self, + *, + question: str, + answer: str, + sources: list[dict[str, Any]], + ) -> str: + for source in sources: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + project_keyword = str(meta.get("query_keyword") or "").strip() + if project_keyword: + return project_keyword[:255] + subject_name = str(meta.get("subject_name") or "").strip() + object_name = str(meta.get("object_name") or "").strip() + if subject_name and object_name: + return f"{subject_name}->{object_name}"[:255] + + text = str(question or "").strip() + keyword = self._extract_project_lookup_keyword(text) + if len(keyword) >= 2: + return keyword[:255] + + answer_text = str(answer or "").strip() + m = re.search(r"项目[「\"]?([^」\"\n]{2,40})", answer_text) + if m: + return str(m.group(1)).strip()[:255] + return "" + + async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]: + session = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.id == int(session_id), + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if session is None: + raise ValueError("qa session 不存在") + + state = await self._ensure_session_state(session_id=int(session_id)) + history = state.rewrite_history if isinstance(state.rewrite_history, list) else [] + return { + "session_id": int(session_id), + "last_subject": str(state.last_subject or "") if state else "", + "last_entity_ids": state.last_entity_ids if state else [], + "last_intent": str(state.last_intent or "") if state else "", + "ambiguity": str(state.ambiguity or "") if state else "", + "rewrite_history": history[-20:], + "updated_at": state.updated_at.isoformat() if state and state.updated_at else None, + } + + async def _try_glm_project_semantic_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if self._is_people_query(question): + return None + if not any(k in question for k in ("项目", "系统", "进度", "状态", "有没有", "是否", "存在", "立项", "任务")): + return None + + db_started = time.perf_counter() + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).scalars().all() + if not projects: + db_query_ms = int((time.perf_counter() - db_started) * 1000) + return { + "answer": "当前项目库为空,无法检索到匹配项目。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "glm_semantic"}, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + project_ids = [int(p.id) for p in projects] + task_rows = [] + if project_ids: + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id.in_(project_ids), + ) + ) + ) + ).scalars().all() + db_query_ms = int((time.perf_counter() - db_started) * 1000) + + done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} + doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} + pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} + + task_stats: dict[int, dict[str, Any]] = {} + for pid in project_ids: + task_stats[pid] = { + "task_total": 0, + "done_count": 0, + "doing_count": 0, + "pending_count": 0, + "latest_task_updated_at": None, + } + + for row in task_rows: + pid = int(row.project_id) + if pid not in task_stats: + continue + stat = task_stats[pid] + stat["task_total"] += 1 + status = str(row.status or "").strip().lower() + if status in done_statuses: + stat["done_count"] += 1 + elif status in doing_statuses: + stat["doing_count"] += 1 + elif status in pending_statuses: + stat["pending_count"] += 1 + else: + stat["pending_count"] += 1 + row_time = row.updated_at or row.created_at + latest = stat.get("latest_task_updated_at") + if row_time and (latest is None or row_time > latest): + stat["latest_task_updated_at"] = row_time + + project_index: list[dict[str, Any]] = [] + for p in projects: + pid = int(p.id) + stats = task_stats.get(pid) or {} + project_index.append( + { + "id": pid, + "title": str(p.title or p.name or "")[:120], + "name": str(p.name or "")[:120], + "status": str(p.status or "unknown"), + "description": str(p.description or "")[:220], + "task_total": int(stats.get("task_total") or 0), + "done_count": int(stats.get("done_count") or 0), + "doing_count": int(stats.get("doing_count") or 0), + "pending_count": int(stats.get("pending_count") or 0), + "latest_task_updated_at": ( + stats.get("latest_task_updated_at").isoformat() + if stats.get("latest_task_updated_at") is not None + else None + ), + } + ) + + prompt = PROMPT_RAG_DB_PROJECT_QA.format( + question=question[:600], + project_index=json.dumps(project_index, ensure_ascii=False), + ).strip() + llm_started = time.perf_counter() + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_db_project_semantic_retrieval", + temperature=0.0, + max_tokens=520, + use_prompt_profile=False, + ) + parsed = self._parse_json_object(raw) + if not parsed: + return None + handled = bool(parsed.get("handled")) + answer = str(parsed.get("answer") or "").strip() + confidence = self._to_float(parsed.get("confidence"), 0.0) + matched_ids_raw = parsed.get("matched_project_ids") + matched_ids: list[int] = [] + if isinstance(matched_ids_raw, list): + for x in matched_ids_raw: + try: + matched_ids.append(int(x)) + except Exception: + continue + matched_ids = matched_ids[:8] + if not handled or not answer: + return None + response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": round(max(0.0, min(1.0, confidence or 0.9)), 4), + "metadata": { + "table": "projects", + "matched_project_ids": matched_ids, + "project_index_count": len(project_index), + "retrieval_mode": "glm_semantic", + }, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": response_ms, + } + except Exception: + return None + + async def _persist_fast_answer( + self, + *, + question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + fast_result: dict[str, Any], + session_id: Optional[int] = None, + external_session_id: Optional[int] = None, + turn_index: Optional[int] = None, + query_type: str = QUERY_TYPE_EXPLAIN, + missing_fields: Optional[list[str]] = None, + state_row: Optional[QASessionState] = None, + effective_question: Optional[str] = None, + rewrite_meta: Optional[dict[str, Any]] = None, + thinking_mode: str = THINKING_MODE_NORMAL, + ) -> dict[str, Any]: + started = time.perf_counter() + answer_text = str(fast_result.get("answer") or "未命中结果") + sources = list(fast_result.get("sources") or []) + active_session_id = session_id + active_turn_index = turn_index + if active_session_id is None or active_turn_index is None: + active_session_id, active_turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=question, + ) + active_turn_index = int(active_turn_index or 1) + active_session_id_int = int(active_session_id) if active_session_id is not None else None + + confidence = self._to_float(fast_result.get("confidence"), 0.96) + if missing_fields is None: + missing_fields = list(fast_result.get("missing_fields") or []) + final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN) + + qa = QARecord( + tenant_id=self.tenant_id, + question=question, + answer=answer_text, + sources=sources, + intent="question", + domains_used=["db"], + response_time_ms=int(fast_result.get("response_time_ms") or 0), + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=active_session_id_int, + turn_index=active_turn_index, + ) + self.db.add(qa) + if state_row is None and active_session_id_int is not None: + state_row = await self._ensure_session_state(session_id=active_session_id_int) + await self._update_session_state_after_turn( + session_state=state_row, + question=question, + effective_question=str(effective_question or question), + answer=answer_text, + query_type=final_query_type, + ambiguity="clear", + rewrite_meta=rewrite_meta or {}, + sources=sources, + ) + await self.db.commit() + await self.db.refresh(qa) + cache_session_id = active_session_id_int if active_session_id_int is not None else ( + int(external_session_id) if external_session_id is not None else None + ) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=cache_session_id, + question=question, + answer=answer_text, + ) + elapsed = int((time.perf_counter() - started) * 1000) + return self._build_response_payload( + answer=answer_text, + sources=sources, + domains_used=["db"], + response_time_ms=int(fast_result.get("response_time_ms") or elapsed), + db_query_ms=int(fast_result.get("db_query_ms") or 0), + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=active_session_id_int, + turn_index=active_turn_index, + relevance_max=0.99, + query_type=final_query_type, + confidence=confidence, + missing_fields=list(missing_fields or []), + thinking_mode=thinking_mode, + ) + + async def _try_fast_project_progress_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if not PROJECT_PROGRESS_RE.search(question): + return None + if "项目" not in question and "系统" not in question: + return None + + primary_keyword = self._extract_project_lookup_keyword(question) + query_terms = self._extract_query_terms(question, preferred=primary_keyword) + if not query_terms: + return None + + started = time.perf_counter() + matched: list[Project] = [] + used_keyword = query_terms[0] + for term in query_terms: + rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=3) + if rows: + matched = rows + used_keyword = term + break + if not matched: + db_query_ms = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{used_keyword}”的项目,无法判断进度。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "query_keyword": used_keyword, "match_count": 0}, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + target = matched[0] + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id == int(target.id), + ) + ) + ) + ).scalars().all() + db_query_ms = int((time.perf_counter() - started) * 1000) + + total = len(task_rows) + done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} + doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} + pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} + + done = 0 + doing = 0 + pending = 0 + latest_update: Optional[datetime] = None + for row in task_rows: + status = str(row.status or "").strip().lower() + if status in done_statuses: + done += 1 + elif status in doing_statuses: + doing += 1 + elif status in pending_statuses: + pending += 1 + else: + pending += 1 + + row_time = row.updated_at or row.created_at + if row_time and (latest_update is None or row_time > latest_update): + latest_update = row_time + + project_name = str(target.title or target.name or f"项目#{int(target.id)}") + project_status = str(target.status or "unknown") + if total == 0: + answer = ( + f"项目「{project_name}」当前状态为 {project_status}。" + "目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。" + ) + else: + completion = round((done / total) * 100, 1) + latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知" + answer = ( + f"项目「{project_name}」当前状态为 {project_status}。" + f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。" + f"最近任务更新时间:{latest_text}。" + ) + + if len(matched) > 1: + others = "、".join([str(x.title or x.name or f'#{x.id}') for x in matched[1:]]) + answer += f" 另有 {len(matched) - 1} 个同名候选项目:{others}。" + + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "project_id": int(target.id), + "query_keyword": used_keyword, + "match_count": len(matched), + }, + }, + { + "source": "db", + "label": "Project database", + "score": 0.98, + "metadata": { + "table": "tasks", + "project_id": int(target.id), + "task_total": total, + "done_count": done, + "doing_count": doing, + "pending_count": pending, + }, + }, + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + async def _try_fast_project_existence_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if "项目" not in question and "系统" not in question: + return None + if ( + "吗" not in question + and "?" not in question + and "?" not in question + and "是否有" not in question + and "有没有" not in question + and "有无" not in question + and not PROJECT_EXISTENCE_RE.search(question) + ): + return None + + primary_keyword = self._extract_project_lookup_keyword(question) + query_terms = self._extract_query_terms(question, preferred=primary_keyword) + if not query_terms: + return None + + started = time.perf_counter() + matched: list[Project] = [] + used_keyword = query_terms[0] + for term in query_terms: + rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=5) + if rows: + matched = rows + used_keyword = term + break + db_query_ms = int((time.perf_counter() - started) * 1000) + + if matched: + lines = [ + f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}(状态:{str(p.status or 'unknown')})" + for p in matched + ] + answer = f"有,当前匹配到 {len(matched)} 个相关项目:\n" + "\n".join(lines) + else: + answer = f"暂未检索到名称或描述包含“{used_keyword}”的项目。" + + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "query_keyword": used_keyword, + "match_count": len(matched), + }, + } + ], + "db_query_ms": db_query_ms, + } + + async def _search_projects_by_keyword( + self, + *, + keyword: str, + project_id: Optional[int], + limit: int, + ) -> list[Project]: + term = str(keyword or "").strip() + if not term: + return [] + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + like = f"%{term}%" + stmt = stmt.where( + or_( + Project.name.ilike(like), + Project.title.ilike(like), + Project.description.ilike(like), + ) + ) + return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all() + + @staticmethod + def _extract_project_lookup_keyword(question: str) -> str: + text = str(question or "").strip() + if not text: + return "" + text = text.replace("?", "?") + + direct_patterns = [ + r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + ] + for pattern in direct_patterns: + m = re.search(pattern, text) + if m: + value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text)) + for m in reversed(anchored_matches): + value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + patterns = [ + r"有(?:没有)?(.{1,24}?)(?:项目|系统).*\?", + r"项目.*?有(?:没有)?(.{1,24}?)(?:吗|\?)", + r"是否有(.{1,24}?)(?:项目|系统)", + r"(.{1,24}?)(?:项目|系统)(?:有吗|有没有|是否有)", + ] + for pattern in patterns: + m = re.search(pattern, text) + if not m: + continue + kw = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + kw = QUESTION_NOISE_RE.sub("", kw).strip() + if kw: + return kw[:24] + + fallback = re.sub(r"[??]", " ", text) + fallback = re.sub( + r"(项目|系统|目前|当前|现在|我们|有没有|是否有|有吗|有|吗|什么|进度|层面|阶段|是在|在|是|请问|一下|多少)", + " ", + fallback, + ) + fallback = re.sub(r"\s+", " ", fallback).strip(" ,。,.!??::;;") + return fallback[:24] + + @staticmethod + def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]: + base = str(question or "").strip() + terms: list[str] = [] + pref = str(preferred or "").strip() + if len(pref) >= 2: + terms.append(pref[:24]) + + clean = re.sub(r"[??,,。!!::;;()()\"“”']", " ", base) + clean = QUESTION_NOISE_RE.sub(" ", clean) + clean = re.sub(r"(项目|系统|进度|状态|阶段|完成率|层面)", " ", clean) + clean = re.sub(r"\s+", " ", clean).strip() + + token_candidates: list[str] = [] + for token in clean.split(" "): + t = token.strip() + if len(t) >= 2: + token_candidates.append(t[:24]) + + for m in re.finditer(r"[\u4e00-\u9fff]{2,10}", clean): + token_candidates.append(m.group(0)[:24]) + + for t in sorted(token_candidates, key=len, reverse=True): + if t and t not in terms: + terms.append(t) + return terms[:6] + + @staticmethod + def _is_people_query(question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + return any(k in text for k in PEOPLE_KEYWORDS) + + # ------------------------------------------------------------------ + # Manifest / Entity resolution / Agentic retrieval + # ------------------------------------------------------------------ + async def get_project_manifest(self, *, project_id: Optional[int] = None) -> dict[str, Any]: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + symbol_snapshot = await self._get_symbol_snapshot_cached() + + project_info: dict[str, Any] = {} + if project_id is not None: + row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if row is not None: + project_info = { + "id": int(row.id), + "title": str(row.title or row.name or f"项目#{int(row.id)}"), + "status": str(row.status or "unknown"), + } + + kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} + try: + kg_overview = { + "entity_total": int( + (await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id))) + .scalar_one() + or 0 + ), + "relation_total": int( + ( + await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) + ).scalar_one() + or 0 + ), + "evidence_total": int( + ( + await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) + ).scalar_one() + or 0 + ), + } + except Exception: + kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} + + platform = ( + system_snapshot.get("platform_profile") + if isinstance(system_snapshot.get("platform_profile"), dict) + else {} + ) + backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} + module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} + feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + feature_summary = { + "feature_total": len(feature_catalog), + "top_features": [ + { + "name": str(item.get("name") or ""), + "route": str(item.get("route") or ""), + } + for item in feature_catalog[:10] + if isinstance(item, dict) + ], + } + + return { + "tenant_id": self.tenant_id, + "project": project_info, + "module_counts": module_counts, + "feature_summary": feature_summary, + "symbol_summary": symbol_snapshot, + "kg_overview": kg_overview, + "manifest_text": self._compose_manifest_text( + project_info=project_info, + module_counts=module_counts, + feature_summary=feature_summary, + symbol_snapshot=symbol_snapshot, + kg_overview=kg_overview, + ), + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + async def entity_resolution( + self, + *, + question: str, + project_id: Optional[int] = None, + limit: int = 10, + ) -> dict[str, Any]: + normalized = self._normalize_entity_text(question) + alias_terms = self._collect_alias_terms(normalized) + query_terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) + for term in alias_terms: + if term not in query_terms: + query_terms.append(term) + + candidates: list[dict[str, Any]] = [] + if query_terms: + try: + max_terms = query_terms[:6] + conditions = [] + for term in max_terms: + like = f"%{term}%" + conditions.append(KGEntity.display_name.ilike(like)) + conditions.append(KGEntity.entity_key.ilike(like)) + rows = ( + await self.db.execute( + select(KGEntity) + .where( + and_( + KGEntity.tenant_id == self.tenant_id, + or_(*conditions), + ) + ) + .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) + .limit(max(20, int(limit) * 4)) + ) + ).scalars().all() + for row in rows: + payload = self._safe_dict(row.payload) + row_project_id = payload.get("project_id") + if project_id is not None and row_project_id is not None and int(row_project_id) != int(project_id): + continue + score = 0.0 + lowered_name = str(row.display_name or "").lower() + lowered_key = str(row.entity_key or "").lower() + for term in max_terms: + term_lower = term.lower() + if term_lower in lowered_name: + score += 0.34 + if term_lower in lowered_key: + score += 0.22 + if score <= 0: + continue + candidates.append( + { + "entity_id": int(row.id), + "entity_type": str(row.entity_type or ""), + "entity_key": str(row.entity_key or ""), + "display_name": str(row.display_name or ""), + "score": round(min(score, 0.99), 4), + } + ) + except Exception: + candidates = [] + candidates.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) + + return { + "normalized_question": normalized, + "canonical_terms": alias_terms, + "query_terms": query_terms[:8], + "matched_entities": candidates[: max(1, int(limit))], + } + + async def get_feature_catalog( + self, + *, + query: Optional[str] = None, + limit: int = 20, + ) -> dict[str, Any]: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + catalog = self._feature_catalog_from_snapshot(system_snapshot) + if query and str(query).strip(): + rows = self._match_feature_catalog_entries( + question=str(query), + system_snapshot=system_snapshot, + limit=max(1, min(int(limit), 100)), + ) + return { + "query": str(query), + "count": len(rows), + "items": rows, + } + max_rows = max(1, min(int(limit), 200)) + return { + "query": str(query or ""), + "count": min(len(catalog), max_rows), + "items": catalog[:max_rows], + } + + def _compose_manifest_text( + self, + *, + project_info: dict[str, Any], + module_counts: dict[str, Any], + feature_summary: dict[str, Any], + symbol_snapshot: dict[str, Any], + kg_overview: dict[str, Any], + ) -> str: + lines: list[str] = [] + if project_info: + lines.append( + f"当前项目:#{int(project_info.get('id') or 0)} {str(project_info.get('title') or 'unknown')} " + f"(status={str(project_info.get('status') or 'unknown')})" + ) + lines.append( + "后端模块规模:" + f"api={int(module_counts.get('api', 0))}," + f"services={int(module_counts.get('services', 0))}," + f"models={int(module_counts.get('models', 0))}," + f"schemas={int(module_counts.get('schemas', 0))}" + ) + lines.append(f"产品功能:total={int(feature_summary.get('feature_total', 0))}") + top_features = feature_summary.get("top_features") + if isinstance(top_features, list) and top_features: + feature_brief = ";".join( + f"{str(item.get('name') or '')}({str(item.get('route') or '')})" + for item in top_features[:6] + if isinstance(item, dict) and str(item.get("name") or "").strip() + ) + if feature_brief: + lines.append(f"核心入口:{feature_brief}") + lines.append( + "符号索引:" + f"files={int(symbol_snapshot.get('indexed_files', 0))}," + f"symbols={int(symbol_snapshot.get('symbol_total', 0))}," + f"python={int(symbol_snapshot.get('python_symbols', 0))}," + f"typescript={int(symbol_snapshot.get('typescript_symbols', 0))}" + ) + lines.append( + "知识图谱:" + f"entities={int(kg_overview.get('entity_total', 0))}," + f"relations={int(kg_overview.get('relation_total', 0))}," + f"evidences={int(kg_overview.get('evidence_total', 0))}" + ) + top_modules = symbol_snapshot.get("top_modules") + if isinstance(top_modules, list) and top_modules: + brief = ";".join( + f"{str(item.get('module') or '')}:{int(item.get('symbols') or 0)}" + for item in top_modules[:5] + if isinstance(item, dict) + ) + if brief: + lines.append(f"热点模块:{brief}") + return "\n".join(lines)[:2200] + + @staticmethod + def _normalize_entity_text(text: str) -> str: + normalized = str(text or "").strip().lower() + normalized = normalized.replace("—", "-").replace("_", " ") + normalized = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + def _collect_alias_terms(self, normalized_question: str) -> list[str]: + aliases: list[str] = [] + for token, canonical in ENTITY_ALIAS_MAP.items(): + if token in normalized_question and canonical not in aliases: + aliases.append(canonical) + return aliases[:8] + + @staticmethod + def _feature_catalog_from_snapshot(system_snapshot: Optional[dict[str, Any]]) -> list[dict[str, Any]]: + if not isinstance(system_snapshot, dict): + return [] + platform = system_snapshot.get("platform_profile") + if not isinstance(platform, dict): + return [] + catalog = platform.get("feature_catalog") + if not isinstance(catalog, list): + return [] + return [item for item in catalog if isinstance(item, dict)] + + def _match_feature_catalog_entries( + self, + *, + question: str, + system_snapshot: Optional[dict[str, Any]], + limit: int = 4, + ) -> list[dict[str, Any]]: + text = str(question or "").strip().lower() + if not text: + return [] + catalog = self._feature_catalog_from_snapshot(system_snapshot) + if not catalog: + return [] + query_terms = [x.lower() for x in self._extract_query_terms(question)[:8] if str(x).strip()] + matched: list[dict[str, Any]] = [] + for item in catalog: + name = str(item.get("name") or "").strip() + route = str(item.get("route") or "").strip() + component = str(item.get("page_component") or "").strip() + aliases_raw = item.get("aliases") + alias_values = aliases_raw if isinstance(aliases_raw, list) else [] + alias_pool = [name, route, component, *[str(x) for x in alias_values]] + alias_pool = [a.strip().lower() for a in alias_pool if str(a).strip()] + if not alias_pool: + continue + + score = 0.0 + for alias in alias_pool: + if alias and alias in text: + score += 0.45 + for term in query_terms: + if term and any(term in alias or alias in term for alias in alias_pool): + score += 0.18 + if score <= 0: + continue + matched.append( + { + **item, + "match_score": round(min(score, 0.99), 4), + } + ) + + matched.sort(key=lambda x: float(x.get("match_score") or 0.0), reverse=True) + return matched[: max(1, int(limit))] + + def _retrieve_feature_catalog_context( + self, + *, + question: str, + system_snapshot: Optional[dict[str, Any]], + max_items: int = 4, + ) -> list[RetrievedItem]: + rows = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=max_items, + ) + out: list[RetrievedItem] = [] + for row in rows: + name = str(row.get("name") or "unknown_feature") + route = str(row.get("route") or "") + summary = str(row.get("summary") or "") + page_file = str(row.get("page_file") or "") + page_component = str(row.get("page_component") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:4] if str(x).strip()) + content = f"功能: {name}\n用途: {summary}\n入口: {route}\n页面: {page_file}" + if api_text: + content += f"\n相关接口: {api_text}" + score = min(0.99, max(0.62, float(row.get("match_score") or 0.62))) + out.append( + RetrievedItem( + source="feature_catalog", + content=content[:1200], + score=score, + source_label="Feature Catalog", + metadata={ + "feature_name": name, + "route": route, + "page_file": page_file, + "page_component": page_component, + "apis": [str(x) for x in apis[:8] if str(x).strip()], + "match_score": round(score, 4), + }, + ) + ) + return out + + def _collect_feature_evidence( + self, + *, + question: str, + retrieved: list[RetrievedItem], + system_snapshot: Optional[dict[str, Any]], + ) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in retrieved: + if str(item.source or "").strip().lower() != "feature_catalog": + continue + meta = item.metadata if isinstance(item.metadata, dict) else {} + route = str(meta.get("route") or "").strip() + page_file = str(meta.get("page_file") or "").strip() + feature_name = str(meta.get("feature_name") or "").strip() + apis = meta.get("apis") if isinstance(meta.get("apis"), list) else [] + clean_apis = [str(x).strip() for x in apis if str(x).strip()] + if not feature_name and not route and not page_file: + continue + rows.append( + { + "feature_name": feature_name, + "route": route, + "page_file": page_file, + "apis": clean_apis[:6], + } + ) + if rows: + return rows[:4] + + # 若检索结果里没有 feature_catalog,回退到 snapshot 匹配结果 + fallback = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=3, + ) + for row in fallback: + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + rows.append( + { + "feature_name": str(row.get("name") or ""), + "route": str(row.get("route") or ""), + "page_file": str(row.get("page_file") or ""), + "apis": [str(x).strip() for x in apis[:6] if str(x).strip()], + } + ) + return rows[:3] + + def _enforce_feature_evidence_in_answer( + self, + *, + question: str, + answer_text: str, + retrieved: list[RetrievedItem], + system_snapshot: Optional[dict[str, Any]], + ) -> str: + text = str(question or "").strip().lower() + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + if not ask_feature: + return answer_text + + evidence_rows = self._collect_feature_evidence( + question=question, + retrieved=retrieved, + system_snapshot=system_snapshot, + ) + if not evidence_rows: + return answer_text + + # 已包含明确入口与接口信息时不重复追加 + lowered_answer = str(answer_text or "").lower() + if ("/" in lowered_answer and "接口" in lowered_answer) or "证据补充" in lowered_answer: + return answer_text + + lines: list[str] = [str(answer_text or "").strip(), "", "证据补充(Feature Catalog):"] + for idx, row in enumerate(evidence_rows, start=1): + feature_name = str(row.get("feature_name") or "未知功能") + route = str(row.get("route") or "") + page_file = str(row.get("page_file") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) + line = f"{idx}. {feature_name}" + if route: + line += f" | 路由: {route}" + if page_file: + line += f" | 页面: {page_file}" + if api_text: + line += f" | API: {api_text}" + lines.append(line) + return "\n".join(lines).strip()[:3200] + + def _symbol_cache_key(self) -> str: + return f"{self.tenant_id}:backend_symbol_snapshot" + + async def _get_symbol_snapshot_cached(self, *, ttl_seconds: int = 300) -> dict[str, Any]: + now = time.time() + key = self._symbol_cache_key() + cached = self._symbol_snapshot_cache.get(key) + if cached and cached[0] > now: + return dict(cached[1]) + try: + service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + built = await service.get_overview(top_n_files=10, auto_sync_if_empty=True) + except Exception: + built = self._build_symbol_index_snapshot() + self._symbol_snapshot_cache[key] = (now + max(60, int(ttl_seconds)), dict(built)) + return built + + def _build_symbol_index_snapshot( + self, + *, + max_files: int = 240, + max_symbols: int = 4000, + ) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + app_root = backend_root / "app" + if not app_root.exists(): + return { + "root": str(app_root), + "indexed_files": 0, + "symbol_total": 0, + "python_symbols": 0, + "typescript_symbols": 0, + "top_modules": [], + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + candidates = [ + p + for p in app_root.rglob("*") + if p.is_file() and p.suffix.lower() in {".py", ".ts", ".tsx", ".js", ".jsx"} + ] + candidates.sort(key=lambda p: str(p)) + file_paths = candidates[: max(1, int(max_files))] + + python_symbols = 0 + ts_symbols = 0 + total_symbols = 0 + module_counter: dict[str, int] = {} + + for path in file_paths: + rel = str(path.relative_to(backend_root)) + module = rel.split("/", 2)[:2] + module_name = "/".join(module) if module else rel + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + + symbols_in_file = 0 + if path.suffix.lower() == ".py": + extracted = self._extract_python_symbols(text) + symbols_in_file = len(extracted) + python_symbols += symbols_in_file + else: + extracted = self._extract_text_symbols(text) + symbols_in_file = len(extracted) + ts_symbols += symbols_in_file + if symbols_in_file <= 0: + continue + module_counter[module_name] = int(module_counter.get(module_name, 0)) + symbols_in_file + total_symbols += symbols_in_file + if total_symbols >= max_symbols: + break + + top_modules = [ + {"module": name, "symbols": int(count)} + for name, count in sorted(module_counter.items(), key=lambda item: item[1], reverse=True)[:10] + ] + return { + "root": str(app_root), + "indexed_files": len(file_paths), + "symbol_total": int(total_symbols), + "python_symbols": int(python_symbols), + "typescript_symbols": int(ts_symbols), + "top_modules": top_modules, + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + @staticmethod + def _extract_python_symbols(text: str) -> list[str]: + raw = str(text or "") + if not raw.strip(): + return [] + try: + tree = ast.parse(raw) + except Exception: + return [] + out: list[str] = [] + for node in tree.body: + if isinstance(node, ast.ClassDef): + out.append(f"class:{node.name}") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + out.append(f"function:{node.name}") + return out + + @staticmethod + def _extract_text_symbols(text: str) -> list[str]: + raw = str(text or "") + if not raw.strip(): + return [] + out: list[str] = [] + for match in TS_SYMBOL_RE.finditer(raw): + name = str(match.group(1) or "").strip() + if name: + out.append(name) + return out + + async def _run_single_retrieval_pass( + self, + *, + question: str, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + resolved_entities: Optional[dict[str, Any]] = None, + vector_top_k: Optional[int] = None, + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + async def _run_db_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if "db" not in domains: + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_structured_db(question, project_id=project_id), + timeout=DB_QUERY_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + async def _run_vector_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if not any(x in domains for x in {"doc", "feishu", "pmflow", "url"}): + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_vector_context( + question, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + top_k=vector_top_k, + ), + timeout=VECTOR_SEARCH_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + async def _run_graph_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if "graph" not in domains: + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_graph_context( + question, + project_id=project_id, + resolved_entities=resolved_entities, + ), + timeout=GRAPH_QUERY_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + (db_rows, db_ms, db_timeout), (vec_rows, vec_ms, vec_timeout), (graph_rows, graph_ms, graph_timeout) = ( + await asyncio.gather( + _run_db_retrieval(), + _run_vector_retrieval(), + _run_graph_retrieval(), + ) + ) + merged = [*db_rows, *vec_rows, *graph_rows] + merged.sort(key=lambda x: x.score, reverse=True) + return ( + merged, + { + "db_query_ms": int(db_ms), + "vector_search_ms": int(vec_ms), + "graph_query_ms": int(graph_ms), + "db_timeout": bool(db_timeout), + "vector_timeout": bool(vec_timeout), + "graph_timeout": bool(graph_timeout), + }, + ) + + async def _build_second_pass_query( + self, + *, + question: str, + project_id: Optional[int], + ) -> tuple[str, dict[str, Any]]: + resolved = await self.entity_resolution(question=question, project_id=project_id, limit=5) + manifest = await self.get_project_manifest(project_id=project_id) + hints: list[str] = [] + for term in resolved.get("canonical_terms") or []: + if str(term).strip(): + hints.append(str(term).strip()) + for row in resolved.get("matched_entities") or []: + if not isinstance(row, dict): + continue + name = str(row.get("display_name") or "").strip() + if name: + hints.append(name[:30]) + + symbol_names: list[str] = [] + try: + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + symbol_hits = await symbol_service.search_symbols(query=question, limit=8) + for row in symbol_hits.get("items") or []: + if not isinstance(row, dict): + continue + name = str(row.get("qualified_name") or row.get("symbol_name") or "").strip() + if name: + symbol_names.append(name[:60]) + except Exception: + symbol_names = [] + for name in symbol_names[:6]: + hints.append(name) + + feature_names: list[str] = [] + feature_routes: list[str] = [] + try: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=6, + ) + for row in feature_hits: + name = str(row.get("name") or "").strip() + route = str(row.get("route") or "").strip() + if name: + feature_names.append(name[:50]) + if route: + feature_routes.append(route[:80]) + except Exception: + feature_names = [] + feature_routes = [] + for name in feature_names[:4]: + hints.append(name) + for route in feature_routes[:3]: + hints.append(route) + + symbol_top = manifest.get("symbol_summary") if isinstance(manifest.get("symbol_summary"), dict) else {} + modules = symbol_top.get("top_modules") if isinstance(symbol_top.get("top_modules"), list) else [] + module_hint = ";".join( + str(item.get("module") or "") + for item in modules[:3] + if isinstance(item, dict) and str(item.get("module") or "").strip() + ) + clean_hints = [] + for item in hints: + if item and item not in clean_hints: + clean_hints.append(item) + if not clean_hints and not module_hint: + return question[:1200], {"mode": "no_hint"} + + revised = str(question or "").strip() + if clean_hints: + revised = f"{revised}。实体线索:{', '.join(clean_hints[:6])}" + if module_hint: + revised = f"{revised}。候选模块:{module_hint}" + if symbol_names: + revised = f"{revised}。候选符号:{', '.join(symbol_names[:4])}" + if feature_names: + revised = f"{revised}。候选功能:{', '.join(feature_names[:3])}" + if feature_routes: + revised = f"{revised}。候选入口:{', '.join(feature_routes[:2])}" + return revised[:1200], { + "mode": "second_pass_query", + "hint_count": len(clean_hints), + "module_hint": module_hint, + "symbol_hit_count": len(symbol_names), + "feature_hit_count": len(feature_names), + } + + @staticmethod + def _merge_retrieved_items( + base: list[RetrievedItem], + extra: list[RetrievedItem], + *, + limit: Optional[int] = None, + ) -> list[RetrievedItem]: + seen: set[str] = set() + merged: list[RetrievedItem] = [] + for item in [*base, *extra]: + key = f"{item.source}|{item.source_label}|{item.content[:120]}" + if key in seen: + continue + seen.add(key) + merged.append(item) + merged.sort(key=lambda x: x.score, reverse=True) + cap = max(DEFAULT_TOP_K * 2, 8) + if limit is not None: + cap = max(4, min(int(limit), 80)) + return merged[:cap] + + @staticmethod + def _should_second_pass(retrieved: list[RetrievedItem]) -> bool: + if not retrieved: + return True + top_score = float(retrieved[0].score or 0.0) + if top_score < 0.55: + return True + return len(retrieved) < 3 + + @staticmethod + def _merge_search_metrics(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + return { + "db_query_ms": int(base.get("db_query_ms", 0)) + int(delta.get("db_query_ms", 0)), + "vector_search_ms": int(base.get("vector_search_ms", 0)) + int(delta.get("vector_search_ms", 0)), + "graph_query_ms": int(base.get("graph_query_ms", 0)) + int(delta.get("graph_query_ms", 0)), + "db_timeout": bool(base.get("db_timeout")) or bool(delta.get("db_timeout")), + "vector_timeout": bool(base.get("vector_timeout")) or bool(delta.get("vector_timeout")), + "graph_timeout": bool(base.get("graph_timeout")) or bool(delta.get("graph_timeout")), + } + + @staticmethod + def _summarize_retrieval_hints(rows: list[RetrievedItem], *, max_items: int = 3) -> str: + hints: list[str] = [] + for row in rows[: max(1, int(max_items))]: + label = str(row.source_label or "").strip() + if label: + hints.append(label[:60]) + continue + content = str(row.content or "").strip() + if content: + hints.append(content.replace("\n", " ")[:60]) + uniq: list[str] = [] + for item in hints: + if item and item not in uniq: + uniq.append(item) + return ";".join(uniq[:3]) + + async def _multi_step_search( + self, + *, + question: str, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + session_state: Optional[QASessionState], + max_steps_override: Optional[int] = None, + vector_top_k: Optional[int] = None, + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6) + manifest = await self.get_project_manifest(project_id=project_id) + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + symbol_hits: dict[str, Any] = {"count": 0, "items": []} + try: + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + symbol_hits = await symbol_service.search_symbols(query=question, limit=6) + except Exception: + symbol_hits = {"count": 0, "items": []} + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=6, + ) + max_steps = max(1, min(int(max_steps_override or AGENT_MAX_RETRIEVAL_STEPS), 8)) + tool_trace: list[dict[str, Any]] = [ + { + "step": 0, + "tool": "entity_resolution", + "matched_entities": len(resolved.get("matched_entities") or []), + "canonical_terms": resolved.get("canonical_terms") if isinstance(resolved.get("canonical_terms"), list) else [], + }, + { + "step": 0, + "tool": "get_project_manifest", + "symbol_total": int( + ( + manifest.get("symbol_summary") + if isinstance(manifest.get("symbol_summary"), dict) + else {} + ).get("symbol_total", 0) + ), + "kg_entity_total": int( + ( + manifest.get("kg_overview") + if isinstance(manifest.get("kg_overview"), dict) + else {} + ).get("entity_total", 0) + ), + }, + { + "step": 0, + "tool": "search_symbols", + "hit_count": int(symbol_hits.get("count") or 0), + }, + { + "step": 0, + "tool": "feature_catalog_lookup", + "hit_count": len(feature_hits), + "feature_names": [str(x.get("name") or "") for x in feature_hits[:4]], + }, + ] + if session_state is not None and isinstance(session_state.last_entity_ids, list): + anchor_ids = [int(x) for x in session_state.last_entity_ids if str(x).isdigit()][:6] + else: + anchor_ids = [] + current_question = str(question or "").strip()[:1200] + current_domains: list[str] = [] + for item in domains: + if item not in current_domains: + current_domains.append(item) + merged_rows: list[RetrievedItem] = [] + merged_metrics: dict[str, Any] = { + "db_query_ms": 0, + "vector_search_ms": 0, + "graph_query_ms": 0, + "db_timeout": False, + "vector_timeout": False, + "graph_timeout": False, + } + merge_limit = max(DEFAULT_TOP_K * 2, 8) + if vector_top_k is not None: + merge_limit = max(merge_limit, max(2, int(vector_top_k)) * 3) + + for step in range(1, max_steps + 1): + resolved_payload = { + **resolved, + "anchor_entity_ids": anchor_ids, + } + step_rows, step_metrics = await self._run_single_retrieval_pass( + question=current_question, + domains=current_domains, + project_id=project_id, + source_chat_id=source_chat_id, + resolved_entities=resolved_payload, + vector_top_k=vector_top_k, + ) + feature_rows = self._retrieve_feature_catalog_context( + question=current_question, + system_snapshot=system_snapshot, + max_items=4, + ) + if feature_rows: + step_rows = self._merge_retrieved_items(feature_rows, step_rows, limit=merge_limit) + merged_rows = self._merge_retrieved_items(merged_rows, step_rows, limit=merge_limit) + merged_metrics = self._merge_search_metrics(merged_metrics, step_metrics) + top_score = round(float(step_rows[0].score), 4) if step_rows else 0.0 + tool_trace.append( + { + "step": step, + "tool": "parallel_retrieval", + "query": current_question[:240], + "domains": current_domains, + "items": len(step_rows), + "feature_items": len(feature_rows), + "top_score": top_score, + "merged_items": len(merged_rows), + } + ) + + if step >= max_steps: + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "max_steps_reached", + "max_steps": max_steps, + } + ) + break + if not self._should_second_pass(merged_rows): + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "enough_evidence", + "top_score": round(float(merged_rows[0].score), 4) if merged_rows else 0.0, + } + ) + break + + next_question, rewrite_meta = await self._build_second_pass_query( + question=current_question, + project_id=project_id, + ) + row_hint = self._summarize_retrieval_hints(step_rows) + if row_hint and row_hint not in next_question: + next_question = f"{next_question}。已命中线索:{row_hint}" + next_question = next_question[:1200] + if next_question.strip() == current_question.strip(): + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "query_not_changed", + "rewrite_meta": rewrite_meta, + } + ) + break + + next_domains: list[str] = [] + for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]: + if item not in next_domains: + next_domains.append(item) + tool_trace.append( + { + "step": step + 1, + "tool": "query_refine", + "rewrite_meta": rewrite_meta, + "next_query": next_question[:240], + "next_domains": next_domains, + "anchor_entity_ids": anchor_ids, + } + ) + current_question = next_question + current_domains = next_domains + + result_cap = max(DEFAULT_TOP_K * 2, 8) + if vector_top_k is not None: + result_cap = max(result_cap, max(2, int(vector_top_k)) * 2) + + return merged_rows[:result_cap], { + **merged_metrics, + "domains_used": current_domains, + "tool_trace": tool_trace, + } + + async def multi_step_search( + self, + *, + question: str, + project_id: Optional[int] = None, + source_chat_id: Optional[str] = None, + session_state: Optional[QASessionState] = None, + ) -> dict[str, Any]: + domains = self._route_domains(question) + rows, metrics = await self._multi_step_search( + question=question, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=session_state, + ) + return { + "question": question, + "domains": metrics.get("domains_used") or domains, + "items": self._dedupe_sources(rows), + "retrieval_metrics": { + "db_query_ms": int(metrics.get("db_query_ms") or 0), + "vector_search_ms": int(metrics.get("vector_search_ms") or 0), + "graph_query_ms": int(metrics.get("graph_query_ms") or 0), + "db_timeout": bool(metrics.get("db_timeout")), + "vector_timeout": bool(metrics.get("vector_timeout")), + "graph_timeout": bool(metrics.get("graph_timeout")), + }, + "tool_trace": metrics.get("tool_trace") if isinstance(metrics.get("tool_trace"), list) else [], + } + + # ------------------------------------------------------------------ + # Retrieval + # ------------------------------------------------------------------ + def _route_domains(self, question: str) -> list[str]: + text = question.lower() + domains: list[str] = [] + is_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + + if self._is_people_query(text): + domains.append("db") + if any(k in text for k in PROJECT_KEYWORDS): + domains.append("db") + domains.append("graph") + if is_referential: + domains.append("graph") + if any(k in text for k in CODE_KEYWORDS): + domains.append("graph") + domains.append("doc") + if any(k in text for k in FEATURE_QUERY_KEYWORDS): + domains.append("doc") + domains.append("graph") + domains.append("pmflow") + if any(k in text for k in DOC_KEYWORDS): + domains.append("doc") + if any(k in text for k in HISTORY_KEYWORDS): + domains.append("feishu") + if any(k in text for k in PMFLOW_KEYWORDS): + domains.append("pmflow") + if self._is_engine_process_query(text): + domains.append("graph") + domains.append("doc") + domains.append("pmflow") + domains.append("db") + if URL_RE.search(text) or "根据" in text and "http" in text: + domains.append("url") + + if not domains: + # 模糊问题:并行检索所有向量域 + domains.extend(["graph", "doc", "feishu", "pmflow", "url"]) + + # 去重并保持顺序 + uniq: list[str] = [] + for item in domains: + if item not in uniq: + uniq.append(item) + return uniq + + async def _retrieve_structured_db(self, question: str, *, project_id: Optional[int]) -> list[RetrievedItem]: + text = question.lower() + rows: list[RetrievedItem] = [] + ask_people = self._is_people_query(text) + + if ask_people: + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + if "项目" in text or project_id is not None: + scoped_project_id, scoped_project_name, _ = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + user_stmt = select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + members = ( + await self.db.execute( + user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(20) + ) + ).scalars().all() + if members: + preview = ";".join( + f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})" + for row in members[:10] + ) + rows.append( + RetrievedItem( + source="db", + content=( + f"成员清单({len(members)}人):{preview}" + if scoped_project_name is None + else f"项目「{scoped_project_name}」成员清单({len(members)}人):{preview}" + ), + score=0.95, + source_label="User database", + metadata={ + "table": "users", + "count": len(members), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + ) + ) + + if "项目" in text and not ask_people: + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id: + stmt = stmt.where(Project.id == project_id) + projects = (await self.db.execute(stmt.limit(8))).scalars().all() + if projects: + summary = ";".join( + f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects + ) + rows.append( + RetrievedItem( + source="db", + content=f"项目列表:{summary}", + score=0.93, + source_label="Project database", + metadata={"table": "projects", "count": len(projects)}, + ) + ) + + if "信号" in text or "待处理" in text: + signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) + if project_id: + signal_stmt = signal_stmt.where( + or_( + Signal.matched_project_id == project_id, + Signal.content.ilike(f"%#{project_id}%"), + ) + ) + pending = ( + await self.db.execute(signal_stmt.order_by(Signal.created_at.desc()).limit(10)) + ).scalars().all() + if pending: + preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5]) + rows.append( + RetrievedItem( + source="db", + content=f"最近信号({len(pending)}条):{preview}", + score=0.9, + source_label="Project database", + metadata={"table": "signals", "count": len(pending)}, + ) + ) + + if "任务" in text or "进度" in text: + task_stmt = select(Task).where(Task.tenant_id == self.tenant_id) + if project_id: + task_stmt = task_stmt.where(Task.project_id == project_id) + tasks = (await self.db.execute(task_stmt.order_by(Task.updated_at.desc()).limit(12))).scalars().all() + if tasks: + open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}] + owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None] + rows.append( + RetrievedItem( + source="db", + content=( + f"任务总数 {len(tasks)},未完成 {len(open_tasks)}。" + f"当前负责人ID(去重): {', '.join(sorted(set(owners))[:8]) or '暂无'}" + ), + score=0.91, + source_label="Project database", + metadata={"table": "tasks", "open_count": len(open_tasks)}, + ) + ) + + if "宪章" in text or "charter" in text: + charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id) + if project_id: + charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) + charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all() + if charters: + rows.append( + RetrievedItem( + source="db", + content="\n".join( + f"宪章#{c.id}: {(c.title or '无标题')} / {str(c.content or '')[:200]}" for c in charters + ), + score=0.92, + source_label="Project database", + metadata={"table": "truth_nodes", "count": len(charters)}, + ) + ) + + if "工作量" in text or "负载" in text: + workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id) + workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all() + if workloads: + content = ";".join( + f"user#{w.user_id} 已分配 {float(w.current_assigned_hours or 0):.1f}h / " + f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}" + for w in workloads + ) + rows.append( + RetrievedItem( + source="db", + content=f"团队工作量:{content}", + score=0.88, + source_label="Project database", + metadata={"table": "user_workloads", "count": len(workloads)}, + ) + ) + + return rows[:DEFAULT_TOP_K] + + async def _retrieve_graph_context( + self, + question: str, + *, + project_id: Optional[int], + resolved_entities: Optional[dict[str, Any]] = None, + ) -> list[RetrievedItem]: + try: + resolution = resolved_entities or {} + matched_entities = ( + resolution.get("matched_entities") if isinstance(resolution.get("matched_entities"), list) else [] + ) + anchor_ids_raw = ( + resolution.get("anchor_entity_ids") if isinstance(resolution.get("anchor_entity_ids"), list) else [] + ) + anchor_ids: list[int] = [] + for value in anchor_ids_raw: + try: + anchor_ids.append(int(value)) + except Exception: + continue + + seed_entities: list[KGEntity] = [] + if matched_entities: + ids: list[int] = [] + for row in matched_entities: + if not isinstance(row, dict): + continue + try: + ids.append(int(row.get("entity_id"))) + except Exception: + continue + if ids: + seed_entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(ids), + ) + ) + ) + ).scalars().all() + + if not seed_entities: + terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) + if terms: + conds = [] + for term in terms[:6]: + like = f"%{term}%" + conds.append(KGEntity.display_name.ilike(like)) + conds.append(KGEntity.entity_key.ilike(like)) + seed_entities = ( + await self.db.execute( + select(KGEntity) + .where( + and_( + KGEntity.tenant_id == self.tenant_id, + or_(*conds), + ) + ) + .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) + .limit(15) + ) + ).scalars().all() + if not seed_entities and anchor_ids: + seed_entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(anchor_ids[:10]), + ) + ) + ) + ).scalars().all() + if not seed_entities: + return [] + + seed_ids = [int(x.id) for x in seed_entities] + relation_rows = ( + await self.db.execute( + select(KGRelation) + .where( + and_( + KGRelation.tenant_id == self.tenant_id, + or_( + KGRelation.subject_entity_id.in_(seed_ids), + KGRelation.object_entity_id.in_(seed_ids), + ), + ) + ) + .order_by(KGRelation.weight.desc(), KGRelation.id.desc()) + .limit(120) + ) + ).scalars().all() + + entity_ids: set[int] = set(seed_ids) + for rel in relation_rows: + entity_ids.add(int(rel.subject_entity_id)) + entity_ids.add(int(rel.object_entity_id)) + entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(list(entity_ids)), + ) + ) + ) + ).scalars().all() + entity_map = {int(row.id): row for row in entities} + + relation_ids = [int(row.id) for row in relation_rows] + evidence_rows: list[KGEvidence] = [] + if relation_ids or entity_ids: + evidence_rows = ( + await self.db.execute( + select(KGEvidence) + .where( + and_( + KGEvidence.tenant_id == self.tenant_id, + or_( + KGEvidence.relation_id.in_(relation_ids or [-1]), + KGEvidence.entity_id.in_(list(entity_ids) or [-1]), + ), + ) + ) + .order_by(KGEvidence.occurred_at.desc(), KGEvidence.id.desc()) + .limit(220) + ) + ).scalars().all() + evidence_by_relation: dict[int, str] = {} + evidence_by_entity: dict[int, str] = {} + for ev in evidence_rows: + excerpt = str(ev.content_excerpt or "").strip() + if not excerpt: + continue + if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation: + evidence_by_relation[int(ev.relation_id)] = excerpt[:320] + if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity: + evidence_by_entity[int(ev.entity_id)] = excerpt[:320] + + out: list[RetrievedItem] = [] + for rel in relation_rows: + subject = entity_map.get(int(rel.subject_entity_id)) + target = entity_map.get(int(rel.object_entity_id)) + if subject is None or target is None: + continue + subject_payload = self._safe_dict(subject.payload) + target_payload = self._safe_dict(target.payload) + if project_id is not None: + subject_pid = subject_payload.get("project_id") + target_pid = target_payload.get("project_id") + if subject_pid is not None and int(subject_pid) != int(project_id): + if target_pid is not None and int(target_pid) != int(project_id): + continue + evidence_text = evidence_by_relation.get(int(rel.id)) + if not evidence_text: + evidence_text = evidence_by_entity.get(int(subject.id)) or evidence_by_entity.get(int(target.id)) or "" + content = ( + f"图路径: {str(subject.display_name or subject.entity_key)} --[{str(rel.relation_type)}]--> " + f"{str(target.display_name or target.entity_key)}\n" + f"证据: {evidence_text or '无显式证据摘录'}" + ) + weight = self._to_float(rel.weight, 1.0) + score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18)) + out.append( + RetrievedItem( + source="graph", + content=content[:1200], + score=score, + source_label="Knowledge Graph", + metadata={ + "relation_id": int(rel.id), + "relation_type": str(rel.relation_type or ""), + "subject_entity_id": int(subject.id), + "object_entity_id": int(target.id), + "subject_name": str(subject.display_name or subject.entity_key), + "object_name": str(target.display_name or target.entity_key), + "path": f"{subject.id}->{str(rel.relation_type)}->{target.id}", + }, + ) + ) + + if not out: + for seed in seed_entities[:DEFAULT_TOP_K]: + payload = self._safe_dict(seed.payload) + if project_id is not None and payload.get("project_id") is not None: + if int(payload.get("project_id")) != int(project_id): + continue + excerpt = evidence_by_entity.get(int(seed.id)) or "" + out.append( + RetrievedItem( + source="graph", + content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}", + score=0.58, + source_label="Knowledge Graph", + metadata={ + "entity_id": int(seed.id), + "entity_type": str(seed.entity_type or ""), + "entity_key": str(seed.entity_key or ""), + }, + ) + ) + out.sort(key=lambda x: x.score, reverse=True) + return out[: max(DEFAULT_TOP_K, 6)] + except Exception: + return [] + + async def _retrieve_vector_context( + self, + question: str, + *, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + top_k: Optional[int] = None, + ) -> list[RetrievedItem]: + question_embedding = await self.llm.get_embedding_async( + question, + db=self.db, + tenant_id=self.tenant_id, + ) + if not question_embedding: + return [] + + source_types = self._map_domains_to_source_types(domains) + if not source_types: + return [] + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type.in_(source_types), + ) + ) + + chunks = (await self.db.execute(stmt.limit(500))).scalars().all() + scored: list[RetrievedItem] = [] + for chunk in chunks: + meta = self._safe_dict(chunk.chunk_metadata).copy() + chunk_project_id = int(chunk.project_id) if chunk.project_id is not None else None + if chunk_project_id is not None: + meta.setdefault("project_id", chunk_project_id) + + if project_id is not None: + # 防止跨项目泄漏:Feishu/PMFlow 来源必须严格匹配项目组; + # 文档/URL 来源可读取全局(project_id 为空)和当前项目数据。 + if chunk.source_type in {"feishu", "pmflow"}: + if chunk_project_id != int(project_id): + continue + elif chunk_project_id is not None and chunk_project_id != int(project_id): + continue + + if chunk.source_type == "feishu" and source_chat_id: + chat_id = str(meta.get("chat_id") or "").strip() + if chat_id and chat_id != str(source_chat_id): + continue + if chunk.source_type == "pmflow": + target_url = str(meta.get("target_url") or "").strip() + if not target_url: + target_url = self._build_pmflow_target_url( + record_type=str(meta.get("record_type") or ""), + record_id=meta.get("record_id"), + project_id=meta.get("project_id"), + ) + if target_url: + meta["target_url"] = target_url + score = self._cosine_similarity(question_embedding, self._embedding_to_list(chunk.embedding)) + if score <= 0: + continue + label = self._source_label(chunk.source_type, meta) + scored.append( + RetrievedItem( + source=str(chunk.source_type or "doc"), + content=str(chunk.content or "")[:1200], + score=score, + source_label=label, + metadata=meta, + ) + ) + scored.sort(key=lambda x: x.score, reverse=True) + limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) + return scored[:limit] + + # ------------------------------------------------------------------ + # Knowledge indexing + # ------------------------------------------------------------------ + async def create_knowledge_doc( + self, + *, + title: str, + file_type: str, + raw_content: str, + uploaded_by: Optional[int], + source_type: str = "file", + file_url: Optional[str] = None, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + doc = await self.create_knowledge_doc_placeholder( + title=title, + file_type=file_type, + uploaded_by=uploaded_by, + source_type=source_type, + file_url=file_url, + ) + await self._index_doc_content( + doc=doc, + raw_content=raw_content, + project_id=project_id, + source_type=source_type, + ) + return self._serialize_doc(doc) + + async def create_knowledge_doc_placeholder( + self, + *, + title: str, + file_type: str, + uploaded_by: Optional[int], + source_type: str = "file", + file_url: Optional[str] = None, + ) -> KnowledgeBaseDoc: + doc = KnowledgeBaseDoc( + tenant_id=self.tenant_id, + title=(title or "未命名文档")[:255], + file_type=(file_type or "txt")[:32], + source_type=(source_type or "file")[:32], + file_url=(file_url or "")[:1024] or None, + uploaded_by=uploaded_by, + status="processing", + last_fetched_at=datetime.now(timezone.utc) if source_type == "url" else None, + ) + self.db.add(doc) + await self.db.commit() + await self.db.refresh(doc) + return doc + + async def process_knowledge_doc_content( + self, + *, + doc_id: int, + raw_content: str, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + await self._index_doc_content( + doc=doc, + raw_content=raw_content, + project_id=project_id, + source_type=str(doc.source_type or "file"), + ) + return self._serialize_doc(doc) + + async def _index_doc_content( + self, + *, + doc: KnowledgeBaseDoc, + raw_content: str, + project_id: Optional[int], + source_type: str, + ) -> None: + clean_content = str(raw_content or "").strip() + if not clean_content: + doc.status = "failed" + doc.error_message = "文档内容为空,无法向量化" + await self.db.commit() + return + + try: + await self.db.execute( + delete(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.doc_id == int(doc.id), + ) + ) + ) + + chunk_items = self._chunk_text_with_metadata(clean_content, chunk_size=500, overlap=50) + for idx, (part, extra_meta) in enumerate(chunk_items): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + chunk_metadata = { + "title": doc.title, + "file_type": doc.file_type, + "source_type": source_type, + "file_url": doc.file_url, + } + chunk_metadata.update(extra_meta) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=int(doc.id), + source_type="url" if source_type == "url" else "doc", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata=chunk_metadata, + ) + ) + doc.status = "complete" + doc.error_message = None + if source_type == "url": + doc.last_fetched_at = datetime.now(timezone.utc) + await self.db.commit() + except Exception as exc: + doc.status = "failed" + doc.error_message = str(exc)[:500] + await self.db.commit() + raise + + async def list_knowledge_docs(self) -> list[dict[str, Any]]: + docs = ( + await self.db.execute( + select(KnowledgeBaseDoc) + .where(KnowledgeBaseDoc.tenant_id == self.tenant_id) + .order_by(KnowledgeBaseDoc.created_at.desc()) + .limit(200) + ) + ).scalars().all() + return [ + self._serialize_doc(d) + for d in docs + ] + + async def get_knowledge_doc(self, *, doc_id: int) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + return self._serialize_doc(doc) + + async def delete_knowledge_doc(self, *, doc_id: int) -> bool: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + return False + + await self.db.execute( + delete(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.doc_id == int(doc_id), + KnowledgeBaseChunk.tenant_id == self.tenant_id, + ) + ) + ) + await self.db.delete(doc) + await self.db.commit() + return True + + async def ingest_feishu_message( + self, + *, + content: str, + sender: str, + chat_id: str, + message_id: Optional[str], + captured_at: Optional[datetime] = None, + project_id: Optional[int] = None, + ) -> None: + text = str(content or "").strip() + if not text: + return + chunks = self._chunk_text(text, chunk_size=320, overlap=40) + for idx, part in enumerate(chunks): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="feishu", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata={ + "sender": sender, + "chat_id": chat_id, + "message_id": message_id, + "captured_at": (captured_at or datetime.now(timezone.utc)).isoformat(), + }, + ) + ) + await self.db.commit() + + async def mark_doc_processing(self, *, doc_id: int) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + doc.status = "processing" + doc.error_message = None + await self.db.commit() + await self.db.refresh(doc) + return self._serialize_doc(doc) + + @staticmethod + async def ingest_feishu_message_async( + *, + tenant_id: int, + content: str, + sender: str, + chat_id: str, + message_id: Optional[str], + captured_at: Optional[datetime] = None, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.ingest_feishu_message( + content=content, + sender=sender, + chat_id=chat_id, + message_id=message_id, + captured_at=captured_at, + project_id=project_id, + ) + except Exception: + await session.rollback() + + @staticmethod + async def process_knowledge_doc_content_async( + *, + tenant_id: int, + doc_id: int, + raw_content: str, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.process_knowledge_doc_content( + doc_id=doc_id, + raw_content=raw_content, + project_id=project_id, + ) + except Exception: + await session.rollback() + + async def refresh_url_doc(self, *, doc_id: int, project_id: Optional[int] = None) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + if str(doc.source_type or "").lower() != "url": + raise ValueError("doc_is_not_url_source") + if not str(doc.file_url or "").strip(): + raise ValueError("doc_url_missing") + text = await self._fetch_url_text(str(doc.file_url)) + await self._index_doc_content( + doc=doc, + raw_content=text, + project_id=project_id, + source_type="url", + ) + await self.db.refresh(doc) + return self._serialize_doc(doc) + + @staticmethod + async def refresh_url_doc_async( + *, + tenant_id: int, + doc_id: int, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.refresh_url_doc(doc_id=doc_id, project_id=project_id) + except Exception: + await session.rollback() + + async def upsert_pmflow_record( + self, + *, + record_type: str, + record_id: int, + project_id: Optional[int], + title: str, + content: str, + ) -> None: + safe_type = str(record_type or "").strip().lower()[:32] + safe_id = int(record_id) + if not safe_type or safe_id <= 0: + return + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ) + if project_id is not None: + stmt = stmt.where(KnowledgeBaseChunk.project_id == int(project_id)) + existing = (await self.db.execute(stmt.limit(2000))).scalars().all() + for row in existing: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != safe_type: + continue + if str(meta.get("record_id") or "").strip() != str(safe_id): + continue + await self.db.delete(row) + + text = f"{title}\n\n{content}".strip() + chunks = self._chunk_text(text, chunk_size=500, overlap=50) + for idx, part in enumerate(chunks): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + target_url = self._build_pmflow_target_url( + record_type=safe_type, + record_id=safe_id, + project_id=project_id, + ) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="pmflow", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata={ + "record_type": safe_type, + "record_id": safe_id, + "project_id": int(project_id) if project_id is not None else None, + "target_url": target_url, + "title": title[:255], + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + ) + await self.db.commit() + + @staticmethod + async def upsert_pmflow_record_async( + *, + tenant_id: int, + record_type: str, + record_id: int, + project_id: Optional[int], + title: str, + content: str, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.upsert_pmflow_record( + record_type=record_type, + record_id=record_id, + project_id=project_id, + title=title, + content=content, + ) + except Exception: + await session.rollback() + + @staticmethod + def _build_intent_correction_record( + *, + user_utterance: str, + wrong_interpretation: str, + correct_intent: str, + canonical_action: str, + clarification_template: str, + risk_level: str, + project_id: Optional[int], + ) -> tuple[int, str, str, dict[str, Any]]: + utterance = " ".join(str(user_utterance or "").split()).strip()[:500] + wrong = " ".join(str(wrong_interpretation or "").split()).strip()[:300] + correct = " ".join(str(correct_intent or "").split()).strip().lower()[:60] + action = " ".join(str(canonical_action or "").split()).strip().lower()[:80] + clarification = " ".join(str(clarification_template or "").split()).strip()[:500] + risk = " ".join(str(risk_level or "medium").split()).strip().lower()[:20] or "medium" + + fingerprint = hashlib.sha1( + "||".join([utterance, wrong, correct, action, clarification, risk]).encode("utf-8") + ).hexdigest() + record_id = int(fingerprint[:12], 16) + title = f"意图纠错/{correct or 'unknown'}" + content = ( + f"用户原话: {utterance}\n" + f"错误理解: {wrong}\n" + f"正确意图: {correct}\n" + f"标准动作: {action}\n" + f"澄清模板: {clarification}\n" + f"风险等级: {risk}" + )[:4000] + metadata = { + "record_type": "intent_correction", + "record_id": str(record_id), + "project_id": int(project_id) if project_id is not None else None, + "user_utterance": utterance, + "wrong_interpretation": wrong, + "correct_intent": correct, + "canonical_action": action, + "clarification_template": clarification, + "risk_level": risk, + "target_url": None, + "title": title, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + return record_id, title, content, metadata + + async def upsert_intent_correction_sample( + self, + *, + user_utterance: str, + wrong_interpretation: str, + correct_intent: str, + canonical_action: str, + clarification_template: str, + risk_level: str, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + record_id, title, content, metadata = self._build_intent_correction_record( + user_utterance=user_utterance, + wrong_interpretation=wrong_interpretation, + correct_intent=correct_intent, + canonical_action=canonical_action, + clarification_template=clarification_template, + risk_level=risk_level, + project_id=project_id, + ) + if not metadata["user_utterance"] or not metadata["correct_intent"]: + raise ValueError("intent correction sample missing required fields") + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ) + existing = (await self.db.execute(stmt.limit(2000))).scalars().all() + for row in existing: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != "intent_correction": + continue + if str(meta.get("record_id") or "").strip() != str(record_id): + continue + await self.db.delete(row) + + embedding = await self.llm.get_embedding_async(content, db=self.db, tenant_id=self.tenant_id) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="pmflow", + project_id=int(project_id) if project_id is not None else None, + content=content, + embedding=embedding if embedding else None, + chunk_index=0, + chunk_metadata=metadata, + ) + ) + await self.db.commit() + return metadata + + async def retrieve_intent_correction_samples( + self, + *, + utterance: str, + top_k: int = 3, + project_id: Optional[int] = None, + ) -> list[dict[str, Any]]: + text = " ".join(str(utterance or "").split()).strip() + if len(text) < 2: + return [] + question_embedding = await self.llm.get_embedding_async( + text, + db=self.db, + tenant_id=self.tenant_id, + ) + if not question_embedding: + return [] + + rows = ( + await self.db.execute( + select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ).limit(500) + ) + ).scalars().all() + scored: list[dict[str, Any]] = [] + for row in rows: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != "intent_correction": + continue + try: + chunk_project_id = int(meta.get("project_id")) if meta.get("project_id") is not None else None + except Exception: + chunk_project_id = None + if project_id is not None and chunk_project_id is not None and chunk_project_id != int(project_id): + continue + score = self._cosine_similarity(question_embedding, self._embedding_to_list(row.embedding)) + if score <= 0: + continue + scored.append( + { + "score": round(float(score), 4), + "record_id": str(meta.get("record_id") or ""), + "user_utterance": str(meta.get("user_utterance") or ""), + "wrong_interpretation": str(meta.get("wrong_interpretation") or ""), + "correct_intent": str(meta.get("correct_intent") or ""), + "canonical_action": str(meta.get("canonical_action") or ""), + "clarification_template": str(meta.get("clarification_template") or ""), + "risk_level": str(meta.get("risk_level") or "medium"), + "project_id": chunk_project_id, + } + ) + scored.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) + return scored[: max(1, min(int(top_k or 3), 10))] + + # ------------------------------------------------------------------ + # QA session helpers + # ------------------------------------------------------------------ + @staticmethod + def _to_naive_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + @staticmethod + def _normalize_session_title_raw(text: str, *, max_len: int = 30) -> str: + compact = " ".join(str(text or "").replace("\n", " ").split()).strip() + if not compact: + return "" + return compact[:max_len] + + @classmethod + def _build_auto_session_title(cls, question: str) -> Optional[str]: + normalized = cls._normalize_session_title_raw(question, max_len=30) + if not normalized: + return None + compact = re.sub(r"\s+", "", normalized) + if len(compact) < SESSION_TITLE_MIN_LEN: + return None + return normalized + + @staticmethod + def _is_default_session_title(title: Optional[str]) -> bool: + clean = str(title or "").strip() + return (not clean) or clean == SESSION_DEFAULT_TITLE + + async def _touch_session_for_new_record( + self, + *, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + question: str, + preferred_session_id: Optional[int] = None, + force_new_session: bool = False, + ) -> tuple[Optional[int], int]: + if asked_by is None: + return (None, 1) + + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + chosen: Optional[QASession] = None + preferred_id = int(preferred_session_id) if preferred_session_id else 0 + if preferred_id > 0: + chosen = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(asked_by), + QASession.id == preferred_id, + ) + ) + ) + ).scalar_one_or_none() + + # 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。 + if chosen is None and not force_new_session: + latest_record = ( + await self.db.execute( + select(QARecord.session_id, QARecord.created_at) + .where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.asked_by == int(asked_by), + QARecord.asked_via == (asked_via or "pmflow")[:20], + QARecord.session_id.is_not(None), + ) + ) + .order_by(QARecord.created_at.desc(), QARecord.id.desc()) + .limit(1) + ) + ).first() + if latest_record and latest_record.session_id is not None: + last_at = self._to_naive_utc(latest_record.created_at) + if last_at is not None: + gap_seconds = abs((now_utc - last_at).total_seconds()) + is_same_day = now_utc.date() == last_at.date() + if is_same_day and gap_seconds <= SESSION_GAP_MINUTES * 60: + chosen = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(asked_by), + QASession.id == int(latest_record.session_id), + ) + ) + ) + ).scalar_one_or_none() + + if chosen is None: + title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE + chosen = QASession( + tenant_id=self.tenant_id, + user_id=int(asked_by), + title=title[:120], + project_id=project_id, + started_at=now_utc, + last_message_at=now_utc, + message_count=0, + ) + self.db.add(chosen) + await self.db.flush() + else: + if chosen.project_id is None and project_id is not None: + chosen.project_id = int(project_id) + chosen.last_message_at = now_utc + + chosen.message_count = int(chosen.message_count or 0) + 1 + if self._is_default_session_title(chosen.title): + generated = self._build_auto_session_title(question) + if generated: + chosen.title = generated[:120] + await self.db.flush() + return (int(chosen.id), int(chosen.message_count)) + + # ------------------------------------------------------------------ + # QA feedback/list/export + # ------------------------------------------------------------------ + async def _latest_feedback_map(self, record_ids: list[int]) -> dict[int, QAFeedback]: + ids = [int(x) for x in record_ids if x is not None] + if not ids: + return {} + feedback_rows = ( + await self.db.execute( + select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(ids), + ) + ) + ) + ).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + fb_at = self._to_naive_utc(fb.created_at) or datetime.min + curr_at = self._to_naive_utc(curr.created_at) if curr else None + if curr is None or curr_at is None or fb_at >= curr_at: + latest_feedback[key] = fb + return latest_feedback + + async def list_qa_sessions( + self, + *, + user_id: int, + date_from: Optional[datetime], + date_to: Optional[datetime], + page: int, + page_size: int, + ) -> dict[str, Any]: + page = max(1, int(page)) + page_size = max(1, min(100, int(page_size))) + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + + base_stmt = select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + if date_from_naive is not None: + base_stmt = base_stmt.where(QASession.last_message_at >= date_from_naive) + if date_to_naive is not None: + base_stmt = base_stmt.where(QASession.last_message_at <= date_to_naive) + + total = int((await self.db.execute(select(func.count()).select_from(base_stmt.subquery()))).scalar() or 0) + rows = ( + await self.db.execute( + base_stmt.order_by(QASession.last_message_at.desc(), QASession.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + if not rows: + return { + "items": [], + "page": page, + "page_size": page_size, + "total": total, + "total_pages": 0, + "count": 0, + } + + session_ids = [int(x.id) for x in rows] + record_rows = ( + await self.db.execute( + select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id.in_(session_ids), + ) + ) + ) + ).scalars().all() + + records_by_session: dict[int, list[QARecord]] = {} + for row in record_rows: + sid = int(row.session_id or 0) + if sid <= 0: + continue + records_by_session.setdefault(sid, []).append(row) + + feedback_map = await self._latest_feedback_map([int(x.id) for x in record_rows]) + items: list[dict[str, Any]] = [] + for row in rows: + sid = int(row.id) + session_records = sorted( + records_by_session.get(sid, []), + key=lambda r: (int(r.turn_index or 0), self._to_naive_utc(r.created_at) or datetime.min), + ) + positive = 0 + negative = 0 + corrected = 0 + last_asked_via = "pmflow" + for rec in session_records: + last_asked_via = str(rec.asked_via or last_asked_via) + fb = feedback_map.get(int(rec.id)) + if not fb: + continue + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + positive += 1 + elif rating == "negative": + negative += 1 + if str(fb.correct_answer or "").strip(): + corrected += 1 + + message_count = int(row.message_count or len(session_records)) + unrated = max(message_count - positive - negative, 0) + first_question = session_records[0].question if session_records else "" + items.append( + { + "id": sid, + "title": str(row.title or SESSION_DEFAULT_TITLE), + "project_id": row.project_id, + "started_at": row.started_at.isoformat() if row.started_at else None, + "last_message_at": row.last_message_at.isoformat() if row.last_message_at else None, + "message_count": message_count, + "turn_count": message_count, + "rating_summary": { + "positive": positive, + "negative": negative, + "unrated": unrated, + "corrected": corrected, + }, + "last_asked_via": last_asked_via, + "preview_question": str(first_question or "")[:120], + } + ) + + total_pages = math.ceil(total / page_size) if total > 0 else 0 + return { + "items": items, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + "count": len(items), + } + + async def get_qa_session_detail(self, *, session_id: int, user_id: int) -> dict[str, Any]: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + raise ValueError("qa session 不存在") + + records = ( + await self.db.execute( + select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ).order_by(QARecord.turn_index.asc(), QARecord.created_at.asc()) + ) + ).scalars().all() + feedback_map = await self._latest_feedback_map([int(x.id) for x in records]) + + messages: list[dict[str, Any]] = [] + positive = 0 + negative = 0 + corrected = 0 + for row in records: + fb = feedback_map.get(int(row.id)) + ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) + if fb: + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + positive += 1 + elif rating == "negative": + negative += 1 + if str(fb.correct_answer or "").strip(): + corrected += 1 + messages.append( + { + "id": int(row.id), + "question": row.question, + "answer": row.answer, + "sources": row.sources or [], + "intent": row.intent, + "domains_used": row.domains_used or [], + "response_time_ms": row.response_time_ms, + "asked_by": row.asked_by, + "asked_via": row.asked_via, + "project_id": row.project_id, + "session_id": row.session_id, + "turn_index": int(row.turn_index or 1), + "created_at": row.created_at.isoformat() if row.created_at else None, + "feedback": { + "id": int(fb.id), + "rating": fb.rating, + "helpfulness": fb.helpfulness, + "error_types": fb.error_types or [], + "correct_answer": fb.correct_answer, + } + if fb + else None, + **ack_meta, + } + ) + + message_count = int(session_row.message_count or len(records)) + unrated = max(message_count - positive - negative, 0) + return { + "session": { + "id": int(session_row.id), + "title": str(session_row.title or SESSION_DEFAULT_TITLE), + "project_id": session_row.project_id, + "started_at": session_row.started_at.isoformat() if session_row.started_at else None, + "last_message_at": session_row.last_message_at.isoformat() if session_row.last_message_at else None, + "message_count": message_count, + "turn_count": message_count, + "rating_summary": { + "positive": positive, + "negative": negative, + "unrated": unrated, + "corrected": corrected, + }, + }, + "messages": messages, + } + + async def rename_qa_session(self, *, session_id: int, user_id: int, title: str) -> dict[str, Any]: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + raise ValueError("qa session 不存在") + + cleaned = self._normalize_session_title_raw(title, max_len=120) + if not cleaned: + raise ValueError("会话标题不能为空") + session_row.title = cleaned + await self.db.commit() + return {"id": int(session_row.id), "title": cleaned} + + async def delete_qa_session(self, *, session_id: int, user_id: int) -> bool: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + return False + + qa_ids = ( + await self.db.execute( + select(QARecord.id).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ) + ) + ).scalars().all() + id_list = [int(x) for x in qa_ids] + if id_list: + await self.db.execute( + delete(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(id_list), + ) + ) + ) + await self.db.execute( + delete(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ) + ) + await self.db.delete(session_row) + await self.db.commit() + return True + + async def submit_feedback( + self, + *, + qa_id: int, + rating: str, + submitted_by: Optional[int], + helpfulness: Optional[str] = None, + error_types: Optional[list[str]] = None, + correct_answer: Optional[str] = None, + ) -> dict[str, Any]: + qa = ( + await self.db.execute( + select(QARecord).where( + and_(QARecord.id == int(qa_id), QARecord.tenant_id == self.tenant_id) + ) + ) + ).scalar_one_or_none() + if not qa: + raise ValueError("qa record 不存在") + + feedback = QAFeedback( + tenant_id=self.tenant_id, + qa_id=int(qa_id), + rating=(rating or "").strip().lower()[:16], + helpfulness=(helpfulness or "").strip()[:32] or None, + error_types=[str(x).strip() for x in (error_types or []) if str(x).strip()] or None, + correct_answer=(correct_answer or "").strip()[:4000] or None, + submitted_by=submitted_by, + ) + self.db.add(feedback) + await self.db.commit() + await self.db.refresh(feedback) + if str(feedback.rating or "").strip().lower() == "negative" and ( + str(feedback.correct_answer or "").strip() or [str(x).strip() for x in (feedback.error_types or []) if str(x).strip()] + ): + try: + await self.upsert_intent_correction_sample( + user_utterance=str(qa.question or ""), + wrong_interpretation=str(qa.answer or ""), + correct_intent="query", + canonical_action="clarify_before_answer", + clarification_template=str(feedback.correct_answer or "").strip() or "收到纠正后先澄清范围再回答。", + risk_level="medium", + project_id=int(qa.project_id) if qa.project_id is not None else None, + ) + except Exception: + await self.db.rollback() + return { + "id": int(feedback.id), + "qa_id": int(feedback.qa_id), + "rating": feedback.rating, + "helpfulness": feedback.helpfulness, + "error_types": feedback.error_types or [], + "correct_answer": feedback.correct_answer, + } + + async def list_qa_records( + self, + *, + project_id: Optional[int], + date_from: Optional[datetime], + date_to: Optional[datetime], + rating: Optional[str], + page: int, + page_size: int, + ) -> dict[str, Any]: + page = max(1, int(page)) + page_size = max(1, min(100, int(page_size))) + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + + stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + stmt = stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + stmt = stmt.where(QARecord.created_at <= date_to_naive) + + records = ( + await self.db.execute( + stmt.order_by(QARecord.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + + ids = [int(r.id) for r in records] + feedback_rows = [] + if ids: + fb_stmt = select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(ids), + ) + ) + if rating: + fb_stmt = fb_stmt.where(QAFeedback.rating == rating) + feedback_rows = (await self.db.execute(fb_stmt)).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + if curr is None or (fb.created_at and curr.created_at and fb.created_at > curr.created_at): + latest_feedback[key] = fb + + items: list[dict[str, Any]] = [] + for row in records: + fb = latest_feedback.get(int(row.id)) + if rating and not fb: + continue + ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) + items.append( + { + "id": int(row.id), + "question": row.question, + "answer": row.answer, + "sources": row.sources or [], + "intent": row.intent, + "domains_used": row.domains_used or [], + "response_time_ms": row.response_time_ms, + "asked_by": row.asked_by, + "asked_via": row.asked_via, + "project_id": row.project_id, + "session_id": row.session_id, + "turn_index": int(row.turn_index or 1), + "created_at": row.created_at.isoformat() if row.created_at else None, + "feedback": { + "id": int(fb.id), + "rating": fb.rating, + "helpfulness": fb.helpfulness, + "error_types": fb.error_types or [], + "correct_answer": fb.correct_answer, + } + if fb + else None, + **ack_meta, + } + ) + + return { + "items": items, + "page": page, + "page_size": page_size, + "count": len(items), + } + + async def get_qa_stats( + self, + *, + project_id: Optional[int], + date_from: Optional[datetime], + date_to: Optional[datetime], + ) -> dict[str, Any]: + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) + + records = (await self.db.execute(record_stmt)).scalars().all() + if not records: + return { + "total_sessions": 0, + "total_qa": 0, + "rated_count": 0, + "rated_ratio": 0.0, + "average_helpfulness": 0.0, + "exportable_pairs": 0, + } + + record_ids = [int(r.id) for r in records] + feedback_rows = ( + await self.db.execute( + select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(record_ids), + ) + ) + ) + ).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + fb_at = fb.created_at or datetime.min + curr_at = curr.created_at if curr and curr.created_at else datetime.min + if curr is None or fb_at >= curr_at: + latest_feedback[key] = fb + + rated_count = len(latest_feedback) + rated_ratio = round(rated_count / max(len(records), 1), 4) + + helpful_scores: list[float] = [] + helpful_map = { + "fully": 5.0, + "could_be_better": 4.0, + "partially": 3.0, + } + for fb in latest_feedback.values(): + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + score = helpful_map.get(str(fb.helpfulness or "").strip().lower(), 4.0) + elif rating == "negative": + score = 1.0 + else: + continue + helpful_scores.append(score) + + avg_helpfulness = round(sum(helpful_scores) / len(helpful_scores), 2) if helpful_scores else 0.0 + + exportable_pairs = 0 + for fb in latest_feedback.values(): + rating = str(fb.rating or "").strip().lower() + if rating == "positive" and str(fb.helpfulness or "").strip(): + exportable_pairs += 1 + continue + if rating == "negative": + exportable_pairs += 1 + + return { + "total_sessions": len({int(r.session_id) for r in records if r.session_id is not None}), + "total_qa": len(records), + "rated_count": rated_count, + "rated_ratio": rated_ratio, + "average_helpfulness": avg_helpfulness, + "exportable_pairs": exportable_pairs, + } + + async def export_qa_jsonl( + self, + *, + include_unrated: bool, + project_id: Optional[int], + rating: Optional[str], + date_from: Optional[datetime], + date_to: Optional[datetime], + domain: Optional[str], + ) -> str: + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) + if domain: + record_stmt = record_stmt.where(QARecord.domains_used.is_not(None)) + + records = (await self.db.execute(record_stmt.order_by(QARecord.created_at.asc()))).scalars().all() + if not records: + return "" + + feedback_map = await self._latest_feedback_map([int(r.id) for r in records]) + session_ids = sorted({int(r.session_id) for r in records if r.session_id is not None}) + session_map: dict[int, QASession] = {} + if session_ids: + rows = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.id.in_(session_ids), + ) + ) + ) + ).scalars().all() + session_map = {int(x.id): x for x in rows} + + lines: list[str] = [] + normalized_rating = str(rating or "").strip().lower() or None + for record in records: + feedback = feedback_map.get(int(record.id)) + if normalized_rating == "unrated": + if feedback: + continue + else: + if not feedback and not include_unrated: + continue + if normalized_rating in {"positive", "negative"}: + if not feedback or str(feedback.rating or "").strip().lower() != normalized_rating: + continue + if domain: + domains = [str(x).lower() for x in (record.domains_used or [])] + if domain.lower() not in domains: + continue + + session = session_map.get(int(record.session_id)) if record.session_id is not None else None + row = { + "session_id": int(record.session_id) if record.session_id is not None else None, + "session_title": str(session.title or SESSION_DEFAULT_TITLE) if session else SESSION_DEFAULT_TITLE, + "turn_index": int(record.turn_index or 1), + "question": record.question, + "answer": record.answer, + "sources": record.sources or [], + "domains_used": record.domains_used or [], + "asked_via": record.asked_via, + "project_id": record.project_id, + "created_at": record.created_at.isoformat() if record.created_at else None, + # 兼容历史导出字段 + "prompt": record.question, + "response": record.answer, + "rating": str(feedback.rating) if feedback else "unrated", + } + if feedback: + if feedback.helpfulness: + row["helpfulness"] = feedback.helpfulness + errors = [str(x) for x in (feedback.error_types or []) if str(x).strip()] + if errors: + row["error_type"] = errors[0] + row["error_types"] = errors + if feedback.correct_answer: + row["correct_answer"] = feedback.correct_answer + lines.append(json.dumps(row, ensure_ascii=False)) + + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + @staticmethod + def _contains_any(text: str, keywords: tuple[str, ...]) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + return any(keyword in lowered for keyword in keywords) + + @classmethod + def _is_correction_query(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + if BRIEF_NEGATIVE_FEEDBACK_RE.match(text): + return True + if cls._contains_any(text, CORRECTION_CUE_KEYWORDS): + return True + if re.search(r"(不是|别|不要).{0,8}(系统架构|架构)", text): + return True + return False + + @classmethod + def _has_explicit_target_scope(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + explicit_groups: tuple[tuple[str, ...], ...] = ( + ENGINE_PROCESS_KEYWORDS, + FEATURE_QUERY_KEYWORDS, + ARCHITECTURE_KEYWORDS, + PROJECT_KEYWORDS, + PEOPLE_KEYWORDS, + DOC_KEYWORDS, + ) + for group in explicit_groups: + if cls._contains_any(text, group): + return True + if any(token in text for token in ("openclaw", "llm", "model", "engine", "api")): + return True + return False + + @classmethod + def _is_engine_process_query(cls, question: str) -> bool: + return cls._contains_any(question, ENGINE_PROCESS_KEYWORDS) + + @classmethod + def _is_explicit_deep_request(cls, question: str) -> bool: + return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS) + + @classmethod + def _forced_deep_reason( + cls, + *, + question: str, + effective_question: str, + conversation: list[dict[str, str]], + ) -> str: + if cls._is_correction_query(question) or cls._is_correction_query(effective_question): + if conversation: + return "correction_turn" + if cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question): + return "engine_process_query" + return "" + + @classmethod + def _should_force_deep_for_channel( + cls, + *, + asked_via: str, + question: str, + effective_question: str, + reason: str, + ) -> bool: + if not str(reason or "").strip(): + return False + channel = str(asked_via or "").strip().lower() + if channel != "feishu": + return True + + if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): + return True + if str(reason or "").strip() == "engine_process_query": + combined = f"{question} {effective_question}".strip() + return cls._has_explicit_target_scope(combined) + return False + + @classmethod + def _is_snapshot_only_sources(cls, sources: list[dict[str, Any]]) -> bool: + if not isinstance(sources, list) or not sources: + return True + allowed_labels = {"System Snapshot", "User Snapshot", "Session State"} + for row in sources: + if not isinstance(row, dict): + continue + source = str(row.get("source") or "").strip().lower() + label = str(row.get("label") or "").strip() + if source != "system": + return False + if label and label not in allowed_labels: + return False + return True + + @classmethod + def _should_auto_deep_retry( + cls, + *, + question: str, + effective_question: str, + sources: list[dict[str, Any]], + relevance_max: float, + ) -> str: + process_sensitive = cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question) + correction_sensitive = cls._is_correction_query(question) or cls._is_correction_query(effective_question) + if not process_sensitive and not correction_sensitive: + return "" + snapshot_only = cls._is_snapshot_only_sources(sources) + if snapshot_only: + return "snapshot_only" + if float(relevance_max or 0.0) < 0.62: + return "low_relevance" + return "" + + @classmethod + def _allow_auto_deep_retry_for_channel( + cls, + *, + asked_via: str, + question: str, + effective_question: str, + reason: str, + ) -> bool: + if not str(reason or "").strip(): + return False + channel = str(asked_via or "").strip().lower() + if channel != "feishu": + return True + if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): + return True + return False + + @classmethod + def _should_block_answer_with_guard( + cls, + *, + question: str, + effective_question: str, + sources: list[dict[str, Any]], + relevance_max: float, + ) -> bool: + if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)): + return False + if not cls._is_snapshot_only_sources(sources): + return False + return float(relevance_max or 0.0) < 0.68 + + @staticmethod + def _build_evidence_guard_answer(question: str) -> str: + raw = str(question or "").strip() + target = raw[:80] if raw else "该问题" + return ( + f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" + "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" + ) + + @staticmethod + def _is_snapshot_direct_question(question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + if AdaptiveRAGService._is_correction_query(text): + return False + if AdaptiveRAGService._is_engine_process_query(text): + return False + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature_purpose = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + return ( + any(k in text for k in IDENTITY_KEYWORDS) + or any(k in text for k in ARCHITECTURE_KEYWORDS) + or ask_feature_purpose + ) + + @staticmethod + def _expand_followup_question(question: str, conversation: list[dict[str, str]]) -> str: + current = str(question or "").strip() + if not current: + return current + + lowered = current.lower() + # 短句不等于追问:若已包含明确业务对象,则按独立问题处理,避免串错上下文。 + has_explicit_subject = any( + marker in lowered for marker in ("项目", "信号", "信号源", "任务", "系统", "架构", "数据库", "redis", "接口", "用户") + ) + is_short_followup = len(current) <= 16 and not has_explicit_subject + has_referential = any(k in lowered for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not (is_short_followup or has_referential): + return current + + anchor_question = "" + for turn in reversed(conversation[-5:]): + candidate = str(turn.get("question") or "").strip() + if not candidate: + continue + if candidate == current: + continue + if any( + marker in candidate + for marker in ("项目", "系统", "任务", "信号", "接口", "数据库", "redis", "架构", "模块") + ): + anchor_question = candidate + break + + if not anchor_question: + return current + + is_enumeration = any(k in lowered for k in FOLLOWUP_ENUMERATION_KEYWORDS) + if is_enumeration and ("信号" in lowered or "信号" in anchor_question): + return f"{anchor_question}。请列出该项目关联的信号标题和ID。" + if is_enumeration and ("任务" in lowered or "任务" in anchor_question): + return f"{anchor_question}。请列出对应任务的标题和状态。" + if is_enumeration and "项目" in anchor_question: + return f"{anchor_question}。请列出对应项目的名称和ID。" + return f"{anchor_question}。补充问题:{current}" + + def _build_snapshot_direct_answer( + self, + question: str, + *, + system_snapshot: Optional[dict[str, Any]], + user_snapshot: Optional[dict[str, Any]], + ) -> Optional[str]: + text = str(question or "").strip().lower() + if not text: + return None + if self._is_correction_query(text): + return None + if self._is_engine_process_query(text): + return None + + ask_identity = any(k in text for k in IDENTITY_KEYWORDS) + ask_architecture = any(k in text for k in ARCHITECTURE_KEYWORDS) + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + if not ask_identity and not ask_architecture and not ask_feature: + return None + + system_data = system_snapshot if isinstance(system_snapshot, dict) else {} + assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {} + platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {} + backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} + infra = platform.get("infrastructure") if isinstance(platform.get("infrastructure"), dict) else {} + module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} + + lines: list[str] = [] + if ask_identity: + name = str(assistant.get("name") or "PMFlow AI 管家") + identity = str(assistant.get("identity") or "PMFlow 内置 AI 助手") + lines.append(f"我是{name},{identity}。") + responsibilities = assistant.get("responsibilities") if isinstance(assistant.get("responsibilities"), list) else [] + if responsibilities: + joined = ";".join(str(x) for x in responsibilities[:5] if str(x).strip()) + if joined: + lines.append(f"职责范围:{joined}。") + + if ask_architecture: + runtime = str(platform.get("runtime") or "unknown") + backend_framework = str(backend.get("framework") or "FastAPI") + orm_name = str(backend.get("orm") or "SQLAlchemy 2.0") + db_name = str(infra.get("database") or "PostgreSQL") + cache_name = str(infra.get("cache") or "Redis") + lines.append( + f"当前系统采用前后端分层架构:后端 {backend_framework} + {orm_name},数据库 {db_name},缓存 {cache_name},运行环境 {runtime}。" + ) + if module_counts: + lines.append( + "后端模块规模:" + f"api {int(module_counts.get('api', 0))}、" + f"services {int(module_counts.get('services', 0))}、" + f"models {int(module_counts.get('models', 0))}、" + f"schemas {int(module_counts.get('schemas', 0))}。" + ) + + if ask_feature: + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_data, + limit=4, + ) + if feature_hits: + lines.append("命中的产品功能:") + for idx, row in enumerate(feature_hits, start=1): + name = str(row.get("name") or "未知功能") + summary = str(row.get("summary") or "暂无说明") + route = str(row.get("route") or "") + page_file = str(row.get("page_file") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) + line = f"{idx}. {name}:{summary}" + if route: + line += f" 入口 {route}。" + if page_file: + line += f" 页面文件 {page_file}。" + if api_text: + line += f" 相关接口 {api_text}。" + lines.append(line) + else: + catalog = self._feature_catalog_from_snapshot(system_data) + if catalog: + brief = "、".join( + str(row.get("name") or "") + for row in catalog[:8] + if isinstance(row, dict) and str(row.get("name") or "").strip() + ) + if brief: + lines.append(f"当前功能目录包括:{brief}。你可以继续指定其中一个功能名称,我会返回用途与入口。") + else: + lines.append("当前 System Snapshot 未包含 feature_catalog,建议先刷新上下文缓存后再提问。") + + if ask_identity and isinstance(user_snapshot, dict) and user_snapshot: + projects = user_snapshot.get("projects") if isinstance(user_snapshot.get("projects"), list) else [] + tasks = user_snapshot.get("assigned_tasks") if isinstance(user_snapshot.get("assigned_tasks"), list) else [] + lines.append(f"你的当前上下文:关联项目 {len(projects)} 个,未完成任务 {len(tasks)} 个。") + + if not lines: + return None + lines.append("来源: System Snapshot / User Snapshot") + return "\n".join(lines)[:3200] + + async def _generate_answer( + self, + question: str, + retrieved: list[RetrievedItem], + conversation: list[dict[str, str]], + project_id: Optional[int], + *, + system_snapshot: Optional[dict[str, Any]] = None, + user_snapshot: Optional[dict[str, Any]] = None, + relevance_max: float = 0.0, + thinking_mode: str = THINKING_MODE_NORMAL, + context_block_limit: Optional[int] = None, + answer_max_tokens: Optional[int] = None, + ) -> str: + active_thinking_mode = self._normalize_thinking_mode(thinking_mode) + deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + context_blocks: list[str] = [] + used_tokens = 0 + max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) + manifest = await self.get_project_manifest(project_id=project_id) + manifest_text = str(manifest.get("manifest_text") or "").strip() + if manifest_text: + manifest_block = f"[M] 项目画像\n{manifest_text}\n" + manifest_tokens = max(len(manifest_block) // 4, 1) + if used_tokens + manifest_tokens <= MAX_CONTEXT_TOKENS: + context_blocks.append(manifest_block) + used_tokens += manifest_tokens + + for idx, item in enumerate(retrieved[:max_blocks]): + block = f"[{idx + 1}] 来源: {item.source_label}\n相关度: {item.score:.3f}\n内容: {item.content}\n" + block_tokens = max(len(block) // 4, 1) + if used_tokens + block_tokens > MAX_CONTEXT_TOKENS: + break + used_tokens += block_tokens + context_blocks.append(block) + + history_lines = [] + for row in conversation[-10:]: + history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}") + + system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000] + user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000] + + prompt = PROMPT_RAG_ANSWER.format( + project_id=project_id if project_id is not None else "unknown", + question=question, + conversation=chr(10).join(history_lines) if history_lines else "无", + context_blocks=chr(10).join(context_blocks) if context_blocks else "无", + system_snapshot=system_snapshot_text or "{}", + user_snapshot=user_snapshot_text or "{}", + relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}", + ).strip() + if deep_thinking: + prompt = ( + f"{prompt}\n\n" + "补充要求(深入思考模式):\n" + "1. 先给结论,再给证据链;\n" + "2. 明确区分已验证事实与推断;\n" + "3. 若存在不确定项,给出待确认清单。" + ) + + effective_max_tokens = int( + answer_max_tokens + if answer_max_tokens is not None + else (DEEP_ANSWER_MAX_TOKENS if deep_thinking else 900) + ) + effective_max_tokens = max(256, min(effective_max_tokens, 3000)) + effective_temperature = 0.15 if deep_thinking else 0.2 + function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal" + + answer = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name=function_name, + temperature=effective_temperature, + max_tokens=effective_max_tokens, + use_prompt_profile=False, + ) + text = str(answer or "").strip() + if not text: + return "我未能从当前上下文生成有效答案,请补充问题细节后重试。" + enforced = self._enforce_feature_evidence_in_answer( + question=question, + answer_text=text, + retrieved=retrieved, + system_snapshot=system_snapshot, + ) + return str(enforced or text)[:3200] + + async def _load_recent_conversation( + self, + *, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + external_session_id: Optional[int], + limit: int, + ) -> list[dict[str, str]]: + active_session_id = session_id if session_id is not None else external_session_id + if active_session_id is not None: + cached_messages = await self.context_cache.get_session_messages( + tenant_id=self.tenant_id, + session_id=active_session_id, + ) + if cached_messages: + cached_turns = self.context_cache.messages_to_turns(cached_messages) + if cached_turns: + return cached_turns[-limit:] + + if asked_by is None: + return [] + + stmt = select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.asked_by == int(asked_by), + QARecord.asked_via == (asked_via or "pmflow")[:20], + ) + ) + if session_id is not None: + stmt = stmt.where(QARecord.session_id == int(session_id)) + if project_id is not None: + stmt = stmt.where(QARecord.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(QARecord.created_at.desc()).limit(limit))).scalars().all() + return [{"question": str(x.question or ""), "answer": str(x.answer or "")} for x in reversed(rows)] + + def _map_domains_to_source_types(self, domains: list[str]) -> list[str]: + mapped: list[str] = [] + if "doc" in domains: + mapped.append("doc") + if "feishu" in domains: + mapped.append("feishu") + if "pmflow" in domains: + mapped.append("pmflow") + if "url" in domains: + mapped.append("url") + return mapped + + def _source_label(self, source_type: str, metadata: dict[str, Any]) -> str: + if source_type == "doc": + title = metadata.get("title") or "unknown" + page_number = metadata.get("page_number") + if page_number is not None: + return f"Document[{title}] p.{page_number}" + return f"Document[{title}]" + if source_type == "url": + return f"URL[{metadata.get('file_url') or metadata.get('url') or 'external'}]" + if source_type == "feishu": + return "Feishu history" + if source_type == "pmflow": + record_type = str(metadata.get("record_type") or "").strip() + record_id = metadata.get("record_id") + if record_type and record_id is not None: + return f"PMFlow[{record_type}#{record_id}]" + return "PMFlow records" + return "Knowledge base" + + async def _noop_results(self) -> list[RetrievedItem]: + return [] + + async def _fetch_url_text(self, url: str) -> str: + target = str(url or "").strip() + if not target: + raise ValueError("url_empty") + async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client: + resp = await client.get(target, headers={"User-Agent": "PMFlow-RAG/1.0"}) + resp.raise_for_status() + content_type = str(resp.headers.get("content-type") or "").lower() + body = resp.text + if "html" in content_type: + return self._html_to_text(body) + return body[:300000] + + @staticmethod + def _html_to_text(html: str) -> str: + text = str(html or "") + text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) + text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r" | ", " ", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"<", "<", text) + text = re.sub(r">", ">", text) + text = re.sub(r"\s+", " ", text).strip() + return text[:300000] + + def _chunk_text(self, text: str, *, chunk_size: int = 500, overlap: int = 50) -> list[str]: + clean = str(text or "").strip() + if not clean: + return [] + if len(clean) <= chunk_size: + return [clean] + + chunks: list[str] = [] + start = 0 + step = max(1, chunk_size - overlap) + while start < len(clean): + end = min(len(clean), start + chunk_size) + part = clean[start:end].strip() + if part: + chunks.append(part) + if end >= len(clean): + break + start += step + return chunks + + def _chunk_text_with_metadata( + self, + text: str, + *, + chunk_size: int = 500, + overlap: int = 50, + ) -> list[tuple[str, dict[str, Any]]]: + segments = self._split_text_by_page_markers(text) + if not segments: + return [(part, {}) for part in self._chunk_text(text, chunk_size=chunk_size, overlap=overlap)] + + out: list[tuple[str, dict[str, Any]]] = [] + for page_number, segment in segments: + for part in self._chunk_text(segment, chunk_size=chunk_size, overlap=overlap): + meta: dict[str, Any] = {} + if page_number is not None: + meta["page_number"] = int(page_number) + out.append((part, meta)) + return out + + def _split_text_by_page_markers(self, text: str) -> list[tuple[Optional[int], str]]: + clean = str(text or "") + matches = list(PAGE_MARKER_RE.finditer(clean)) + if not matches: + return [] + + segments: list[tuple[Optional[int], str]] = [] + prefix = clean[: matches[0].start()].strip() + if prefix: + segments.append((None, prefix)) + + for idx, marker in enumerate(matches): + try: + page_number = int(marker.group(1)) + except Exception: + page_number = None + start = marker.end() + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(clean) + segment = clean[start:end].strip() + if segment: + segments.append((page_number, segment)) + return segments + + @staticmethod + def _to_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return float(default) + + @staticmethod + def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + @staticmethod + def _parse_json_object(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if text.startswith("```"): + parts = text.split("```") + if len(parts) >= 2: + text = parts[1].strip() + if text.startswith("json"): + text = text[4:].strip() + parsed: dict[str, Any] = {} + try: + value = json.loads(text) + if isinstance(value, dict): + parsed = value + except Exception: + pass + return parsed + + @staticmethod + def _embedding_to_list(raw: Any) -> list[float]: + if raw is None: + return [] + if isinstance(raw, list): + return [float(x) for x in raw] + if isinstance(raw, str): + try: + value = json.loads(raw) + if isinstance(value, list): + return [float(x) for x in value] + except Exception: + return [] + return [] + + @staticmethod + def _cosine_similarity(v1: list[float], v2: list[float]) -> float: + if not v1 or not v2: + return 0.0 + size = min(len(v1), len(v2)) + if size == 0: + return 0.0 + a = v1[:size] + b = v2[:size] + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + sim = dot / (norm_a * norm_b) + return (sim + 1) / 2 + + def _dedupe_sources(self, retrieved: list[RetrievedItem]) -> list[dict[str, Any]]: + seen: set[str] = set() + out: list[dict[str, Any]] = [] + for item in retrieved: + meta_identity = ( + item.metadata.get("message_id") + or item.metadata.get("file_url") + or item.metadata.get("target_url") + or item.metadata.get("path") + or item.metadata.get("relation_id") + or item.metadata.get("entity_id") + or item.metadata.get("subject_entity_id") + or item.metadata.get("object_entity_id") + or item.content[:80] + ) + key = f"{item.source}|{item.source_label}|{meta_identity}" + if key in seen: + continue + seen.add(key) + out.append( + { + "source": item.source, + "label": item.source_label, + "score": round(item.score, 4), + "metadata": item.metadata, + } + ) + if len(out) >= 8: + break + return out + + @staticmethod + def _build_pmflow_target_url(*, record_type: str, record_id: Any, project_id: Any) -> str: + safe_type = str(record_type or "").strip().lower() + try: + pid = int(project_id) if project_id is not None else None + except Exception: + pid = None + try: + rid = int(record_id) if record_id is not None else None + except Exception: + rid = None + if pid is None: + return "" + if safe_type == "charter" and rid is not None: + return f"/projects/{pid}/charter/{rid}" + if safe_type == "task" and rid is not None: + return f"/projects/{pid}/board?focus=task&taskId={rid}" + if safe_type == "review" and rid is not None: + return f"/projects/{pid}/board?focus=review&reviewId={rid}" + return f"/projects/{pid}/board" + + @staticmethod + def _serialize_doc(doc: KnowledgeBaseDoc) -> dict[str, Any]: + return { + "id": int(doc.id), + "title": doc.title, + "file_type": doc.file_type, + "source_type": doc.source_type, + "file_url": doc.file_url, + "status": doc.status, + "uploaded_by": doc.uploaded_by, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + "last_fetched_at": doc.last_fetched_at.isoformat() if doc.last_fetched_at else None, + "error_message": doc.error_message, + } + + +__all__ = ["AdaptiveRAGService"] diff --git a/backend/app/services/ai_orchestration_run_service.py b/backend/app/services/ai_orchestration_run_service.py new file mode 100644 index 00000000..3b9b44d1 --- /dev/null +++ b/backend/app/services/ai_orchestration_run_service.py @@ -0,0 +1,179 @@ +"""AI 编排运行账本服务。""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AIOrchestrationRun + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AIOrchestrationRunService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create_run( + self, + *, + tenant_id: int, + trace_id: str, + run_type: str, + project_id: int, + signal_id: Optional[int], + response_mode: str, + status: str, + planner_status: Optional[str], + openclaw_status: Optional[str], + worker_total: int, + worker_completed: int, + worker_failed: int, + summary: str, + planner_diagnostics: Optional[dict[str, Any]], + result_payload: Optional[dict[str, Any]], + created_by_user_id: Optional[int], + started_at: Optional[datetime] = None, + completed_at: Optional[datetime] = None, + ) -> AIOrchestrationRun: + row = AIOrchestrationRun( + tenant_id=tenant_id, + trace_id=trace_id, + run_type=run_type, + project_id=project_id, + signal_id=signal_id, + response_mode=response_mode, + status=status, + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=max(0, int(worker_total)), + worker_completed=max(0, int(worker_completed)), + worker_failed=max(0, int(worker_failed)), + summary=str(summary or "").strip()[:1000] or None, + planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else None, + result_payload=result_payload if isinstance(result_payload, dict) else None, + created_by_user_id=created_by_user_id, + started_at=started_at or _utc_now_naive(), + completed_at=completed_at, + ) + self.db.add(row) + await self.db.flush() + return row + + async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]: + return ( + await self.db.execute( + select(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.tenant_id == int(tenant_id), + AIOrchestrationRun.trace_id == str(trace_id), + ) + ) + ) + ).scalar_one_or_none() + + async def update_run( + self, + *, + tenant_id: int, + trace_id: str, + status: Optional[str] = None, + planner_status: Optional[str] = None, + openclaw_status: Optional[str] = None, + worker_total: Optional[int] = None, + worker_completed: Optional[int] = None, + worker_failed: Optional[int] = None, + summary: Optional[str] = None, + planner_diagnostics: Optional[dict[str, Any]] = None, + result_payload: Optional[dict[str, Any]] = None, + completed_at: Optional[datetime] = None, + ) -> Optional[AIOrchestrationRun]: + row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id) + if row is None: + return None + if status is not None: + row.status = str(status or "").strip()[:20] + if planner_status is not None: + row.planner_status = str(planner_status or "").strip()[:20] or None + if openclaw_status is not None: + row.openclaw_status = str(openclaw_status or "").strip()[:20] or None + if worker_total is not None: + row.worker_total = max(0, int(worker_total)) + if worker_completed is not None: + row.worker_completed = max(0, int(worker_completed)) + if worker_failed is not None: + row.worker_failed = max(0, int(worker_failed)) + if summary is not None: + row.summary = str(summary or "").strip()[:1000] or None + if planner_diagnostics is not None: + row.planner_diagnostics = planner_diagnostics + if result_payload is not None: + row.result_payload = result_payload + if completed_at is not None: + row.completed_at = completed_at + return row + + async def mark_stale_runs_interrupted( + self, + *, + older_than_minutes: int = 10, + ) -> int: + threshold = _utc_now_naive() - timedelta(minutes=max(1, int(older_than_minutes))) + rows = ( + await self.db.execute( + select(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.status.in_(("queued", "running")), + AIOrchestrationRun.started_at.is_not(None), + AIOrchestrationRun.started_at < threshold, + ) + ) + ) + ).scalars().all() + count = 0 + for row in rows: + payload = row.result_payload if isinstance(row.result_payload, dict) else {} + payload["run_status"] = "interrupted" + payload["pending_workers"] = payload.get("pending_workers") or [] + row.status = "interrupted" + row.completed_at = _utc_now_naive() + row.result_payload = payload + count += 1 + return count + + @staticmethod + def serialize_run(row: AIOrchestrationRun) -> dict[str, Any]: + payload = row.result_payload if isinstance(row.result_payload, dict) else {} + pending_workers = payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [] + worker_total = int(row.worker_total or 0) + worker_completed = int(row.worker_completed or 0) + worker_failed = int(row.worker_failed or 0) + worker_pending = max(0, worker_total - worker_completed - worker_failed) + if not pending_workers and worker_pending > 0: + pending_workers = [f"pending_{idx}" for idx in range(1, worker_pending + 1)] + return { + "trace_id": str(row.trace_id or ""), + "run_type": str(row.run_type or ""), + "project_id": int(row.project_id), + "signal_id": int(row.signal_id) if row.signal_id is not None else None, + "response_mode": str(row.response_mode or ""), + "run_status": str(row.status or ""), + "planner_status": str(row.planner_status or ""), + "openclaw_status": str(row.openclaw_status or ""), + "worker_total": worker_total, + "worker_completed": worker_completed, + "worker_failed": worker_failed, + "worker_pending": worker_pending, + "pending_workers": pending_workers, + "summary": str(row.summary or payload.get("summary") or "").strip(), + "planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None, + "result_snapshot": payload, + "created_by_user_id": int(row.created_by_user_id) if row.created_by_user_id is not None else None, + "started_at": row.started_at.isoformat() if row.started_at else None, + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + } diff --git a/backend/app/services/ai_pmo_cadence_service.py b/backend/app/services/ai_pmo_cadence_service.py new file mode 100644 index 00000000..c989c8b6 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence_service.py @@ -0,0 +1,483 @@ +"""AI PMO cadence engine。""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AIPMOCadenceService: + """日会 / 周会 / 风险例会 / 里程碑检查 / 项目群视角。""" + + SUPPORTED_CADENCE_TYPES = {"daily_standup", "weekly_review", "risk_review", "milestone_check"} + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def generate_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> dict[str, Any]: + normalized_type = str(cadence_type or "").strip().lower() + if normalized_type not in self.SUPPORTED_CADENCE_TYPES: + raise ValueError("unsupported cadence_type") + if normalized_type == "daily_standup": + payload = await self._build_daily_standup(project_id=project_id) + elif normalized_type == "weekly_review": + payload = await self._build_weekly_review(project_id=project_id) + elif normalized_type == "risk_review": + payload = await self._build_risk_review(project_id=project_id) + else: + payload = await self._build_milestone_check(project_id=project_id) + + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" + row = PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type=normalized_type[:20], + scope_ref=scope_ref[:64], + summary=payload.get("summary"), + payload=payload, + ) + self.db.add(row) + await self.db.flush() + return { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + async def get_latest_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> Optional[dict[str, Any]]: + normalized_type = str(cadence_type or "").strip().lower() + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" + row = ( + await self.db.execute( + select(PortfolioSnapshot).where( + and_( + PortfolioSnapshot.tenant_id == self.tenant_id, + PortfolioSnapshot.scope_type == normalized_type, + PortfolioSnapshot.scope_ref == scope_ref, + ) + ).order_by(PortfolioSnapshot.id.desc()).limit(1) + ) + ).scalar_one_or_none() + if row is None: + return None + return { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + async def build_program_view(self) -> dict[str, Any]: + projects = ( + await self.db.execute( + select(Project, ProjectDomain) + .outerjoin(ProjectDomain, and_(ProjectDomain.id == Project.domain_id, ProjectDomain.tenant_id == self.tenant_id)) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.status.in_(["active", "planning", "doing", "todo"]), + ) + ) + .order_by(Project.id.asc()) + ) + ).all() + + domain_groups: dict[str, dict[str, Any]] = {} + for project, domain in projects: + key = str((domain.slug if domain is not None else "unscoped") or "unscoped") + group = domain_groups.setdefault( + key, + { + "program_key": key, + "program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"), + "project_count": 0, + "project_ids": [], + "blocked_task_count": 0, + "pending_manual_count": 0, + "budget_usage_ratio_max": 0.0, + }, + ) + group["project_count"] += 1 + group["project_ids"].append(int(project.id)) + + if not domain_groups: + return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []} + + project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]] + task_rows = ( + await self.db.execute( + select(Task.project_id, func.count(Task.id)) + .where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id.in_(project_ids), + Task.status == "blocked", + ) + ) + .group_by(Task.project_id) + ) + ).all() + task_map = {int(project_id): int(count or 0) for project_id, count in task_rows} + + work_rows = ( + await self.db.execute( + select(WorkItem.project_id, func.count(WorkItem.id)) + .where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.project_id.in_(project_ids), + WorkItem.state == "waiting_manual", + ) + ) + .group_by(WorkItem.project_id) + ) + ).all() + work_map = {int(project_id): int(count or 0) for project_id, count in work_rows} + + budget_rows = ( + await self.db.execute( + select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd) + .where( + and_( + Budget.tenant_id == self.tenant_id, + Budget.project_id.in_(project_ids), + ) + ) + ) + ).all() + budget_ratio_map = { + int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0 + for project_id, amount, usage in budget_rows + if project_id is not None + } + + for group in domain_groups.values(): + group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"]) + group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"]) + ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]] + group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4) + + programs = sorted(domain_groups.values(), key=lambda item: (item["pending_manual_count"], item["blocked_task_count"], item["project_count"]), reverse=True) + return { + "summary": { + "program_count": len(programs), + "active_project_count": len(project_ids), + "pending_manual_total": sum(item["pending_manual_count"] for item in programs), + "blocked_task_total": sum(item["blocked_task_count"] for item in programs), + }, + "programs": programs, + } + + async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]: + now = _utc_now_naive() + since = now - timedelta(days=1) + work_items = await self._list_recent_work_items(since=since, project_id=project_id) + blocked_tasks = await self._list_blocked_tasks(project_id=project_id) + recent_signals = await self._list_recent_signals(since=since, project_id=project_id) + pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id) + return { + "summary": { + "cadence_type": "daily_standup", + "recent_work_item_count": len(work_items), + "pending_manual_count": pending_manual_count, + "blocked_task_count": len(blocked_tasks), + "recent_signal_count": len(recent_signals), + }, + "work_items": work_items[:10], + "blocked_tasks": blocked_tasks[:10], + "recent_signals": recent_signals[:10], + "focus_actions": self._build_focus_actions(pending_manual_count=pending_manual_count, blocked_task_count=len(blocked_tasks)), + } + + async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]: + now = _utc_now_naive() + since = now - timedelta(days=7) + weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id) + budgets = await self._list_budget_hotspots(project_id=project_id) + capacity = await self._list_capacity_hotspots(project_id=project_id) + program_view = await self.build_program_view() if project_id is None else None + by_state: dict[str, int] = {} + for item in weekly_rows: + state = str(item.get("state") or "unknown") + by_state[state] = by_state.get(state, 0) + 1 + return { + "summary": { + "cadence_type": "weekly_review", + "work_item_total": len(weekly_rows), + "by_state": by_state, + "budget_hotspot_count": len(budgets), + "capacity_hotspot_count": len(capacity), + }, + "work_items": weekly_rows[:20], + "budget_hotspots": budgets[:10], + "capacity_hotspots": capacity[:10], + "program_view": program_view, + } + + async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]: + blocked_tasks = await self._list_blocked_tasks(project_id=project_id) + signal_sources = await self._list_pending_signal_sources(project_id=project_id) + notifications = await self._list_unread_risk_notifications(project_id=project_id) + return { + "summary": { + "cadence_type": "risk_review", + "blocked_task_count": len(blocked_tasks), + "pending_signal_source_count": len(signal_sources), + "unread_risk_notification_count": len(notifications), + }, + "blocked_tasks": blocked_tasks[:20], + "pending_signal_sources": signal_sources[:20], + "risk_notifications": notifications[:20], + } + + async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]: + overdue_tasks = await self._list_overdue_tasks(project_id=project_id) + work_items = await self._count_work_items(state="waiting_manual", project_id=project_id) + stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = (await self.db.execute(stmt)).all() + return { + "summary": { + "cadence_type": "milestone_check", + "project_count": len(projects), + "overdue_task_count": len(overdue_tasks), + "pending_manual_count": work_items, + }, + "projects": [ + {"project_id": int(pid), "title": str(title or ""), "status": str(status or "")} + for pid, title, status in projects + ], + "overdue_tasks": overdue_tasks[:20], + } + + async def _list_recent_work_items(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(WorkItem).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.created_at >= since, + ) + ) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "state": row.state, + "intent": row.intent, + "project_id": row.project_id, + "signal_id": row.signal_id, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in rows + ] + + async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int: + stmt = select(func.count(WorkItem.id)).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.state == state, + ) + ) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + return int((await self.db.execute(stmt)).scalar_one() or 0) + + async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.status == "blocked", + ) + ) + if project_id is not None: + stmt = stmt.where(Task.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "priority": str(row.priority or ""), + } + for row in rows + ] + + async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + now = _utc_now_naive() + stmt = select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.due_date.is_not(None), + Task.due_date < now, + Task.status.not_in(["done", "completed", "closed"]), + ) + ) + if project_id is not None: + stmt = stmt.where(Task.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "due_date": row.due_date.isoformat() if row.due_date else None, + } + for row in rows + ] + + async def _list_recent_signals(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.created_at >= since, + ) + ) + if project_id is not None: + stmt = stmt.where( + or_( + Signal.matched_project_id == int(project_id), + Signal.id.in_( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ) + ) + ), + ) + ) + rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "signal_type": str(row.signal_type or ""), + } + for row in rows + ] + + async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Budget.project_id == int(project_id)) + rows = (await self.db.execute(stmt)).scalars().all() + hotspots = [] + for row in rows: + amount = float(row.budget_amount_usd or 0.0) + usage = float(row.current_usage_usd or 0.0) + ratio = (usage / amount) if amount > 0 else 0.0 + if ratio >= 0.8: + hotspots.append( + { + "budget_id": int(row.id), + "project_id": row.project_id, + "usage_ratio": round(ratio, 4), + "hard_limit_enabled": bool(row.hard_limit_enabled), + } + ) + return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True) + + async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(UserWorkload).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.workload_ratio >= 0.85, + ) + ) + if project_id is not None: + stmt = stmt.where( + UserWorkload.user_id.in_( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ) + rows = (await self.db.execute(stmt.limit(50))).scalars().all() + items = [ + { + "user_id": int(row.user_id), + "workload_ratio": float(row.workload_ratio or 0.0), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + } + for row in rows + ] + return items + + async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == self.tenant_id, + SignalSourceRegistry.onboarding_status == "pending_manual", + ) + ) + if project_id is not None: + stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50))).scalars().all() + return [ + { + "registry_id": int(row.id), + "project_id": int(row.project_id), + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "status": str(row.status or ""), + "onboarding_status": str(row.onboarding_status or ""), + } + for row in rows + ] + + async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Notification).where( + and_( + Notification.tenant_id == self.tenant_id, + Notification.is_read.is_(False), + Notification.kind.in_(["risk", "alert", "action_required"]), + ) + ) + if project_id is not None: + stmt = stmt.where( + and_( + Notification.entity_type == "project", + Notification.entity_id == int(project_id), + ) + ) + rows = (await self.db.execute(stmt.order_by(Notification.updated_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "priority": str(row.priority or ""), + } + for row in rows + ] + + @staticmethod + def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]: + actions: list[str] = [] + if pending_manual_count > 0: + actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。") + if blocked_task_count > 0: + actions.append("优先处理 blocked tasks,降低关键依赖压力。") + if not actions: + actions.append("当前节奏平稳,继续按计划推进。") + return actions diff --git a/backend/app/services/ai_pmo_domain_workflow.py b/backend/app/services/ai_pmo_domain_workflow.py new file mode 100644 index 00000000..0cbb672d --- /dev/null +++ b/backend/app/services/ai_pmo_domain_workflow.py @@ -0,0 +1,108 @@ +"""AI PMO domain workflow adapters。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from .signal_source_orchestration_service import SignalSourceOrchestrationService + + +class SignalSourceDomainWorkflow: + """把 signal source onboarding 归一成 domain workflow 结果。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def run( + self, + *, + tenant_id: int, + project_id: int, + signal_id: int, + initiating_text: str, + requested_via: str, + operator_user_id: Optional[int], + ) -> dict[str, Any]: + raw_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + initiating_text=initiating_text, + requested_via=requested_via, + operator_user_id=operator_user_id, + dry_run=False, + response_mode="fast", + ) + normalized = self.normalize(raw_result) + normalized["raw_result"] = raw_result + return normalized + + @classmethod + def normalize(cls, raw_result: dict[str, Any]) -> dict[str, Any]: + payload = raw_result if isinstance(raw_result, dict) else {} + signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} + worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else [] + typed_actions = cls._extract_typed_actions(payload.get("openclaw")) + material_actions = [ + row + for row in typed_actions + if str(row.get("operation_code") or "").strip() != "notification.create" + and str(row.get("status") or "").strip() in {"applied", "planned"} + ] + fallback_used = any(bool(item.get("used_fallback")) for item in worker_executions if isinstance(item, dict)) + + raw_status = str(payload.get("status") or "").strip().lower() + registry_status = str(signal_source.get("status") or "").strip().lower() + onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower() + openclaw_status = str((payload.get("openclaw") or {}).get("status") or "").strip().lower() + + if raw_status == "failed" or openclaw_status == "failed": + final_status = "failed_safe" + elif onboarding_status != "completed": + final_status = "pending_manual" + elif not material_actions: + final_status = "pending_manual" + elif fallback_used: + final_status = "pending_manual" + elif raw_status in {"completed", "partial", "planned"}: + final_status = "completed" + else: + final_status = "pending_manual" + + next_step = "等待人工确认" if final_status != "completed" else "继续自动执行后续任务" + return { + "status": final_status, + "registry_id": signal_source.get("registry_id"), + "registry_status": registry_status, + "onboarding_status": onboarding_status, + "openclaw_status": openclaw_status, + "typed_actions": typed_actions, + "material_action_count": len(material_actions), + "worker_executions": worker_executions, + "pending_workers": payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [], + "next_step": next_step, + "fallback_used": fallback_used, + } + + @staticmethod + def _extract_typed_actions(openclaw_payload: Any) -> list[dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + actions: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + actions.append( + { + "operation_code": str(row.get("operation_code") or "").strip(), + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "payload": row.get("payload") if isinstance(row.get("payload"), dict) else {}, + } + ) + return actions diff --git a/backend/app/services/ai_pmo_kernel_service.py b/backend/app/services/ai_pmo_kernel_service.py new file mode 100644 index 00000000..2e4f22e1 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_service.py @@ -0,0 +1,2300 @@ +"""AI PMO 决策中枢内核服务。""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.logging import get_logger +from ..models import ( + AIProcessingLog, + Budget, + DecisionRecord, + IngestEvent, + Notification, + PlanBundle, + PortfolioSnapshot, + Project, + ProjectDomain, + ProjectSignalLink, + ReplyReceipt, + Signal, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from .adaptive_rag_service import AdaptiveRAGService +from .ai_pmo_cadence_service import AIPMOCadenceService +from .ai_steward import AIStewardService +from .ai_pmo_review_service import AIPMOReviewService +from .notification_service import NotificationService +from .openclaw_commander_service import OpenClawCommanderService +from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow +from .signal_source_orchestration_service import SignalSourceOrchestrationService + +logger = get_logger(__name__) + +INTENT_QUERY = "query.read_only" +INTENT_PROJECT_CREATE = "project.create" +INTENT_SIGNAL_CREATE = "signal.create" +INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" +INTENT_FOLLOWUP_CONFIRM = "followup.confirm" +INTENT_FOLLOWUP_CONTINUE = "followup.continue" +INTENT_IGNORE = "ignore" +FOLLOWUP_TARGET_INTENTS = { + INTENT_PROJECT_CREATE, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, +} + +STATE_RECEIVED = "received" +STATE_MODELED = "modeled" +STATE_DECIDED = "decided" +STATE_PLANNED = "planned" +STATE_EXECUTING = "executing" +STATE_WAITING_MANUAL = "waiting_manual" +STATE_SUCCEEDED = "succeeded" +STATE_FAILED = "failed" +STATE_IGNORED = "ignored" + +RECEIPT_PHASE_ACTION = "action_applied" +RECEIPT_PHASE_PENDING = "pending_manual" +RECEIPT_PHASE_ERROR = "error" + +QUESTION_HINTS = ("?", "?", "怎么", "为何", "为什么", "什么", "多少", "是否", "帮我看", "状态") +SMALLTALK_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "yes", + "no", + "谢谢", + "thanks", + "thx", + "在吗", + "在不在", +} +TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是") +FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条") +FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") +FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) +SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") +PROJECT_CREATE_HINTS = ("创建项目", "新建项目", "建立项目", "做个项目", "立项", "开个项目") +POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")), + ("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")), + ("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")), + ("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")), + ("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), +) + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_workload_ratio(value: Any) -> float: + try: + ratio = float(value or 0.0) + except (TypeError, ValueError): + return 0.0 + if ratio <= 0: + return 0.0 + return ratio / 100.0 if ratio > 1.0 else ratio + + +@dataclass(slots=True) +class CanonicalIntent: + name: str + confidence: float + project_id: Optional[int] + signal_title: str + signal_summary: str + metadata: dict[str, Any] + + +class IntentResolver: + """把输入收口为 canonical intent。""" + + @staticmethod + def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + pretty = re.sub(r"\s+", " ", str(text or "")).strip() + if not pretty: + return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) + if normalized in SMALLTALK_EXACT: + return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) + if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): + return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) + if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): + return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {}) + if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty): + return CanonicalIntent( + INTENT_SIGNAL_SOURCE_ONBOARD, + 0.92, + project_id, + IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"), + pretty[:300], + {"source_request": True}, + ) + if any(token in pretty for token in PROJECT_CREATE_HINTS) or OpenClawCommanderService._extract_project_create_title(pretty): + return CanonicalIntent( + INTENT_PROJECT_CREATE, + 0.9, + project_id, + IntentResolver._build_signal_title(pretty, default="新项目创建请求"), + pretty[:300], + {}, + ) + if any(token in pretty for token in QUESTION_HINTS): + return CanonicalIntent( + INTENT_QUERY, + 0.88, + project_id, + "", + pretty[:300], + {}, + ) + if any(token in pretty for token in SIGNAL_CREATE_HINTS) or any(token in pretty for token in ("新增", "增加", "录入", "跟踪", "关注")): + return CanonicalIntent( + INTENT_SIGNAL_CREATE, + 0.78, + project_id, + IntentResolver._build_signal_title(pretty, default="新信号"), + pretty[:300], + {}, + ) + return CanonicalIntent( + INTENT_SIGNAL_CREATE, + 0.65, + project_id, + IntentResolver._build_signal_title(pretty, default="新信号"), + pretty[:300], + {"fallback": True}, + ) + + @staticmethod + def _build_signal_title(text: str, *, default: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::") + if not cleaned: + return default + for token in ("。", ";", ";", "!", "!", "?", "?"): + if token in cleaned: + cleaned = cleaned.split(token, 1)[0].strip() + break + return cleaned[:120] or default + + +class StrategicBrain: + """组合级判断。""" + + @staticmethod + def evaluate(*, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + risk_count = int((world_model.get("risk_summary") or {}).get("open_risk_count") or 0) + active_projects = int((world_model.get("portfolio") or {}).get("active_project_count") or 0) + + budget_ratio = float(budget.get("usage_ratio") or 0.0) + overloaded_people = [row for row in capacity if _normalize_workload_ratio(row.get("workload_ratio")) >= 0.85] + risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) + if intent.name == INTENT_PROJECT_CREATE: + risk_score = max(risk_score, 0.62) + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + risk_score = max(risk_score, 0.58) + + return { + "decision_level": "project" if project else "portfolio", + "decision_object_type": "project" if project else "portfolio", + "decision_object_id": _safe_int((project or {}).get("id")), + "risk_score": round(risk_score, 3), + "budget_impact": { + "budget_id": budget.get("budget_id"), + "usage_ratio": budget_ratio, + "hard_limit_enabled": bool(budget.get("hard_limit_enabled")), + }, + "resource_impact": { + "overloaded_user_ids": [row.get("user_id") for row in overloaded_people], + "active_project_count": active_projects, + }, + "rationale": ( + f"intent={intent.name}; " + f"project_scope={'project' if project else 'portfolio'}; " + f"budget_ratio={budget_ratio:.2f}; " + f"open_risks={risk_count}; " + f"overloaded={len(overloaded_people)}" + ), + "message_excerpt": message_text[:240], + } + + +class TacticalBrain: + """周/日执行计划。""" + + @staticmethod + def plan(*, intent: CanonicalIntent, world_model: dict[str, Any], decision: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None + project_ref = f"项目#{project['id']}" if project and project.get("id") is not None else "项目群" + strategic_plan = { + "priority": "high" if float(decision.get("risk_score") or 0.0) >= 0.6 else "medium", + "objectives": [ + f"围绕 {project_ref} 控制风险与节奏", + "优先保证关键路径和资源边界不被破坏", + ], + "signals": [ + f"budget_usage_ratio={float((decision.get('budget_impact') or {}).get('usage_ratio') or 0.0):.2f}", + f"overloaded_users={len((decision.get('resource_impact') or {}).get('overloaded_user_ids') or [])}", + ], + } + execution_actions = [] + if intent.name == INTENT_QUERY: + execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) + elif intent.name == INTENT_PROJECT_CREATE: + execution_actions.append({"operation_code": "project.create", "mode": "write"}) + elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) + execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) + elif intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + execution_actions.append({"operation_code": "work_item.resume", "mode": "write"}) + elif intent.name == INTENT_SIGNAL_CREATE: + execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) + + execution_plan = { + "intent": intent.name, + "project_id": intent.project_id, + "actions": execution_actions, + "summary": f"执行 {intent.name},共 {len(execution_actions)} 个 typed actions", + } + return strategic_plan, execution_plan + + +class SimulationEngine: + """执行前仿真。""" + + BUDGET_COST_BY_OPERATION = { + "query.answer": 1.0, + "project.create": 120.0, + "signal.enter": 8.0, + "signal_source.onboard": 40.0, + } + HOURS_BY_OPERATION = { + "query.answer": 0.2, + "project.create": 12.0, + "signal.enter": 1.5, + "signal_source.onboard": 6.0, + } + + @classmethod + def simulate(cls, *, intent: CanonicalIntent, world_model: dict[str, Any], execution_plan: dict[str, Any]) -> dict[str, Any]: + actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] + operation_codes = [str(item.get("operation_code") or "").strip() for item in actions if isinstance(item, dict)] + estimated_budget_delta = sum(cls.BUDGET_COST_BY_OPERATION.get(code, 0.0) for code in operation_codes) + estimated_capacity_delta = sum(cls.HOURS_BY_OPERATION.get(code, 0.0) for code in operation_codes) + + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + budget_amount = float(budget.get("budget_amount_usd") or 0.0) + current_ratio = float(budget.get("usage_ratio") or 0.0) + projected_budget_ratio = current_ratio + (estimated_budget_delta / budget_amount if budget_amount > 0 else 0.0) + + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + current_peak_ratio = max([_normalize_workload_ratio(item.get("workload_ratio")) for item in capacity], default=0.0) + project_member_count = max(1, int(world_model.get("member_count") or len(capacity) or 1)) + projected_capacity_peak_ratio = current_peak_ratio + (estimated_capacity_delta / (40.0 * project_member_count)) + + task_summary = world_model.get("task_summary") if isinstance(world_model.get("task_summary"), dict) else {} + blocked_count = int((task_summary.get("by_status") or {}).get("blocked") or 0) + overdue_count = int(task_summary.get("overdue_count") or 0) + missing_skill_tag_ids = task_summary.get("uncovered_skill_tag_ids") if isinstance(task_summary.get("uncovered_skill_tag_ids"), list) else [] + + charter = world_model.get("charter") if isinstance(world_model.get("charter"), list) else [] + dependency_pressure = "high" if blocked_count > 0 or overdue_count > 0 else ("medium" if not charter else "low") + projected_risk_score = min( + 1.0, + 0.12 + + projected_budget_ratio * 0.24 + + projected_capacity_peak_ratio * 0.28 + + blocked_count * 0.08 + + overdue_count * 0.05 + + len(missing_skill_tag_ids) * 0.07, + ) + + return { + "intent": intent.name, + "operation_codes": operation_codes, + "estimated_budget_delta_usd": round(estimated_budget_delta, 2), + "projected_budget_usage_ratio": round(projected_budget_ratio, 4), + "estimated_capacity_delta_hours": round(estimated_capacity_delta, 2), + "projected_capacity_peak_ratio": round(projected_capacity_peak_ratio, 4), + "blocked_task_count": blocked_count, + "overdue_task_count": overdue_count, + "dependency_pressure": dependency_pressure, + "missing_skill_tag_ids": missing_skill_tag_ids[:20], + "missing_skill_count": len(missing_skill_tag_ids), + "projected_risk_score": round(projected_risk_score, 4), + } + + +class ExecutionPolicyBrain: + """自动执行边界。""" + + @staticmethod + def evaluate( + *, + message_text: str, + world_model: dict[str, Any], + intent: CanonicalIntent, + simulation: dict[str, Any], + ) -> dict[str, Any]: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + for gate_code, tokens in POLICY_GATE_RULES: + if any(token.lower().replace(" ", "") in normalized for token in tokens): + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": gate_code, + "approval_requirement": { + "required": True, + "reason": gate_code, + "message": "命中硬策略边界,必须进入显式审批/人工确认。", + }, + } + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and intent.project_id is None: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "missing_project_scope", + "approval_requirement": { + "required": True, + "reason": "missing_project_scope", + "message": "信号源接入缺少项目归属,必须先确认项目。", + }, + } + + # 轻量级事实写入/只读查询不应被项目当前依赖或产能压力阻塞。 + # 否则“AI 雷达”会在最需要先收集事实时失去自动化能力。 + if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}: + return { + "auto_execute": True, + "policy_gate_hit": False, + "policy_gate_code": None, + "approval_requirement": {"required": False}, + } + + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + if bool(budget.get("hard_limit_enabled")) and float(budget.get("usage_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "budget_hard_limit", + "approval_requirement": { + "required": True, + "reason": "budget_hard_limit", + "message": "项目预算已触达硬限制,自动执行被拦截。", + }, + } + + if float(simulation.get("projected_budget_usage_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_budget_limit", + "approval_requirement": { + "required": True, + "reason": "simulation_budget_limit", + "message": "执行前仿真显示预算将触顶,自动执行被拦截。", + }, + } + + if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_capacity_limit", + "approval_requirement": { + "required": True, + "reason": "simulation_capacity_limit", + "message": "执行前仿真显示产能会超载,自动执行被拦截。", + }, + } + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_skill_gap", + "approval_requirement": { + "required": True, + "reason": "simulation_skill_gap", + "message": "执行前仿真显示当前项目存在技能缺口,信号源接入先进入待确认。", + }, + } + + if intent.name in {INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE} and str(simulation.get("dependency_pressure") or "") == "high": + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_dependency_pressure", + "approval_requirement": { + "required": True, + "reason": "simulation_dependency_pressure", + "message": "执行前仿真显示关键依赖压力过高,当前先进入待人工确认。", + }, + } + + return { + "auto_execute": True, + "policy_gate_hit": False, + "policy_gate_code": None, + "approval_requirement": {"required": False}, + } + + +class DecisionEngine: + """汇总三脑输出。""" + + def __init__(self) -> None: + self.strategic_brain = StrategicBrain() + self.tactical_brain = TacticalBrain() + self.simulation_engine = SimulationEngine() + self.execution_policy_brain = ExecutionPolicyBrain() + + def decide(self, *, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: + strategic = self.strategic_brain.evaluate(intent=intent, world_model=world_model, message_text=message_text) + strategic_plan, execution_plan = self.tactical_brain.plan(intent=intent, world_model=world_model, decision=strategic) + simulation = self.simulation_engine.simulate(intent=intent, world_model=world_model, execution_plan=execution_plan) + strategic_plan["simulation"] = simulation + execution_plan["simulation"] = simulation + policy = self.execution_policy_brain.evaluate( + message_text=message_text, + world_model=world_model, + intent=intent, + simulation=simulation, + ) + strategic["risk_score"] = max(float(strategic.get("risk_score") or 0.0), float(simulation.get("projected_risk_score") or 0.0)) + return { + "strategic": strategic, + "strategic_plan": strategic_plan, + "execution_plan": execution_plan, + "simulation": simulation, + "policy": policy, + } + + +class ReplyRenderer: + """唯一回执出口。""" + + @staticmethod + def render( + *, + work_state: str, + intent: CanonicalIntent, + decision_record: dict[str, Any], + execution_result: dict[str, Any], + ) -> dict[str, Any]: + if work_state == STATE_WAITING_MANUAL: + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "").strip() + if followup_text: + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "reason": followup_text}, + "text": followup_text, + } + gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip() + if not gate_message: + gate_message = "已进入待人工确认队列。" + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "reason": gate_message}, + "text": gate_message, + } + if work_state == STATE_FAILED: + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "").strip() + if followup_text: + return { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"intent": intent.name, "reason": followup_text}, + "text": followup_text, + } + reason = str(execution_result.get("error_message") or "系统处理中出现异常,本次未执行。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"intent": intent.name, "reason": reason}, + "text": reason, + } + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "已继续处理。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": { + "intent": intent.name, + "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")), + "target_state": execution_result.get("target_state"), + }, + "text": followup_text, + } + if intent.name == INTENT_QUERY: + answer = str(execution_result.get("answer") or "当前暂无可用回答。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "answer": answer}, + "text": answer, + } + if intent.name == INTENT_PROJECT_CREATE: + project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {} + project_id = _safe_int(project.get("id")) + project_title = str(project.get("title") or project.get("name") or "新项目").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title}, + "text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。", + } + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + signal_id = _safe_int(execution_result.get("signal_id")) + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip() + if status == "pending_manual": + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, + "text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。", + } + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, + "text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。", + } + signal_id = _safe_int(execution_result.get("signal_id")) + project_id = _safe_int(execution_result.get("project_id")) + if signal_id is not None and project_id is not None: + text = f"已入库:已为项目#{project_id}创建信号#{signal_id}。" + elif signal_id is not None: + text = f"已入库:已创建信号#{signal_id}。" + else: + text = "已收到并入库。" + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "signal_id": signal_id, "project_id": project_id}, + "text": text, + } + + +class TypedExecutor: + """执行层只做 typed action 落地。""" + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.signal_source_workflow = SignalSourceDomainWorkflow(db) + + async def execute( + self, + *, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + ) -> dict[str, Any]: + if intent.name == INTENT_QUERY: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + rag_result = await rag_service.answer_question( + question=message_text, + asked_by=operator_user_id, + asked_via=source_system, + project_id=intent.project_id, + source_chat_id=source_message_id, + conversation_key=source_message_id, + action_mode="read", + sync_requirement=False, + ) + return { + "status": "succeeded", + "answer": str(rag_result.get("answer") or "").strip(), + "rag_result": rag_result, + "project_id": intent.project_id, + } + + if intent.name == INTENT_PROJECT_CREATE: + if operator_user_id is None: + return { + "status": "failed_safe", + "error_message": "未找到可用项目创建人,当前无法自动创建项目。", + } + steward = AIStewardService(self.db) + payload = await steward.create_project_from_natural_language( + input_text=message_text, + creator_id=int(operator_user_id), + tenant_id=self.tenant_id, + ) + project = payload.get("project") if isinstance(payload.get("project"), dict) else {} + signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {} + return { + "status": "succeeded", + "project": project, + "signal": signal, + "project_id": _safe_int(project.get("id")), + "signal_id": _safe_int(signal.get("id")), + "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], + "raw_payload": payload, + } + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + signal = await self._create_signal( + title=intent.signal_title or "新增信号源接入请求", + summary=intent.signal_summary or message_text, + content=message_text, + project_id=intent.project_id, + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + ) + domain_workflow = await self.signal_source_workflow.run( + tenant_id=self.tenant_id, + project_id=int(intent.project_id or 0), + signal_id=int(signal.id), + initiating_text=message_text, + requested_via=source_system, + operator_user_id=operator_user_id, + ) + orchestration = domain_workflow.get("raw_result") if isinstance(domain_workflow.get("raw_result"), dict) else {} + return { + "status": "succeeded" if str(domain_workflow.get("status") or "").lower() == "completed" else ( + "pending_manual" if str(domain_workflow.get("status") or "").lower() == "pending_manual" else "failed_safe" + ), + "signal_id": int(signal.id), + "project_id": intent.project_id, + "signal_source_workflow": domain_workflow, + "signal_source_orchestration": orchestration, + "auto_tasks": self._extract_auto_tasks(orchestration.get("openclaw")), + } + + if intent.name == INTENT_SIGNAL_CREATE: + signal = await self._create_signal( + title=intent.signal_title or "新信号", + summary=intent.signal_summary or message_text, + content=message_text, + project_id=intent.project_id, + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + ) + return { + "status": "succeeded", + "signal_id": int(signal.id), + "project_id": intent.project_id, + "auto_tasks": [], + } + + return { + "status": "failed_safe", + "error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。", + } + + async def _create_signal( + self, + *, + title: str, + summary: str, + content: str, + project_id: Optional[int], + source: str, + signal_type: str, + recommended_action: str, + ) -> Signal: + signal = Signal( + tenant_id=self.tenant_id, + source=source, + title=(title or "新信号")[:120], + summary=(summary or content or "")[:300], + content=(content or summary or "")[:2000], + signal_type=signal_type, + reasoning=json.dumps(["由 AI PMO kernel 统一入库。"], ensure_ascii=False), + confidence=0.78, + recommended_action=recommended_action, + matched_project_id=int(project_id) if project_id is not None else None, + impact_score=0.7, + priority="medium", + processed=False, + tasks_generated=json.dumps([], ensure_ascii=False), + status="待处理", + ) + self.db.add(signal) + await self.db.flush() + if project_id is not None: + exists = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal.id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if exists is None: + self.db.add( + ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + await self.db.flush() + return signal + + @staticmethod + def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + items: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + if operation_code != "task.create": + continue + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + items.append( + { + "operation_code": operation_code, + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "title": payload.get("title"), + "task_id": payload.get("task_id"), + } + ) + return items + + +class AIPMOKernelService: + """AI PMO kernel,作为新的统一事实链。""" + + def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.intent_resolver = IntentResolver() + self.decision_engine = DecisionEngine() + self.reply_renderer = ReplyRenderer() + self.executor = TypedExecutor(db, tenant_id=self.tenant_id) + self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id) + self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id) + + async def process_feishu_message( + self, + *, + group_chat_id: str, + message_content: str, + sender: str, + source_message_id: Optional[str], + source_sender_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + ) -> dict[str, Any]: + return await self.process_ingress( + source_system="feishu", + source_message_id=source_message_id, + message_text=message_content, + sender=sender, + channel_ref=group_chat_id, + source_sender_open_id=source_sender_open_id, + captured_at=captured_at, + chat_type=chat_type, + ) + + async def process_ingress( + self, + *, + source_system: str, + source_message_id: Optional[str], + message_text: str, + sender: str, + channel_ref: Optional[str] = None, + source_sender_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + project_hint_id: Optional[int] = None, + ) -> dict[str, Any]: + started = time.perf_counter() + clean_text = self._normalize_text(message_text) + if not clean_text: + return {"handled": True, "status": "ignored", "classification": "empty", "direct_reply_required": False} + transport_mode_reply = self._build_transport_mode_reply(clean_text) + if transport_mode_reply: + return { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [int(project_hint_id)] if project_hint_id is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": None, + "results": [], + "intent": INTENT_QUERY, + "direct_reply_required": True, + "direct_reply_text": transport_mode_reply, + "reply_template_key": "transport_mode_ack", + "work_item_id": None, + "decision_level": None, + "review_summary": None, + } + + synthetic_message_id = self._build_source_message_id( + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + sender=sender, + message_text=clean_text, + ) + + ingest_event, created = await self._claim_ingress_event( + source_system=source_system, + source_message_id=synthetic_message_id, + payload={ + "sender": sender, + "source_sender_open_id": source_sender_open_id, + "channel_ref": channel_ref, + "chat_type": chat_type, + "captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None, + "message_text": clean_text, + }, + ) + if not created: + return { + "handled": True, + "status": "ignored", + "reason": "duplicate_filtered", + "classification": "duplicate", + "direct_reply_required": False, + "source_message_id": synthetic_message_id, + } + + operator = await self._resolve_operator_user(sender=sender, sender_open_id=source_sender_open_id) + work_item = await self._create_work_item( + ingest_event=ingest_event, + source_system=source_system, + source_message_id=synthetic_message_id, + owner_user_id=_safe_int(getattr(operator, "id", None)), + ) + + try: + world_model = await self._build_world_model( + message_text=clean_text, + sender=sender, + project_hint_id=project_hint_id, + ) + work_item.state = STATE_MODELED + work_item.context_snapshot = world_model + intent = self.intent_resolver.resolve( + text=clean_text, + project_id=_safe_int(world_model.get("resolved_project_id")), + ) + if intent.name == INTENT_IGNORE: + work_item.state = STATE_IGNORED + work_item.intent = intent.name + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=clean_text, + route="ai_pmo.ignore", + operation_code=None, + operation_result={"reason": intent.metadata.get("reason")}, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + await self.db.commit() + return { + "handled": True, + "status": "ignored", + "classification": "ignored", + "direct_reply_required": False, + "work_item_id": int(work_item.id), + "ai_trace_id": f"ai-pmo-{int(work_item.id)}", + } + + work_item.intent = intent.name + work_item.project_id = intent.project_id + decision_payload = self.decision_engine.decide( + intent=intent, + world_model=world_model, + message_text=clean_text, + ) + work_item.state = STATE_DECIDED + work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio") + + decision_row = await self._create_decision_record( + work_item=work_item, + world_model=world_model, + decision_payload=decision_payload, + ) + await self._create_plan_bundle( + work_item_id=int(work_item.id), + plan_type="strategic", + summary="Strategic Brain 产出的项目群/项目策略。", + payload=decision_payload["strategic_plan"], + ) + await self._create_plan_bundle( + work_item_id=int(work_item.id), + plan_type="execution", + summary="Execution Plan typed action bundle。", + payload=decision_payload["execution_plan"], + ) + + policy = decision_payload["policy"] + if not bool(policy.get("auto_execute")): + work_item.state = STATE_WAITING_MANUAL + work_item.action_bundle = decision_payload["execution_plan"] + work_item.execution_result = { + "status": "waiting_manual", + "reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(), + } + manual_issues = ["waiting_manual"] + policy_gate_code = str(policy.get("policy_gate_code") or "").strip() + if policy_gate_code: + manual_issues.append(policy_gate_code) + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=work_item.execution_result, + world_model=world_model, + verification={"verified": False, "issues": manual_issues}, + simulation=decision_payload["simulation"], + ) + render = self.reply_renderer.render( + work_state=STATE_WAITING_MANUAL, + intent=intent, + decision_record={ + "approval_requirement": policy.get("approval_requirement"), + }, + execution_result={}, + ) + work_item.reply_template_key = str(render["template_key"]) + await self._project_waiting_manual( + work_item=work_item, + message_text=clean_text, + render=render, + intent=intent, + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=synthetic_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=clean_text, + route="ai_pmo.waiting_manual", + operation_code=None, + operation_result=work_item.execution_result, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + await self.db.commit() + return self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=work_item.execution_result or {}, + render=render, + direct_reply_required=direct_reply_required, + status="captured", + classification="command", + ) + + work_item.state = STATE_PLANNED + work_item.action_bundle = decision_payload["execution_plan"] + work_item.state = STATE_EXECUTING + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + execution_result, verification = await self._execute_followup_intent( + work_item=work_item, + intent=intent, + message_text=clean_text, + sender=sender, + operator_user_id=_safe_int(getattr(operator, "id", None)), + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + world_model=world_model, + ) + else: + execution_result = await self.executor.execute( + intent=intent, + message_text=clean_text, + sender=sender, + operator_user_id=_safe_int(getattr(operator, "id", None)), + source_system=source_system, + source_message_id=synthetic_message_id, + ) + verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) + work_item.execution_result = execution_result + if _safe_int(execution_result.get("project_id")) is not None: + work_item.project_id = _safe_int(execution_result.get("project_id")) + if _safe_int(execution_result.get("signal_id")) is not None: + work_item.signal_id = _safe_int(execution_result.get("signal_id")) + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=execution_result, + world_model=world_model, + verification=verification, + simulation=decision_payload["simulation"], + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + decision_row.execution_result = execution_result + issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] + if verification["verified"]: + work_item.state = STATE_SUCCEEDED + await self._project_success(intent=intent, execution_result=execution_result) + elif any( + issue in {"waiting_manual", "followup_ambiguous", "signal_source_pending_manual"} + for issue in issues + ) or str(execution_result.get("status") or "").strip().lower() == "pending_manual": + work_item.state = STATE_WAITING_MANUAL + else: + work_item.state = STATE_FAILED + decision_row.final_outcome = work_item.state + + render = self.reply_renderer.render( + work_state=work_item.state, + intent=intent, + decision_record={ + "approval_requirement": decision_payload["policy"].get("approval_requirement"), + }, + execution_result=execution_result, + ) + work_item.reply_template_key = str(render["template_key"]) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=synthetic_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=clean_text, + route=f"ai_pmo.{intent.name}", + operation_code=self._primary_operation_code(intent.name), + operation_result=execution_result, + processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + await self.db.commit() + return self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status="answered" if intent.name in {INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE} and work_item.state == STATE_SUCCEEDED else "captured", + classification=( + "question" + if intent.name == INTENT_QUERY + else ("signal" if intent.name == INTENT_SIGNAL_CREATE else "command") + ), + ) + except Exception as exc: + await self.db.rollback() + logger.warning( + "ai_pmo_kernel_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=synthetic_message_id, + error=str(exc), + ) + return {"handled": False, "reason": "kernel_exception", "error": str(exc)} + + async def _claim_ingress_event( + self, + *, + source_system: str, + source_message_id: str, + payload: dict[str, Any], + ) -> tuple[IngestEvent, bool]: + channel = f"{source_system}_message" + existing = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == channel, + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return existing, False + row = IngestEvent( + tenant_id=self.tenant_id, + channel=channel, + event_key=source_message_id, + status="processing", + payload=payload, + retry_count=0, + error_message=None, + processed_at=None, + ) + self.db.add(row) + try: + await self.db.flush() + except IntegrityError: + await self.db.rollback() + existing = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == channel, + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one() + return existing, False + return row, True + + async def _create_work_item( + self, + *, + ingest_event: IngestEvent, + source_system: str, + source_message_id: str, + owner_user_id: Optional[int], + ) -> WorkItem: + row = WorkItem( + tenant_id=self.tenant_id, + ingest_event_id=int(ingest_event.id), + source_system=source_system, + source_message_id=source_message_id, + state=STATE_RECEIVED, + owner_user_id=owner_user_id, + dedupe_key=f"{source_system}:{source_message_id}", + ) + self.db.add(row) + await self.db.flush() + return row + + async def _build_world_model( + self, + *, + message_text: str, + sender: str, + project_hint_id: Optional[int], + ) -> dict[str, Any]: + project = await self._resolve_project(message_text=message_text, project_hint_id=project_hint_id) + project_id = _safe_int(getattr(project, "id", None)) + portfolio_summary = await self._build_portfolio_summary(project_id=project_id) + budget = await self._build_budget_summary(project_id=project_id) + capacity = await self._build_capacity_summary(project_id=project_id) + skill_graph = await self._build_skill_graph(project_id=project_id) + charter = await self._build_charter_snapshot(project_id=project_id) + task_summary = await self._build_task_summary(project_id=project_id) + risk_summary = await self._build_risk_summary(project_id=project_id) + domain = await self._build_project_domain_snapshot(project_id=project_id) + dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) + recent_signals = await self._build_recent_signal_summary(project_id=project_id) + + world_model = { + "sender": sender, + "resolved_project_id": project_id, + "project": self._serialize_project(project) if project is not None else None, + "project_domain": domain, + "portfolio": portfolio_summary, + "budget": budget, + "capacity": capacity, + "member_count": len(capacity), + "skill_graph": skill_graph, + "charter": charter, + "task_summary": task_summary, + "risk_summary": risk_summary, + "dependencies": dependencies, + "recent_signals": recent_signals, + "decision_context_snapshot": { + "captured_at": _utc_now_naive().isoformat(), + "project_id": project_id, + "budget": budget, + "capacity": capacity, + "skill_graph_size": len(skill_graph), + "dependency_pressure": dependencies.get("pressure"), + }, + } + self.db.add( + PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type="tenant", + scope_ref=f"tenant:{self.tenant_id}", + summary=portfolio_summary, + payload=world_model, + ) + ) + await self.db.flush() + return world_model + + async def _resolve_project(self, *, message_text: str, project_hint_id: Optional[int]) -> Optional[Project]: + if project_hint_id is not None: + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_hint_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + + id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or "")) + if id_match: + project_id = int(id_match.group(1)) + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == project_id, + ) + ).limit(1) + ) + ).scalar_one_or_none() + + projects = ( + await self.db.execute( + select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50) + ) + ).scalars().all() + normalized_message = re.sub(r"\s+", "", str(message_text or "")).lower() + for project in projects: + for candidate in (project.title, project.name): + normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower() + if normalized_candidate and normalized_candidate in normalized_message: + return project + return None + + async def _build_portfolio_summary(self, *, project_id: Optional[int]) -> dict[str, Any]: + active_project_count = ( + await self.db.execute( + select(func.count(Project.id)).where( + and_( + Project.tenant_id == self.tenant_id, + Project.status.in_(["active", "planning", "todo", "doing"]), + ) + ) + ) + ).scalar_one() + return { + "active_project_count": int(active_project_count or 0), + "focus_project_id": project_id, + } + + async def _build_budget_summary(self, *, project_id: Optional[int]) -> dict[str, Any]: + stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None))) + row = (await self.db.execute(stmt.order_by(Budget.project_id.desc().nulls_last(), Budget.id.desc()).limit(1))).scalars().first() + if row is None: + return {"budget_id": None, "usage_ratio": 0.0, "hard_limit_enabled": False} + amount = float(row.budget_amount_usd or 0.0) + usage = float(row.current_usage_usd or 0.0) + ratio = (usage / amount) if amount > 0 else 0.0 + return { + "budget_id": int(row.id), + "budget_type": str(row.budget_type or ""), + "period": str(row.period or ""), + "budget_amount_usd": amount, + "current_usage_usd": usage, + "usage_ratio": round(ratio, 4), + "hard_limit_enabled": bool(row.hard_limit_enabled), + } + + async def _build_capacity_summary(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + user_ids: list[int] = [] + if project_id is not None: + rows = ( + await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ).scalars().all() + user_ids = [int(uid) for uid in rows] + if not user_ids and project_id is not None: + owner_id = ( + await self.db.execute( + select(Project.owner_id).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if owner_id is not None: + user_ids = [int(owner_id)] + if not user_ids: + return [] + rows = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).scalars().all() + return [ + { + "user_id": int(row.user_id), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + "max_available_hours_per_week": float(row.max_available_hours_per_week or 0.0), + "workload_ratio": float(row.workload_ratio or 0.0), + } + for row in rows + ] + + async def _build_skill_graph(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + user_ids: list[int] = [] + if project_id is not None: + rows = ( + await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ).scalars().all() + user_ids = [int(uid) for uid in rows] + if not user_ids: + return [] + rows = ( + await self.db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == self.tenant_id, + UserSkillBinding.user_id.in_(user_ids), + ) + ).limit(50) + ) + ).scalars().all() + return [ + { + "user_id": int(row.user_id), + "skill_tag_id": int(row.skill_tag_id), + "proficiency_level": str(row.proficiency_level or ""), + "quality_score": float(row.quality_score or 0.0), + "efficiency_score": float(row.efficiency_score or 0.0), + } + for row in rows + ] + + async def _build_project_domain_snapshot(self, *, project_id: Optional[int]) -> Optional[dict[str, Any]]: + if project_id is None: + return None + domain_row = ( + await self.db.execute( + select(ProjectDomain) + .join(Project, Project.domain_id == ProjectDomain.id) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ProjectDomain.tenant_id == self.tenant_id, + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if domain_row is None: + return None + return { + "id": int(domain_row.id), + "name": str(domain_row.name or ""), + "slug": str(domain_row.slug or ""), + "description": str(domain_row.description or ""), + "owner_id": domain_row.owner_id, + } + + async def _build_charter_snapshot(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + if project_id is None: + return [] + rows = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == self.tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(3) + ) + ).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "content": row.content if isinstance(row.content, dict) else {}, + } + for row in rows + ] + + async def _build_task_summary(self, *, project_id: Optional[int]) -> dict[str, Any]: + if project_id is None: + return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []} + tasks = ( + await self.db.execute( + select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id))) + ) + ).scalars().all() + by_status: dict[str, int] = {} + required_skill_tag_ids: set[int] = set() + blocked_tasks: list[dict[str, Any]] = [] + overdue_count = 0 + now = _utc_now_naive() + for row in tasks: + status = str(row.status or "unknown") + by_status[status] = by_status.get(status, 0) + 1 + for skill_id in row.required_skill_tag_ids or []: + if skill_id is not None: + required_skill_tag_ids.add(int(skill_id)) + if status == "blocked": + blocked_tasks.append({"id": int(row.id), "title": str(row.title or "")[:120]}) + if row.due_date and str(row.status or "").lower() not in {"done", "completed", "closed"}: + due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date + if due_date < now: + overdue_count += 1 + skill_graph = await self._build_skill_graph(project_id=project_id) + available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None} + uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids) + return { + "total": sum(by_status.values()), + "by_status": by_status, + "overdue_count": overdue_count, + "required_skill_tag_ids": sorted(required_skill_tag_ids), + "uncovered_skill_tag_ids": uncovered_skill_ids, + "blocked_tasks": blocked_tasks[:8], + } + + async def _build_recent_signal_summary(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + if project_id is None: + return [] + rows = ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + or_( + Signal.matched_project_id == int(project_id), + Signal.id.in_( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ) + ) + ), + ), + ) + ).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(5) + ) + ).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "signal_type": str(row.signal_type or ""), + } + for row in rows + ] + + async def _build_risk_summary(self, *, project_id: Optional[int]) -> dict[str, Any]: + stmt = select(func.count(Notification.id)).where( + and_( + Notification.tenant_id == self.tenant_id, + Notification.is_read.is_(False), + Notification.kind.in_(["risk", "alert", "action_required"]), + ) + ) + if project_id is not None: + stmt = stmt.where(or_(Notification.entity_id == int(project_id), Notification.payload.is_(None), Notification.entity_type == "project")) + open_risk_count = (await self.db.execute(stmt)).scalar_one() + return {"open_risk_count": int(open_risk_count or 0)} + + @staticmethod + def _build_dependency_summary(*, charter: list[dict[str, Any]], task_summary: dict[str, Any]) -> dict[str, Any]: + blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else [] + overdue_count = int(task_summary.get("overdue_count") or 0) + pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low") + return { + "pressure": pressure, + "blocked_tasks": blocked_tasks[:5], + "overdue_count": overdue_count, + "charter_ready": bool(charter), + } + + async def _create_decision_record( + self, + *, + work_item: WorkItem, + world_model: dict[str, Any], + decision_payload: dict[str, Any], + ) -> DecisionRecord: + strategic = decision_payload["strategic"] + policy = decision_payload["policy"] + row = DecisionRecord( + tenant_id=self.tenant_id, + work_item_id=int(work_item.id), + decision_object_type=str(strategic.get("decision_object_type") or "portfolio"), + decision_object_id=_safe_int(strategic.get("decision_object_id")), + decision_level=str(strategic.get("decision_level") or "portfolio"), + context_snapshot=world_model, + rationale=str(strategic.get("rationale") or ""), + confidence=0.82, + risk_score=float(strategic.get("risk_score") or 0.0), + budget_impact=strategic.get("budget_impact"), + resource_impact=strategic.get("resource_impact"), + simulation_result=decision_payload.get("simulation"), + policy_gate_hit=bool(policy.get("policy_gate_hit")), + policy_gate_code=(str(policy.get("policy_gate_code") or "")[:80] or None), + action_bundle=decision_payload["execution_plan"], + approval_requirement=policy.get("approval_requirement"), + final_outcome=STATE_DECIDED, + ) + self.db.add(row) + await self.db.flush() + return row + + async def _create_plan_bundle( + self, + *, + work_item_id: int, + plan_type: str, + summary: str, + payload: dict[str, Any], + ) -> PlanBundle: + row = PlanBundle( + tenant_id=self.tenant_id, + work_item_id=int(work_item_id), + plan_type=plan_type[:20], + summary=summary[:2000], + payload=payload, + ) + self.db.add(row) + await self.db.flush() + return row + + async def _claim_reply_receipt( + self, + *, + work_item_id: int, + source_system: str, + source_message_id: str, + receipt_phase: str, + template_key: str, + payload: dict[str, Any], + ) -> bool: + row = ReplyReceipt( + tenant_id=self.tenant_id, + work_item_id=int(work_item_id), + source_system=source_system[:32], + source_message_id=source_message_id[:128], + receipt_phase=receipt_phase[:32], + template_key=template_key[:64], + payload=payload, + status="ready", + ) + self.db.add(row) + try: + await self.db.flush() + return True + except IntegrityError: + await self.db.rollback() + existing_ingest = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == f"{source_system}_message", + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one() + existing_work_item = ( + await self.db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.ingest_event_id == int(existing_ingest.id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing_work_item is not None: + self.db.add(existing_work_item) + return False + + async def _project_waiting_manual( + self, + *, + work_item: WorkItem, + message_text: str, + render: dict[str, Any], + intent: CanonicalIntent, + ) -> None: + await NotificationService(self.db).create_notifications( + tenant_id=self.tenant_id, + user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id), + title="AI PMO 待人工确认", + content=str(render.get("text") or message_text)[:4000], + category="decision", + source="ai_pmo", + entity_type="work_item", + entity_id=int(work_item.id), + target_url=f"/inbox?workItemId={int(work_item.id)}", + kind="action_required", + priority="high", + status="pending_manual", + recommended_action="view_detail", + dedupe_key=f"ai-pmo-waiting:{int(work_item.id)}", + payload={ + "work_item_id": int(work_item.id), + "intent": intent.name, + "project_id": intent.project_id, + "message_text": message_text[:500], + }, + commit=False, + ) + + async def _project_success(self, *, intent: CanonicalIntent, execution_result: dict[str, Any]) -> None: + if intent.name == INTENT_SIGNAL_CREATE and _safe_int(execution_result.get("signal_id")) is not None: + await NotificationService(self.db).notify_signal_created( + tenant_id=self.tenant_id, + signal_id=int(execution_result["signal_id"]), + signal_content=str(execution_result.get("summary") or execution_result.get("signal_id") or ""), + source="ai_pmo", + target_url=( + f"/projects/{int(execution_result['project_id'])}/board" + if _safe_int(execution_result.get("project_id")) is not None + else None + ), + ) + + async def _execute_followup_intent( + self, + *, + work_item: WorkItem, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + world_model: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + explicit_target_id = self._extract_followup_work_item_id(message_text) + candidates = await self._load_recent_followup_candidates( + current_work_item_id=int(work_item.id), + source_system=source_system, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + explicit_target_id=explicit_target_id, + ) + actionable = [ + (target, ingest) + for target, ingest in candidates + if str(target.state or "") == STATE_WAITING_MANUAL + and str(target.intent or "") in FOLLOWUP_TARGET_INTENTS + ] + if explicit_target_id is not None and not actionable: + matched = next((target for target, _ in candidates if int(target.id) == int(explicit_target_id)), None) + if matched is not None: + reply_text = f"工作项#{int(matched.id)} 当前状态为 {matched.state},无需继续处理。" + return { + "status": "succeeded", + "followup_reply": reply_text, + "target_work_item_id": int(matched.id), + "target_state": matched.state, + "target_intent": matched.intent, + "project_id": _safe_int(matched.project_id), + "signal_id": _safe_int(matched.signal_id), + }, {"verified": True, "issues": []} + + if not actionable: + reply_text = "当前群最近没有可继续处理的待确认工作项。" + return { + "status": "pending_manual", + "followup_reply": reply_text, + "candidate_work_item_ids": [], + }, {"verified": False, "issues": ["waiting_manual"]} + + if len(actionable) > 1: + reply_text = self._build_followup_candidate_clarification([target for target, _ in actionable]) + return { + "status": "pending_manual", + "followup_reply": reply_text, + "candidate_work_item_ids": [int(target.id) for target, _ in actionable[:5]], + }, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]} + + target_work_item, target_ingest = actionable[0] + return await self._resume_followup_target( + target_work_item=target_work_item, + target_ingest=target_ingest, + followup_intent=intent, + followup_sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + world_model=world_model, + ) + + async def _load_recent_followup_candidates( + self, + *, + current_work_item_id: int, + source_system: str, + channel_ref: Optional[str], + sender: str, + source_sender_open_id: Optional[str], + explicit_target_id: Optional[int], + limit: int = 12, + ) -> list[tuple[WorkItem, IngestEvent]]: + rows = ( + await self.db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.id != int(current_work_item_id), + ) + ) + .order_by(WorkItem.id.desc()) + .limit(max(limit * 3, 18)) + ) + ).all() + selected: list[tuple[WorkItem, IngestEvent]] = [] + normalized_channel = str(channel_ref or "").strip() + normalized_sender = str(sender or "").strip() + normalized_open_id = str(source_sender_open_id or "").strip() + for target_work_item, ingest in rows: + if explicit_target_id is not None and int(target_work_item.id) == int(explicit_target_id): + selected.append((target_work_item, ingest)) + continue + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel + same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id + same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender + if not (same_channel or same_open_id or same_sender): + continue + selected.append((target_work_item, ingest)) + if len(selected) >= limit: + break + return selected + + async def _resume_followup_target( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + followup_intent: CanonicalIntent, + followup_sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + world_model: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} + original_message = str(payload.get("message_text") or "").strip() + override_project_id = _safe_int(world_model.get("resolved_project_id")) + target_project_id = _safe_int(target_work_item.project_id) or override_project_id + rebuilt_intent = self.intent_resolver.resolve(text=original_message, project_id=target_project_id) + target_intent_name = str(target_work_item.intent or rebuilt_intent.name or "").strip() + target_intent = CanonicalIntent( + name=target_intent_name or rebuilt_intent.name, + confidence=float(rebuilt_intent.confidence or 0.88), + project_id=target_project_id, + signal_title=rebuilt_intent.signal_title, + signal_summary=rebuilt_intent.signal_summary, + metadata=dict(rebuilt_intent.metadata), + ) + if target_intent.name not in FOLLOWUP_TARGET_INTENTS: + reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" + return { + "status": "failed_safe", + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": target_work_item.state, + "target_intent": target_intent.name, + }, {"verified": False, "issues": ["unsupported_intent"]} + if target_intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD} and target_project_id is None: + reply_text = "未执行:目标工作项仍缺少项目归属,请补充项目 ID 后再继续处理。" + return { + "status": "pending_manual", + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": STATE_WAITING_MANUAL, + "target_intent": target_intent.name, + }, {"verified": False, "issues": ["waiting_manual"]} + + target_work_item.project_id = target_project_id + target_work_item.state = STATE_EXECUTING + target_execution_result = await self.executor.execute( + intent=target_intent, + message_text=original_message, + sender=followup_sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=f"{source_message_id}:resume:{int(target_work_item.id)}", + ) + target_work_item.execution_result = target_execution_result + if _safe_int(target_execution_result.get("signal_id")) is not None: + target_work_item.signal_id = _safe_int(target_execution_result.get("signal_id")) + + target_verification = await self._verify_execution( + intent=target_intent, + execution_result=target_execution_result, + world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model, + ) + target_work_item.review_summary = await self.review_service.review_work_item( + work_item=target_work_item, + intent_name=target_intent.name, + execution_result=target_execution_result, + world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model, + verification=target_verification, + simulation={ + "followup_resumed": True, + "target_work_item_id": int(target_work_item.id), + "followup_intent": followup_intent.name, + }, + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(target_work_item.project_id), + review_summary=target_work_item.review_summary, + ) + if target_verification.get("verified"): + target_work_item.state = STATE_SUCCEEDED + await self._project_success(intent=target_intent, execution_result=target_execution_result) + else: + target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else [] + if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues): + target_work_item.state = STATE_WAITING_MANUAL + else: + target_work_item.state = STATE_FAILED + + target_decision = ( + await self.db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == self.tenant_id, + DecisionRecord.work_item_id == int(target_work_item.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if target_decision is not None: + target_decision.execution_result = target_execution_result + target_decision.final_outcome = target_work_item.state + + target_render = self.reply_renderer.render( + work_state=target_work_item.state, + intent=target_intent, + decision_record={}, + execution_result=target_execution_result, + ) + reply_text = str(target_render.get("text") or "").strip() + if target_work_item.state == STATE_SUCCEEDED: + reply_text = f"已继续处理:{reply_text}" + elif not reply_text: + reply_text = "未执行:继续处理后仍未形成可验证终态。" + project_id = _safe_int(target_execution_result.get("project_id")) or _safe_int(target_work_item.project_id) + signal_id = _safe_int(target_execution_result.get("signal_id")) or _safe_int(target_work_item.signal_id) + status = ( + "succeeded" + if target_work_item.state == STATE_SUCCEEDED + else ("pending_manual" if target_work_item.state == STATE_WAITING_MANUAL else "failed_safe") + ) + current_verification = {"verified": target_work_item.state == STATE_SUCCEEDED, "issues": []} + if target_work_item.state == STATE_WAITING_MANUAL: + current_verification = {"verified": False, "issues": ["waiting_manual"]} + elif target_work_item.state == STATE_FAILED: + current_verification = {"verified": False, "issues": ["executor_failed"]} + return { + "status": status, + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": target_work_item.state, + "target_intent": target_intent.name, + "project_id": project_id, + "signal_id": signal_id, + "auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [], + }, current_verification + + async def _verify_execution( + self, + *, + intent: CanonicalIntent, + execution_result: dict[str, Any], + world_model: dict[str, Any], + ) -> dict[str, Any]: + if str(execution_result.get("status") or "").lower() == "failed_safe": + return {"verified": False, "issues": ["executor_failed"]} + if str(execution_result.get("status") or "").lower() == "pending_manual": + return {"verified": False, "issues": ["pending_manual"]} + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + return { + "verified": bool( + _safe_int(execution_result.get("target_work_item_id")) is not None + and str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED + ), + "issues": [] if str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED else ["waiting_manual"], + } + if intent.name == INTENT_QUERY: + return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} + if intent.name == INTENT_PROJECT_CREATE: + return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} + if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: + signal_id = _safe_int(execution_result.get("signal_id")) + if signal_id is None: + return {"verified": False, "issues": ["signal_missing"]} + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + if str(workflow.get("status") or "").lower() != "completed": + return {"verified": False, "issues": ["signal_source_pending_manual"]} + if intent.project_id is None: + return {"verified": True, "issues": []} + link_exists = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(intent.project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return {"verified": link_exists is not None, "issues": [] if link_exists is not None else ["project_signal_link_missing"]} + return {"verified": False, "issues": ["unsupported_intent"]} + + @staticmethod + def _build_review_summary( + *, + intent: CanonicalIntent, + verification: dict[str, Any], + world_model: dict[str, Any], + ) -> dict[str, Any]: + return { + "verified": bool(verification.get("verified")), + "issues": verification.get("issues") if isinstance(verification.get("issues"), list) else [], + "next_step": "进入下一轮调度" if verification.get("verified") else "等待人工复核", + "risk_score_after": float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0), + "intent": intent.name, + } + + async def _maybe_emit_risk_cadence_snapshot( + self, + *, + project_id: Optional[int], + review_summary: dict[str, Any], + ) -> None: + if not isinstance(review_summary, dict): + return + risk_after = float(review_summary.get("risk_score_after") or 0.0) + divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] + if risk_after < 0.7 and not divergences: + return + await self.cadence_service.generate_snapshot( + cadence_type="risk_review", + project_id=int(project_id) if project_id is not None else None, + ) + + async def _resolve_operator_user(self, *, sender: str, sender_open_id: Optional[str]) -> Optional[User]: + cleaned_sender = str(sender or "").strip() + if cleaned_sender: + user = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + or_(User.name == cleaned_sender, User.username == cleaned_sender), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if user is not None: + return user + preferred = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + User.status == "active", + User.role.in_(["BOSS", "ADMIN", "PM"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + if preferred is not None: + return preferred + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + + async def _write_processing_log( + self, + *, + source_message_id: str, + work_item: WorkItem, + message_text: str, + route: str, + operation_code: Optional[str], + operation_result: Any, + processing_status: str, + duration_ms: int, + ) -> None: + self.db.add( + AIProcessingLog( + tenant_id=self.tenant_id, + feishu_message_id=source_message_id if work_item.source_system == "feishu" else None, + feishu_item_id=None, + raw_message=message_text[:8000], + ai_raw_output=json.dumps( + { + "work_item_id": int(work_item.id), + "intent": work_item.intent, + "state": work_item.state, + "reply_template_key": work_item.reply_template_key, + }, + ensure_ascii=False, + ), + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text=message_text[:8000], + route=route[:80], + intent=(str(work_item.intent or "")[:40] or None), + executor="ai_pmo_kernel", + operation_code=(operation_code[:80] if operation_code else None), + operation_result=operation_result if isinstance(operation_result, dict) else {"result": operation_result}, + reply_source=(str(work_item.reply_template_key or "")[:40] or None), + routing_decision=route[:64], + routing_reason=f"AI PMO kernel route={route}", + reasoning_bullets=[ + f"work_item_id={int(work_item.id)}", + f"state={work_item.state}", + f"intent={work_item.intent}", + ], + processing_status=processing_status[:20], + error_message=None, + duration_ms=int(duration_ms), + ) + ) + await self.db.flush() + + def _build_capture_result( + self, + *, + work_item: WorkItem, + intent: CanonicalIntent, + execution_result: dict[str, Any], + render: dict[str, Any], + direct_reply_required: bool, + status: str, + classification: str, + ) -> dict[str, Any]: + linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else [] + result = { + "handled": True, + "status": status, + "classification": classification, + "linked_project_ids": linked_project_ids, + "signal_id": _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id), + "created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None, + "auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []), + "ai_trace_id": f"ai-pmo-{int(work_item.id)}", + "results": [ + { + "work_item_id": int(work_item.id), + "state": work_item.state, + "intent": intent.name, + "route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done", + } + ], + "intent": intent.name, + "direct_reply_required": bool(direct_reply_required), + "direct_reply_text": str(render.get("text") or "").strip(), + "reply_template_key": str(render.get("template_key") or "").strip(), + "work_item_id": int(work_item.id), + "decision_level": work_item.decision_level, + "review_summary": work_item.review_summary, + } + if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): + result["rag_result"] = execution_result["rag_result"] + if intent.name == INTENT_PROJECT_CREATE: + raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {} + result["project"] = raw_payload.get("project") + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + result["signal_source_workflow"] = execution_result.get("signal_source_workflow") + result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration") + result["ai_execution"] = execution_result.get("signal_source_orchestration") + return result + + @staticmethod + def _primary_operation_code(intent_name: str) -> Optional[str]: + if intent_name == INTENT_QUERY: + return "query.answer" + if intent_name == INTENT_PROJECT_CREATE: + return "project.create" + if intent_name == INTENT_SIGNAL_CREATE: + return "signal.enter" + if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: + return "signal_source.onboard" + if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + return "work_item.resume" + return None + + @staticmethod + def _extract_followup_work_item_id(message: str) -> Optional[int]: + match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or "")) + return int(match.group(1)) if match else None + + @staticmethod + def _build_followup_candidate_clarification(items: list[WorkItem]) -> str: + if not items: + return "当前没有可继续处理的待确认工作项。" + labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]] + return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。" + + @staticmethod + def _build_source_message_id( + *, + source_system: str, + source_message_id: Optional[str], + channel_ref: Optional[str], + sender: str, + message_text: str, + ) -> str: + explicit = str(source_message_id or "").strip() + if explicit: + return explicit[:128] + basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}" + digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest() + return f"{source_system}-{digest[:32]}" + + @staticmethod + def _normalize_text(text: str) -> str: + cleaned = re.sub(r"]*>", " ", str(text or ""), flags=re.IGNORECASE) + cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:4000] + + @staticmethod + def _build_transport_mode_reply(message_text: str) -> Optional[str]: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + has_long_connection = "长连接" in normalized or "longconnection" in normalized + has_webhook = "webhook" in normalized + if not (has_long_connection and has_webhook): + return None + if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS): + return None + return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。" + + @staticmethod + def _serialize_project(project: Optional[Project]) -> Optional[dict[str, Any]]: + if project is None: + return None + return { + "id": int(project.id), + "name": str(project.name or ""), + "title": str(project.title or project.name or ""), + "description": str(project.description or ""), + "status": str(project.status or ""), + "owner_id": _safe_int(project.owner_id), + } diff --git a/backend/app/services/ai_pmo_review_service.py b/backend/app/services/ai_pmo_review_service.py new file mode 100644 index 00000000..40ca3396 --- /dev/null +++ b/backend/app/services/ai_pmo_review_service.py @@ -0,0 +1,189 @@ +"""AI PMO review engine。""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem + + +def _safe_int(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +class AIPMOReviewService: + """复盘层:结果核验、偏差检测、风险再评分、规则建议。""" + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def review_work_item( + self, + *, + work_item: WorkItem, + intent_name: str, + execution_result: dict[str, Any], + world_model: dict[str, Any], + verification: dict[str, Any], + simulation: dict[str, Any], + ) -> dict[str, Any]: + divergences: list[dict[str, Any]] = [] + rule_updates: list[dict[str, Any]] = [] + + signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id) + project_id = _safe_int(work_item.project_id) + if signal_id is not None and project_id is not None: + link_exists = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if link_exists is None: + divergences.append( + { + "code": "project_signal_link_missing", + "severity": "high", + "detail": f"signal#{signal_id} 未关联 project#{project_id}", + } + ) + rule_updates.append( + { + "rule_key": "signal_link_persistence_guard", + "reason": "信号已写入但 link 缺失,后续查询会出现空白板。", + } + ) + + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + raw_orchestration = execution_result.get("signal_source_orchestration") if isinstance(execution_result.get("signal_source_orchestration"), dict) else {} + registry_id = _safe_int(workflow.get("registry_id")) + if registry_id is not None: + registry = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == self.tenant_id, + SignalSourceRegistry.id == int(registry_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if registry is not None: + normalized_status = str(workflow.get("status") or "").strip().lower() + registry_onboarding = str(registry.onboarding_status or "").strip().lower() + if normalized_status == "pending_manual" and registry_onboarding == "completed": + divergences.append( + { + "code": "registry_workflow_divergence", + "severity": "high", + "detail": f"registry#{registry_id} 已 completed,但 domain workflow 为 pending_manual", + } + ) + rule_updates.append( + { + "rule_key": "signal_source_finalize_single_source", + "reason": "registry 与 workflow 终态不一致,必须收口到 domain workflow。", + } + ) + if workflow and str(workflow.get("status") or "").strip().lower() != "completed": + rule_updates.append( + { + "rule_key": "onboarding_requires_material_actions", + "reason": "signal source onboarding 只有 material actions + 非 fallback 才能标记 completed。", + } + ) + + duplicate_reply_groups = ( + await self.db.execute( + select(ReplyReceipt.receipt_phase, func.count(ReplyReceipt.id)) + .where( + and_( + ReplyReceipt.tenant_id == self.tenant_id, + ReplyReceipt.work_item_id == int(work_item.id), + ) + ) + .group_by(ReplyReceipt.receipt_phase) + ) + ).all() + duplicate_phases = [str(phase) for phase, count in duplicate_reply_groups if int(count or 0) > 1] + if duplicate_phases: + divergences.append( + { + "code": "duplicate_reply_receipts", + "severity": "medium", + "detail": ",".join(duplicate_phases), + } + ) + rule_updates.append( + { + "rule_key": "reply_receipt_single_phase", + "reason": "同一 source_message 不得生成多条同 phase 回执。", + } + ) + + issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] + manual_issue_codes = {"pending_manual", "signal_source_pending_manual", "waiting_manual"} + base_score = 0.92 if verification.get("verified") else (0.56 if any(issue in manual_issue_codes for issue in issues) else 0.32) + fallback_penalty = 0.15 if bool(workflow.get("fallback_used")) else 0.0 + divergence_penalty = min(0.45, len(divergences) * 0.12) + agent_quality_score = max(0.0, round(base_score - fallback_penalty - divergence_penalty, 4)) + + risk_before = float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0) + projected_risk = float(simulation.get("projected_risk_score") or 0.0) + risk_after = min(1.0, projected_risk + len(divergences) * 0.08 + (0.1 if not verification.get("verified") else 0.0)) + + next_actions: list[str] = [] + if divergences: + next_actions.append("优先修正 projection / registry / reply 收口偏差。") + if not verification.get("verified"): + next_actions.append("当前工作项未形成可验证终态,进入风险例会或人工确认。") + if risk_after >= 0.7: + next_actions.append("风险重评分偏高,建议纳入风险例会升级。") + if not next_actions: + next_actions.append("复盘通过,进入下一轮节奏调度。") + + quality_grade = "A" if agent_quality_score >= 0.85 else ("B" if agent_quality_score >= 0.7 else ("C" if agent_quality_score >= 0.5 else "D")) + review_payload = { + "verified": bool(verification.get("verified")), + "issues": issues, + "divergences": divergences, + "risk_score_before": round(risk_before, 4), + "risk_score_after": round(risk_after, 4), + "agent_quality_score": agent_quality_score, + "agent_quality_grade": quality_grade, + "rule_updates": rule_updates, + "next_actions": next_actions, + "simulation_summary": simulation, + "workflow_summary": workflow or raw_orchestration, + } + self.db.add( + PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type="review_cycle", + scope_ref=f"work_item:{int(work_item.id)}", + summary={ + "work_item_id": int(work_item.id), + "intent": intent_name, + "verified": bool(verification.get("verified")), + "agent_quality_score": agent_quality_score, + "risk_score_after": round(risk_after, 4), + }, + payload=review_payload, + ) + ) + await self.db.flush() + return review_payload diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 00000000..299aab13 --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,590 @@ +""" +PMFlow AI 核心能力封装 + +技术栈:LangChain 0.3+、OpenAI SDK、Redis +- 封装 GPT-4o mini 调用(流式/非流式) +- Prompt 集中在 config/ai_prompts +- AI 调用日志写入 ai_call_logs +- Redis 结果缓存(1 小时 TTL) +- 指数退避重试 +""" + +import hashlib +import json +import os +import time +from datetime import datetime +from typing import Any, AsyncGenerator, Optional, Union +from sqlalchemy import and_, select + +# LangChain & OpenAI +try: + from langchain_openai import ChatOpenAI + from langchain_core.messages import HumanMessage, AIMessage, SystemMessage + from langchain_core.language_models.chat_models import BaseChatModel + LANGCHAIN_AVAILABLE = True +except ImportError: + LANGCHAIN_AVAILABLE = False + BaseChatModel = object + +# Redis +try: + import redis.asyncio as redis + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + +# Token 定价 (GPT-4o-mini, 2024) +INPUT_PRICE_PER_1K = 0.00015 +OUTPUT_PRICE_PER_1K = 0.0006 +CACHE_TTL_SECONDS = 3600 # 1 小时 +PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 + +from ..config.ai_prompts import build_default_role_prompt_bundle +from ..database import AsyncSessionLocal +from ..models import Integration + + +def _t2s(v: Union[str, int]) -> str: + """统一转为 str""" + return str(v) if v is not None else "" + + +def _hash_input(fn: str, *args, **kwargs) -> str: + """生成缓存 key""" + payload = json.dumps({"fn": fn, "args": args, "kwargs": kwargs}, sort_keys=True, default=str) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _estimate_cost(prompt_tokens: int, completion_tokens: int) -> float: + return (prompt_tokens * INPUT_PRICE_PER_1K + completion_tokens * OUTPUT_PRICE_PER_1K) / 1000 + + +def _tenant_to_int(tenant_id: Union[str, int]) -> int: + try: + return int(tenant_id) + except (TypeError, ValueError): + return 1 + + +class AIService: + """PMFlow AI 服务""" + + def __init__( + self, + api_key: Optional[str] = None, + model: str = "gpt-4o-mini", + redis_url: Optional[str] = None, + max_retries: int = 3, + ): + self.api_key = api_key or os.getenv("OPENAI_API_KEY", "") + self.model = model + self.redis_url = redis_url or os.getenv("REDIS_URL") + self.max_retries = max_retries + self._llm: Optional[BaseChatModel] = None + self._redis: Optional[Any] = None + self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {} + + def _get_llm(self) -> BaseChatModel: + if not LANGCHAIN_AVAILABLE: + raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") + if not self.api_key: + raise RuntimeError("请设置 OPENAI_API_KEY 环境变量") + if self._llm is None: + self._llm = ChatOpenAI( + model=self.model, + api_key=self.api_key, + temperature=0.3, + ) + return self._llm + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis.from_url(self.redis_url, decode_responses=True) + return self._redis + + async def _cache_get(self, key: str) -> Optional[str]: + r = await self._get_redis() + if r is None: + return None + try: + return await r.get(key) + except Exception: + return None + + async def _cache_set(self, key: str, value: str, ttl: int = CACHE_TTL_SECONDS): + r = await self._get_redis() + if r is None: + return + try: + await r.setex(key, ttl, value) + except Exception: + pass + + async def _resolve_active_system_prompt( + self, + *, + tenant_id: Union[str, int], + db=None, + ) -> tuple[str, str]: + tenant_int = _tenant_to_int(tenant_id) + now = time.time() + cached = self._prompt_profile_cache.get(tenant_int) + if cached and cached[0] > now: + return cached[1] + + role_name = "" + system_prompt = "" + try: + async def _load_profile(session) -> tuple[str, str]: + row = ( + await session.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_int), + Integration.type == "ai_prompt_profiles", + ) + ) + ) + ).scalar_one_or_none() + bundle = row.config if row is not None and isinstance(row.config, dict) else {} + active_key = str(bundle.get("active_role_key") or "").strip() + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + selected = None + for item in roles: + if not isinstance(item, dict): + continue + if str(item.get("role_key") or "").strip() == active_key: + selected = item + break + if selected is None: + for item in roles: + if isinstance(item, dict) and bool(item.get("enabled", True)): + selected = item + break + if not isinstance(selected, dict): + return ("", "") + return ( + str(selected.get("role_name") or "").strip(), + str(selected.get("system_prompt") or "").strip(), + ) + + if db is not None: + role_name, system_prompt = await _load_profile(db) + else: + async with AsyncSessionLocal() as session: + role_name, system_prompt = await _load_profile(session) + except Exception: + role_name = "" + system_prompt = "" + + if len(system_prompt) < 20: + fallback = build_default_role_prompt_bundle() + active_key = str(fallback.get("active_role_key") or "").strip() + roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] + selected = None + for row in roles: + if not isinstance(row, dict): + continue + if str(row.get("role_key") or "").strip() == active_key: + selected = row + break + if selected is None and roles and isinstance(roles[0], dict): + selected = roles[0] + if isinstance(selected, dict): + role_name = str(selected.get("role_name") or role_name or "默认角色").strip() + system_prompt = str(selected.get("system_prompt") or "").strip() + + result = (role_name, system_prompt) + self._prompt_profile_cache[tenant_int] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) + return result + + async def _log_call( + self, + db, + tenant_id: str, + function_name: str, + input_hash: str, + status: str, + prompt_tokens: int = 0, + completion_tokens: int = 0, + latency_ms: Optional[int] = None, + error_message: Optional[str] = None, + ): + try: + from ..models import AICallLog + + total = prompt_tokens + completion_tokens + cost = _estimate_cost(prompt_tokens, completion_tokens) + log = AICallLog( + tenant_id=_t2s(tenant_id), + function_name=function_name, + input_hash=input_hash, + status=status, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total, + cost_usd=cost, + latency_ms=latency_ms, + error_message=error_message, + ) + db.add(log) + await db.commit() + except Exception: + pass + + async def _invoke_with_retry( + self, + prompt: str, + db=None, + tenant_id: str = "default", + function_name: str = "unknown", + use_cache: bool = True, + cache_key: Optional[str] = None, + project_id: Optional[int] = None, + ) -> str: + """带缓存、重试、日志的 LLM 调用""" + input_hash = cache_key or _hash_input(function_name, prompt) + + if use_cache and db is None: + cached = await self._cache_get(input_hash) + if cached: + return cached + + # 预算前置检查(仅在有 db 会话时启用) + if db is not None: + try: + from .budget_enforcement_service import BudgetEnforcementService + + tenant_int = _tenant_to_int(tenant_id) + # 预估:按 prompt 长度粗略估算,保守给定 completion 预算 + estimated_prompt_tokens = max(len(prompt) // 4, 1) + estimated_completion_tokens = 1200 + estimated_cost = _estimate_cost(estimated_prompt_tokens, estimated_completion_tokens) + + budget_service = BudgetEnforcementService(db) + budget_check = await budget_service.check_budget_before_ai_call( + tenant_id=tenant_int, + estimated_cost_usd=estimated_cost, + project_id=project_id, + ) + if not budget_check.get("allowed", True): + raise RuntimeError("预算超限,AI 调用被阻止(hard limit)") + except RuntimeError: + raise + except Exception: + # 预算系统异常不阻断主流程,避免影响现有可用性 + pass + + llm = self._get_llm() + _, system_prompt = await self._resolve_active_system_prompt( + tenant_id=tenant_id, + db=db, + ) + last_err = None + for attempt in range(self.max_retries): + try: + t0 = time.perf_counter() + messages = [HumanMessage(content=prompt)] + if system_prompt: + messages = [SystemMessage(content=system_prompt), *messages] + resp = await llm.ainvoke(messages) + latency_ms = int((time.perf_counter() - t0) * 1000) + text = resp.content if hasattr(resp, "content") else str(resp) + pt = getattr(resp, "response_metadata", None) or {} + usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {}) + prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) + completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) + + if db: + await self._log_call( + db, tenant_id, function_name, input_hash, "success", + prompt_tokens, completion_tokens, latency_ms, + ) + # 按实际 token 成本累计预算 + try: + from .budget_enforcement_service import BudgetEnforcementService + + actual_cost = _estimate_cost(prompt_tokens, completion_tokens) + await BudgetEnforcementService(db).update_budget_usage_after_ai_call( + tenant_id=_tenant_to_int(tenant_id), + actual_cost_usd=actual_cost, + project_id=project_id, + ) + except Exception: + pass + if use_cache: + await self._cache_set(input_hash, text) + return text + except Exception as e: + last_err = e + wait = 2 ** attempt + time.sleep(wait) + + if db: + await self._log_call( + db, tenant_id, function_name, input_hash, "failed", + error_message=str(last_err), + ) + raise last_err + + def _parse_json(self, text: str) -> dict: + text = text.strip() + for p in ("```json", "```"): + if text.startswith(p): + text = text[len(p):] + if text.endswith("```"): + text = text[:-3] + return json.loads(text.strip()) + + # ======================================================================== + # 核心函数 + # ======================================================================== + + async def generate_charter_from_signal( + self, + signal_content: str, + tenant_id: Union[str, int] = "default", + db=None, + ) -> dict: + """从市场/模型信号生成需求宪章草案""" + from ..config.ai_prompts import PROMPT_GENERATE_CHARTER + + tid = _t2s(tenant_id) + prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal", + ) + return self._parse_json(text) + + async def decompose_tasks_from_charter( + self, + charter_content: str, + project_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + ) -> dict: + """从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表""" + from ..config.ai_prompts import PROMPT_DECOMPOSE_TASKS + + tid = _t2s(tenant_id) + prompt = PROMPT_DECOMPOSE_TASKS.format( + charter_content=charter_content, + project_id=_t2s(project_id), + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter", + ) + return self._parse_json(text) + + async def recommend_assignees_for_task( + self, + task_content: str, + required_skill_tag_ids: list, + project_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + candidates_json: Optional[str] = None, + ) -> dict: + """基于人员能力图谱、负载、熟练度,推荐 Top3 人员""" + from ..config.ai_prompts import PROMPT_RECOMMEND_ASSIGNEES + + tid = _t2s(tenant_id) + candidates = candidates_json or "[]" + prompt = PROMPT_RECOMMEND_ASSIGNEES.format( + task_content=task_content, + required_skill_tag_ids=json.dumps(required_skill_tag_ids), + candidates_json=candidates, + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task", + ) + return self._parse_json(text) + + async def update_user_skill_scores( + self, + task_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + quality_score: float = 0.0, + efficiency_score: float = 0.0, + assignee_ids: Optional[list] = None, + skill_tag_ids: Optional[list] = None, + ) -> dict: + """任务完成后,根据质量/效率评分更新相关人员技能分""" + from sqlalchemy import select + from ..models import UserSkillBinding + from uuid import UUID + + def to_uuid(v): + if v is None: + return None + if isinstance(v, UUID): + return v + s = str(v) + try: + return UUID(s) if len(s) == 36 else None + except ValueError: + return None + + if db is None: + return {"updated_count": 0, "updates": []} + + tid = _t2s(tenant_id) + updates = [] + if assignee_ids and skill_tag_ids: + for uid in assignee_ids: + for sid in skill_tag_ids: + uid_u = to_uuid(uid) + sid_u = to_uuid(sid) + if not uid_u or not sid_u: + continue + try: + r = await db.execute( + select(UserSkillBinding).where( + UserSkillBinding.tenant_id == tid, + UserSkillBinding.user_id == uid_u, + UserSkillBinding.skill_tag_id == sid_u, + ) + ) + b = r.scalar_one_or_none() + if b: + b.quality_score = min(100, max(0, (b.quality_score or 0) + quality_score)) + b.efficiency_score = min(100, max(0, (b.efficiency_score or 0) + efficiency_score)) + b.last_used_at = datetime.utcnow() + await db.commit() + updates.append({ + "user_id": str(uid), + "skill_tag_id": str(sid), + "quality_score_delta": quality_score, + "efficiency_score_delta": efficiency_score, + "last_used_at": b.last_used_at.isoformat(), + }) + except Exception: + pass + + return {"updated_count": len(updates), "updates": updates} + + async def calculate_alignment_score( + self, + user_id: Union[str, int], + truth_node_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + truth_content: str = "", + user_context: str = "", + ) -> dict: + """计算用户对真理节点的认知对齐度(0-100)""" + from ..config.ai_prompts import PROMPT_ALIGNMENT_SCORE + from sqlalchemy import select + from ..models import TruthNode + + tid = _t2s(tenant_id) + if db and not truth_content: + r = await db.execute( + select(TruthNode).where( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tid, + ) + ) + tn = r.scalar_one_or_none() + if tn: + truth_content = tn.content or tn.title or "" + + prompt = PROMPT_ALIGNMENT_SCORE.format( + user_id=_t2s(user_id), + truth_node_id=_t2s(truth_node_id), + truth_content=truth_content or "(无内容)", + user_context=user_context or "(无)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="calculate_alignment_score", + ) + data = self._parse_json(text) + data["user_id"] = _t2s(user_id) + data["truth_node_id"] = _t2s(truth_node_id) + return data + + async def detect_cognitive_conflicts( + self, + truth_node_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + truth_content: str = "", + user_viewpoints: str = "", + ) -> dict: + """识别真理节点下的隐性认知冲突""" + from ..config.ai_prompts import PROMPT_COGNITIVE_CONFLICTS + from sqlalchemy import select + from ..models import TruthNode + + tid = _t2s(tenant_id) + if db and not truth_content: + r = await db.execute( + select(TruthNode).where( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tid, + ) + ) + tn = r.scalar_one_or_none() + if tn: + truth_content = tn.content or tn.title or "" + + prompt = PROMPT_COGNITIVE_CONFLICTS.format( + truth_node_id=_t2s(truth_node_id), + truth_content=truth_content or "(无内容)", + user_viewpoints=user_viewpoints or "(无)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="detect_cognitive_conflicts", + ) + data = self._parse_json(text) + data["truth_node_id"] = _t2s(truth_node_id) + data["total_count"] = len(data.get("conflicts", [])) + return data + + async def evaluate_model_requirement( + self, + charter_content: str, + model_version_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + model_info: str = "", + ) -> dict: + """评估需求与模型的匹配度""" + from ..config.ai_prompts import PROMPT_EVALUATE_MODEL_REQUIREMENT + + tid = _t2s(tenant_id) + prompt = PROMPT_EVALUATE_MODEL_REQUIREMENT.format( + charter_content=charter_content, + model_version_id=_t2s(model_version_id), + model_info=model_info or "(未提供)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="evaluate_model_requirement", + ) + return self._parse_json(text) + + # ======================================================================== + # 流式调用 + # ======================================================================== + + async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: + """流式生成""" + llm = self._get_llm() + async for chunk in llm.astream([HumanMessage(content=prompt)]): + if hasattr(chunk, "content") and chunk.content: + yield chunk.content + + +# 单例 +_ai_service: Optional[AIService] = None + + +def get_ai_service() -> AIService: + global _ai_service + if _ai_service is None: + _ai_service = AIService() + return _ai_service diff --git a/backend/app/services/ai_steward.py b/backend/app/services/ai_steward.py new file mode 100644 index 00000000..939eb742 --- /dev/null +++ b/backend/app/services/ai_steward.py @@ -0,0 +1,656 @@ +"""AI Steward Service - 主动式 AI 管家核心服务。""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Dict, List + +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.logging import get_logger +from ..database import get_db +from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence +from .adaptive_rag_service import AdaptiveRAGService +from .llm import LLMService +from .task_persistence import ( + build_task_create_draft, + normalize_skill_names, + persist_task, + resolve_skill_tag_ids_by_names, +) + +logger = get_logger(__name__) +PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = 8.0 + + +class AIStewardService: + """主动式 AI 管家服务。""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def create_project_from_natural_language( + self, + input_text: str, + creator_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """零配置项目创建,统一走 draft -> normalize -> persist 三段式。""" + analysis = await self._analyze_project_input(input_text, tenant_id) + draft = await self._generate_project_creation_draft( + input_text=input_text, + analysis=analysis, + tenant_id=tenant_id, + ) + normalized = await self._normalize_project_creation_draft( + draft=draft, + input_text=input_text, + creator_id=creator_id, + tenant_id=tenant_id, + ) + payload = await self._persist_project_creation_draft( + normalized=normalized, + input_text=input_text, + creator_id=creator_id, + tenant_id=tenant_id, + ) + + await self.db.commit() + + try: + charter = payload["charter"] + project = payload["project"] + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter["id"]), + project_id=int(project["id"]), + title=str(charter.get("title") or "项目宪章"), + content=json.dumps(charter.get("content") or {}, ensure_ascii=False), + ) + ) + for task in payload.get("tasks", []): + task_id = int(str(task.get("id") or "0") or 0) + if task_id <= 0: + continue + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type="task", + record_id=task_id, + project_id=int(project["id"]), + title=str(task.get("title") or "任务"), + content=str(task.get("description") or ""), + ) + ) + except Exception: + pass + + return payload + + async def _generate_project_creation_draft( + self, + *, + input_text: str, + analysis: Dict[str, Any], + tenant_id: int, + ) -> dict[str, Any]: + signal_draft, charter_draft, task_drafts = await asyncio.gather( + self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id), + self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id), + self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id), + ) + return { + "analysis": analysis, + "project": { + "name": analysis.get("project_name") or input_text[:20] or "未命名项目", + "title": analysis.get("project_title") or input_text[:50] or "未命名项目", + "description": input_text, + "assigned_role": analysis.get("primary_role") or "backend", + "domain_id": analysis.get("domain_id"), + }, + "signal": signal_draft, + "charter": charter_draft, + "tasks": task_drafts, + "risks": self._build_risk_alert_drafts(analysis=analysis), + } + + async def _generate_project_creation_text( + self, + *, + prompt: str, + tenant_id: int, + function_name: str, + timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, + ) -> str: + try: + return await asyncio.wait_for( + self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name=function_name, + ), + timeout=timeout_seconds, + ) + except Exception as exc: + logger.warning( + "ai_steward_project_creation_step_fallback", + tenant_id=int(tenant_id), + function_name=function_name, + error=str(exc), + ) + return "" + + async def _normalize_project_creation_draft( + self, + *, + draft: dict[str, Any], + input_text: str, + creator_id: int, + tenant_id: int, + ) -> dict[str, Any]: + analysis = draft.get("analysis") if isinstance(draft.get("analysis"), dict) else {} + project_draft = draft.get("project") if isinstance(draft.get("project"), dict) else {} + charter_draft = draft.get("charter") if isinstance(draft.get("charter"), dict) else {} + signal_draft = draft.get("signal") if isinstance(draft.get("signal"), dict) else {} + risk_drafts = draft.get("risks") if isinstance(draft.get("risks"), list) else [] + + normalized_task_drafts = [] + task_audit: list[dict[str, Any]] = [] + for raw_task in (draft.get("tasks") if isinstance(draft.get("tasks"), list) else [])[:12]: + if not isinstance(raw_task, dict): + continue + raw_required_skills = normalize_skill_names(raw_task.get("required_skills")) + resolution = await resolve_skill_tag_ids_by_names( + self.db, + tenant_id=tenant_id, + skill_names=raw_required_skills, + ) + validation_error = None + try: + task_draft = build_task_create_draft( + title=str(raw_task.get("title") or ""), + description=str(raw_task.get("description") or "").strip() or None, + required_skill_tag_ids=resolution.matched_ids, + priority=raw_task.get("priority"), + status="todo", + estimated_hours=raw_task.get("estimated_hours"), + due_date=raw_task.get("due_date"), + ) + except ValueError as exc: + validation_error = str(exc) + task_draft = build_task_create_draft( + title=str(raw_task.get("title") or ""), + description=str(raw_task.get("description") or "").strip() or None, + required_skill_tag_ids=resolution.matched_ids, + priority=raw_task.get("priority"), + status="todo", + estimated_hours=None, + due_date=None, + ) + normalized_task_drafts.append(task_draft) + task_audit.append( + { + "raw_task_draft": raw_task, + "normalized_title": task_draft.title, + "required_skills_input": raw_required_skills, + "required_skill_tag_ids": resolution.matched_ids, + "skill_resolution": resolution.matched_items, + "dropped_required_skills": resolution.unmatched_names, + "validation_error": validation_error, + } + ) + + normalized_project = { + "name": str(project_draft.get("name") or "未命名项目").strip()[:50] or "未命名项目", + "title": str(project_draft.get("title") or input_text[:50] or "未命名项目").strip()[:50] or "未命名项目", + "description": str(project_draft.get("description") or input_text).strip()[:4000] or input_text[:4000], + "assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend", + "domain_id": analysis.get("domain_id"), + } + normalized_charter_content = charter_draft.get("content") if isinstance(charter_draft.get("content"), dict) else { + "rationale": str(charter_draft.get("content") or ""), + "success_criteria": charter_draft.get("success_criteria") or [], + "constraints": charter_draft.get("constraints") or [], + "signal_ids": [], + } + normalized_signal = { + "content": str(signal_draft.get("content") or "").strip()[:2000] or f"竞品分析:{normalized_project['title']}", + "impact_score": float(signal_draft.get("impact_score") or 0.8), + "priority": str(signal_draft.get("priority") or "high").strip().lower() or "high", + "strategic_analysis": signal_draft.get("strategic_analysis") if isinstance(signal_draft.get("strategic_analysis"), dict) else {}, + } + normalized_risks = [risk for risk in risk_drafts if isinstance(risk, dict)] + + return { + "analysis": analysis, + "project": normalized_project, + "signal": normalized_signal, + "charter": { + "title": str(charter_draft.get("title") or f"{normalized_project['name']}项目宪章").strip()[:200] or "项目宪章", + "content": normalized_charter_content, + }, + "tasks": normalized_task_drafts, + "task_audit": task_audit, + "risks": normalized_risks, + "creator_id": int(creator_id), + } + + async def _persist_project_creation_draft( + self, + *, + normalized: dict[str, Any], + input_text: str, + creator_id: int, + tenant_id: int, + ) -> dict[str, Any]: + project_data = normalized["project"] + await ensure_project_id_sequence(self.db) + project = Project( + name=project_data["name"], + title=project_data["title"], + description=project_data["description"], + owner_id=int(creator_id), + status="active", + domain_id=project_data.get("domain_id"), + assigned_role=project_data["assigned_role"], + signal_count=0, + tenant_id=int(tenant_id), + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=int(tenant_id), + project_id=int(project.id), + owner_id=int(creator_id), + assigned_role=project.assigned_role, + ) + + signal_data = normalized["signal"] + signal = Signal( + source="AI-Steward", + content=signal_data["content"], + impact_score=signal_data["impact_score"], + priority=signal_data["priority"], + processed=True, + strategic_analysis=json.dumps(signal_data.get("strategic_analysis") or {}, ensure_ascii=False), + tasks_generated=json.dumps([]), + tenant_id=int(tenant_id), + ) + self.db.add(signal) + await self.db.flush() + + self.db.add( + ProjectSignalLink( + tenant_id=int(tenant_id), + project_id=int(project.id), + signal_id=int(signal.id), + link_type="competitive_analysis", + relevance_score=0.9, + ) + ) + + charter_data = normalized["charter"] + charter = TruthNode( + node_type="charter", + project_id=int(project.id), + created_by=int(creator_id), + title=charter_data["title"], + content=charter_data["content"], + version=1, + status="active", + tenant_id=int(tenant_id), + ) + self.db.add(charter) + await self.db.flush() + + persisted_tasks: list[dict[str, Any]] = [] + persisted_task_ids: list[int] = [] + for task_draft in normalized["tasks"]: + task = await persist_task( + self.db, + tenant_id=int(tenant_id), + truth_node_id=int(charter.id), + project_id=int(project.id), + draft=task_draft, + actor_user_id=int(creator_id), + action="task.create", + remark="ai_steward.project_from_text", + commit=False, + refresh_workload=False, + ) + persisted_task_ids.append(int(task.id)) + persisted_tasks.append( + { + "id": str(task.id), + "title": task.title, + "description": task.description or "", + "estimated_hours": float(task.estimated_hours or 0), + "priority": task.priority, + "status": task.status, + } + ) + + risks_payload: list[dict[str, Any]] = [] + for risk in normalized["risks"]: + description = str(risk.get("description") or "").strip() + conflict_type = str(risk.get("type") or "risk").strip() or "risk" + if not description: + continue + self.db.add( + CognitiveConflict( + truth_node_id=int(charter.id), + conflict_type=conflict_type, + description=description, + involved_user_ids=[], + status="active", + tenant_id=int(tenant_id), + ) + ) + risks_payload.append( + { + "type": conflict_type, + "description": description, + "threshold_hours": risk.get("threshold_hours"), + "threshold": risk.get("threshold"), + "severity": risk.get("severity"), + } + ) + + logger.info( + "ai_steward_project_from_text_persisted", + tenant_id=int(tenant_id), + creator_id=int(creator_id), + project_id=int(project.id), + signal_id=int(signal.id), + charter_id=int(charter.id), + persisted_task_ids=persisted_task_ids, + task_audit=normalized["task_audit"], + input_text=input_text[:500], + ) + + return { + "project": { + "id": str(project.id), + "name": project.name, + "title": project.title, + "description": project.description, + "status": project.status, + }, + "signal": { + "id": str(signal.id), + "content": signal.content, + "impact_score": signal.impact_score, + }, + "charter": { + "id": str(charter.id), + "title": charter.title, + "content": charter.content, + }, + "tasks": persisted_tasks, + "risks": risks_payload, + "analysis": normalized["analysis"], + } + + async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]: + """使用 AI 分析项目输入,提取关键信息。""" + prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回: + +用户输入:{input_text} + +请提取以下信息: +{{ + "project_name": "项目名称(简短)", + "project_title": "项目标题", + "tech_stack": ["技术栈列表"], + "target_audience": "目标用户", + "timeline": "时间周期", + "primary_role": "主要角色类型(ai_engineer/backend/frontend)", + "domain_id": 1, + "key_features": ["核心功能列表"], + "competitors": ["竞品列表"], + "success_metrics": ["成功指标"] +}} + +只返回 JSON,不要其他内容。""" + + response = await self._generate_project_creation_text( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_from_text_analysis", + ) + try: + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + analysis = json.loads(response.strip()) + except Exception: + analysis = { + "project_name": input_text[:20], + "project_title": input_text[:50], + "tech_stack": ["GPT-4o-mini"], + "target_audience": "通用", + "timeline": "3周", + "primary_role": "ai_engineer", + "domain_id": 1, + "key_features": [], + "competitors": [], + "success_metrics": [], + } + return analysis + + async def _generate_competitive_signal_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + competitors = analysis.get("competitors", []) + if not competitors: + competitors = ["行业同类产品"] + + prompt = f"""基于以下项目信息,生成竞品分析信号: + +项目:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +竞品:{', '.join(competitors)} + +请生成: +1. 竞品现状分析 +2. 差异化机会 +3. 技术风险评估 + +以 JSON 格式返回: +{{ + "content": "信号内容(200字以内)", + "impact_score": 0.9, + "priority": "high", + "strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}} +}}""" + + response = await self._generate_project_creation_text( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_from_text_signal", + ) + try: + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + return json.loads(response.strip()) + except Exception: + return { + "content": f"竞品分析:{', '.join(competitors)} 的技术现状和差异化机会", + "impact_score": 0.8, + "priority": "high", + "strategic_analysis": {"is_strategic": True}, + } + + async def _generate_charter_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + prompt = f"""基于以下项目信息,生成项目宪章: + +项目:{analysis.get('project_title')} +描述:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +核心功能:{', '.join(analysis.get('key_features', []))} +成功指标:{', '.join(analysis.get('success_metrics', []))} + +请生成包含以下内容的宪章: +1. 项目目标(明确、可衡量) +2. 成功标准(具体指标) +3. 技术约束 +4. 时间节点 + +以 JSON 格式返回: +{{ + "title": "宪章标题", + "content": "宪章完整内容(包含上述4个方面)", + "success_criteria": ["标准1", "标准2"], + "constraints": ["约束1", "约束2"] +}}""" + + response = await self._generate_project_creation_text( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_from_text_charter", + ) + try: + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + charter_data = json.loads(response.strip()) + except Exception: + charter_data = { + "title": f"{analysis.get('project_name')}项目宪章", + "content": f"项目目标:{analysis.get('project_title')}\n成功标准:按时上线", + "success_criteria": ["按时交付", "功能完整"], + "constraints": ["时间限制"], + } + + raw_content = charter_data.get("content", "") + if isinstance(raw_content, dict): + content = dict(raw_content) + else: + content = { + "rationale": str(raw_content), + "success_criteria": charter_data.get("success_criteria", []), + "constraints": charter_data.get("constraints", []), + "signal_ids": [], + } + return { + "title": charter_data.get("title", "项目宪章"), + "content": content, + } + + async def _generate_task_chain_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> List[Dict[str, Any]]: + prompt = f"""基于以下信息,生成完整的任务链: + +项目:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +时间周期:{analysis.get('timeline', '3周')} + +请生成: +1. 任务列表(按优先级排序) +2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求 + +以 JSON 格式返回: +{{ + "tasks": [ + {{ + "title": "任务名称", + "type": "backend/frontend/ai_engineer", + "estimated_hours": 8, + "description": "任务描述", + "priority": "high/medium/low", + "required_skills": ["技能1", "技能2"] + }} + ] +}}""" + + response = await self._generate_project_creation_text( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_from_text_task_chain", + ) + try: + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + tasks_data = json.loads(response.strip()) + tasks_list = tasks_data.get("tasks", []) + if isinstance(tasks_list, list) and tasks_list: + return tasks_list + except Exception: + pass + return [ + { + "title": "技术方案设计", + "type": analysis.get("primary_role", "backend"), + "estimated_hours": 16, + "description": "完成项目技术方案设计", + "priority": "high", + "required_skills": ["系统设计"], + }, + { + "title": "核心功能开发", + "type": analysis.get("primary_role", "backend"), + "estimated_hours": 40, + "description": "完成核心功能开发", + "priority": "high", + "required_skills": ["开发"], + }, + ] + + @staticmethod + def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: + estimated_hours = analysis.get("estimated_hours", 40) + return [ + { + "type": "schedule_delay", + "description": "任务超时预警", + "threshold_hours": estimated_hours * 1.2, + "severity": "medium", + }, + { + "type": "quality_drop", + "description": "质量下降预警", + "threshold": 0.85, + "severity": "high", + }, + { + "type": "skill_mismatch", + "description": "技能匹配度不足", + "threshold": 0.6, + "severity": "medium", + }, + ] + + +def get_ai_steward_service(db: AsyncSession) -> AIStewardService: + """获取 AI Steward 服务实例。""" + return AIStewardService(db) diff --git a/backend/app/services/alignment_orchestrator.py b/backend/app/services/alignment_orchestrator.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/analyzer.py b/backend/app/services/analyzer.py new file mode 100644 index 00000000..9dd9ae10 --- /dev/null +++ b/backend/app/services/analyzer.py @@ -0,0 +1,12 @@ +"""信号影响评分分析(轻量实现)。""" + + +def analyze_impact(content: str) -> float: + text = (content or "").strip() + if not text: + return 0.1 + if len(text) > 300: + return 0.8 + if len(text) > 120: + return 0.6 + return 0.4 diff --git a/backend/app/services/auditor.py b/backend/app/services/auditor.py new file mode 100644 index 00000000..09203e8e --- /dev/null +++ b/backend/app/services/auditor.py @@ -0,0 +1,635 @@ +""" +Neural Sync - Enhanced Code Auditor Service + +增强版代码审计服务 - 支持严格模式的自动合并 +""" + +import asyncio +import re +import logging +from typing import Dict, List, Optional, Any, Tuple +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum + +logger = logging.getLogger(__name__) + + +class AuditSeverity(str, Enum): + """审计问题严重程度""" + CRITICAL = "critical" # 阻止合并 + HIGH = "high" # 大幅扣分 + MEDIUM = "medium" # 中度扣分 + LOW = "low" # 轻微扣分 + INFO = "info" # 仅提示 + + +class AuditIssue(BaseModel): + """审计问题""" + severity: AuditSeverity + category: str # security, performance, style, logic, etc. + file_path: str + line_number: Optional[int] = None + message: str + suggestion: Optional[str] = None + score_penalty: float = 0.0 + + +class AuditResult(BaseModel): + """审计结果""" + signal_id: Optional[str] = None + commit_sha: Optional[str] = None + task_name: Optional[str] = None + passed: bool + score: float = Field(default=0.0, ge=0.0, le=100.0) + issues: List[AuditIssue] = Field(default_factory=list) + summary: str = "" + details: Dict[str, Any] = Field(default_factory=dict) + timestamp: float = Field(default_factory=lambda: datetime.utcnow().timestamp()) + + +class SecurityIssue(BaseModel): + """安全问题""" + type: str # sql_injection, xss, csrf, etc. + severity: AuditSeverity + file_path: str + line_number: int + code_snippet: str + description: str + fix_suggestion: str + + +class CodeAuditor: + """ + 增强版代码审计器 + + 特性: + - 安全漏洞扫描 + - 代码质量评估 + - 性能问题检测 + - 最佳实践检查 + - 严格评分机制(98+ 分阈值) + """ + + # 审计权重 + WEIGHTS = { + "security": 30, # 安全性最重要 + "functionality": 25, # 功能正确性 + "performance": 15, # 性能优化 + "style": 10, # 代码风格 + "documentation": 10, # 文档完整性 + "testing": 10, # 测试覆盖 + } + + # 安全模式库 + SECURITY_PATTERNS = { + "sql_injection": [ + r'execute\s*\(\s*["\'].*?\+.*?["\']', # 字符串拼接 SQL + r'cursor\.execute\s*\(\s*["\'].*?%s.*?["\']', # %s 格式化 + r'\$\{.*\}.*?(?:SELECT|INSERT|UPDATE|DELETE)', # 模板字符串 SQL + ], + "xss": [ + r'innerHTML\s*=\s*.*?\+.*?(?:request|input|form)', + r'eval\s*\(', + r'document\.write\s*\(', + ], + "hardcoded_secrets": [ + r'(?:password|secret|api_key|token)\s*=\s*["\'][^"\']+["\']', + r'Bearer\s+[A-Za-z0-9]{20,}', + ], + "command_injection": [ + r'subprocess\.call\s*\(\s*["\'].*?\+.*?["\']', + r'os\.system\s*\(\s*["\'].*?\+.*?["\']', + ], + "path_traversal": [ + r'open\s*\(\s*["\'].*?\.\.\/.*?["\']', + r'Path\s*\(\s*["\'].*?\.\.\/.*?["\']', + ], + } + + # 性能问题模式 + PERFORMANCE_PATTERNS = { + "n_plus_1": [ + r'for\s+\w+\s+in\s+range\s*\(\s*len\s*\(', + r'\[[^\]]+\]\s*\.append\s*\(.*?\)\s*for\s+\w+\s+in\s+', + ], + "inefficient_string_concat": [ + r'["\'][^"\']+["\']\s*\+.*?\+', + ], + "unused_import": [], + "large_file": None, # 文件行数检查 + } + + # 代码风格检查 + STYLE_PATTERNS = { + "line_too_long": 100, # 最大行长度 + "function_too_long": 50, # 最大函数行数 + "class_too_long": 200, # 最大类行数 + } + + def __init__(self, llm_service): + self.llm = llm_service + + async def audit_code_change( + self, + task_name: str, + commit_message: str, + code_diff: str, + signal_id: Optional[str] = None, + commit_sha: Optional[str] = None, + file_contexts: Optional[Dict[str, str]] = None, + ) -> AuditResult: + """ + 审计代码变更 + + Args: + task_name: 任务名称 + commit_message: 提交消息 + code_diff: 代码 diff + signal_id: 关联的信号 ID + commit_sha: 提交 SHA + file_contexts: 文件上下文(完整文件内容) + + Returns: + 审计结果 + """ + issues = [] + + try: + # 解析 diff 获取变更的文件 + changed_files = self._parse_diff(code_diff) + + # 对每个变更的文件进行审计 + for file_path, file_changes in changed_files.items(): + # 获取文件内容(如果提供了上下文) + full_content = file_contexts.get(file_path) if file_contexts else None + + # 安全审计 + security_issues = await self._audit_security( + file_path, file_changes, full_content + ) + issues.extend(security_issues) + + # 性能审计 + perf_issues = self._audit_performance( + file_path, file_changes, full_content + ) + issues.extend(perf_issues) + + # 代码风格审计 + style_issues = self._audit_style( + file_path, file_changes, full_content + ) + issues.extend(style_issues) + + # 功能正确性审计(使用 AI) + functional_issues = await self._audit_functionality( + task_name, file_path, file_changes, full_content + ) + issues.extend(functional_issues) + + # AI 深度审计 + ai_issues = await self._ai_deep_audit( + task_name, commit_message, code_diff, changed_files + ) + issues.extend(ai_issues) + + # 计算总分 + score = self._calculate_score(issues) + + # 生成摘要 + summary = self._generate_summary(issues, score) + + # 判断是否通过(严格模式:98分) + passed = score >= 98.0 and not any( + i.severity == AuditSeverity.CRITICAL for i in issues + ) + + return AuditResult( + signal_id=signal_id, + commit_sha=commit_sha, + task_name=task_name, + passed=passed, + score=score, + issues=issues, + summary=summary, + details={ + "changed_files_count": len(changed_files), + "issues_by_severity": self._count_issues_by_severity(issues), + "issues_by_category": self._count_issues_by_category(issues), + "changed_files": list(changed_files.keys()), + }, + ) + + except Exception as e: + logger.error(f"Audit failed: {e}", exc_info=True) + return AuditResult( + signal_id=signal_id, + commit_sha=commit_sha, + task_name=task_name, + passed=False, + score=0.0, + issues=[ + AuditIssue( + severity=AuditSeverity.CRITICAL, + category="system", + file_path="unknown", + message=f"审计过程出错: {str(e)}", + score_penalty=100.0, + ) + ], + summary=f"审计失败: {str(e)}", + ) + + def _parse_diff(self, diff: str) -> Dict[str, str]: + """解析 unified diff 格式""" + files = {} + current_file = None + current_content = [] + + for line in diff.split("\n"): + if line.startswith("diff --git"): + # 新文件开始 + if current_file and current_content: + files[current_file] = "\n".join(current_content) + + # 提取文件路径 + parts = line.split() + if len(parts) >= 4: + current_file = parts[3].lstrip("b/") + else: + current_file = parts[2].lstrip("b/") + current_content = [] + + elif line.startswith("@@") or line.startswith("---") or line.startswith("+++"): + continue + elif current_file: + current_content.append(line) + + # 保存最后一个文件 + if current_file and current_content: + files[current_file] = "\n".join(current_content) + + return files + + async def _audit_security( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """安全审计""" + issues = [] + content = full_content or file_changes + + # 检查常见安全问题 + for vuln_type, patterns in self.SECURITY_PATTERNS.items(): + for pattern in patterns: + matches = re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + + # 计算扣分 + if vuln_type in ["sql_injection", "xss", "command_injection"]: + severity = AuditSeverity.CRITICAL + penalty = 20.0 + elif vuln_type == "hardcoded_secrets": + severity = AuditSeverity.HIGH + penalty = 10.0 + elif vuln_type == "path_traversal": + severity = AuditSeverity.HIGH + penalty = 15.0 + else: + severity = AuditSeverity.MEDIUM + penalty = 5.0 + + issues.append( + AuditIssue( + severity=severity, + category="security", + file_path=file_path, + line_number=line_num, + message=f"潜在{vuln_type}漏洞", + suggestion=f"使用参数化查询/输入验证来避免{vuln_type}", + score_penalty=penalty, + ) + ) + + return issues + + def _audit_performance( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """性能审计""" + issues = [] + content = full_content or file_changes + + # 检查 N+1 问题 + for pattern in self.PERFORMANCE_PATTERNS.get("n_plus_1", []): + matches = re.finditer(pattern, content, re.MULTILINE) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + issues.append( + AuditIssue( + severity=AuditSeverity.MEDIUM, + category="performance", + file_path=file_path, + line_number=line_num, + message="可能的 N+1 查询问题", + suggestion="考虑使用 bulk_create 或 select_related 优化", + score_penalty=5.0, + ) + ) + + # 检查低效字符串拼接 + for pattern in self.PERFORMANCE_PATTERNS.get("inefficient_string_concat", []): + matches = re.finditer(pattern, content) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + issues.append( + AuditIssue( + severity=AuditSeverity.LOW, + category="performance", + file_path=file_path, + line_number=line_num, + message="低效的字符串拼接", + suggestion="使用 f-string 或 join()", + score_penalty=2.0, + ) + ) + + # 检查文件大小 + if full_content: + line_count = len(full_content.split("\n")) + if line_count > 500: + issues.append( + AuditIssue( + severity=AuditSeverity.MEDIUM, + category="performance", + file_path=file_path, + message=f"文件过大 ({line_count} 行)", + suggestion="考虑拆分为更小的模块", + score_penalty=5.0, + ) + ) + + return issues + + def _audit_style( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """代码风格审计""" + issues = [] + content = full_content or file_changes + + # 检查行长度 + max_length = self.STYLE_PATTERNS["line_too_long"] + for i, line in enumerate(content.split("\n"), 1): + if len(line) > max_length: + issues.append( + AuditIssue( + severity=AuditSeverity.INFO, + category="style", + file_path=file_path, + line_number=i, + message=f"行过长 ({len(line)} > {max_length})", + suggestion="拆分为多行", + score_penalty=1.0, + ) + ) + + return issues + + async def _audit_functionality( + self, task_name: str, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """功能正确性审计(使用 AI)""" + issues = [] + + # 构建 AI 审计 prompt + prompt = f"""作为一位资深代码审查员,请审计以下代码变更: + +任务需求:{task_name} + +文件:{file_path} + +代码变更: +```diff +{file_changes} +``` + +{f'完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```' if full_content else ''} + +请从以下维度进行审查: +1. 功能正确性:代码是否正确实现了需求 +2. 逻辑完整性:是否考虑了边界情况 +3. 错误处理:是否有适当的异常处理 +4. 资源管理:是否有资源泄漏风险 + +返回格式(JSON): +```json +{{ + "issues": [ + {{ + "severity": "critical|high|medium|low|info", + "line_number": 123, + "message": "问题描述", + "suggestion": "改进建议", + "score_penalty": 10.0 + }} + ] +}} +``` +""" + + try: + # 调用 LLM 进行深度审计 + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "你是一位严格但公平的代码审查专家。"}, + {"role": "user", "content": prompt} + ], + temperature=0.2, + ) + + # 解析 AI 响应 + import json + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) + if json_match: + ai_result = json.loads(json_match.group(1)) + for issue_data in ai_result.get("issues", []): + # 限制 AI 生成的问题数量,避免过度敏感 + if len(issues) < 10: # 最多 10 个 AI 发现的问题 + severity_map = { + "critical": AuditSeverity.CRITICAL, + "high": AuditSeverity.HIGH, + "medium": AuditSeverity.MEDIUM, + "low": AuditSeverity.LOW, + "info": AuditSeverity.INFO, + } + issues.append( + AuditIssue( + severity=severity_map.get(issue_data.get("severity", "info"), AuditSeverity.INFO), + category="functionality", + file_path=file_path, + line_number=issue_data.get("line_number"), + message=issue_data.get("message", ""), + suggestion=issue_data.get("suggestion", ""), + score_penalty=issue_data.get("score_penalty", 5.0), + ) + ) + except Exception as e: + logger.warning(f"AI functionality audit failed: {e}") + + return issues + + async def _ai_deep_audit( + self, task_name: str, commit_message: str, code_diff: str, changed_files: Dict[str, str] + ) -> List[AuditIssue]: + """AI 深度审计 - 跨文件分析""" + issues = [] + + # 限制文件数量,避免 prompt 过长 + files_to_audit = list(changed_files.keys())[:5] + + prompt = f"""作为一位资深架构师,请从系统层面审查以下代码变更: + +任务:{task_name} +提交消息:{commit_message} + +变更的文件: +{', '.join(files_to_audit)} + +完整 diff: +```diff +{code_diff[:2000]} # 限制长度 +``` + +请检查: +1. 架构一致性:变更是否符合项目架构 +2. 依赖关系:是否引入了不兼容的依赖 +3. 配置变更:是否需要更新配置文件 +4. 迁移影响:是否需要数据迁移 +5. 测试覆盖:是否需要添加测试 + +返回格式(JSON): +```json +{{ + "issues": [ + {{ + "severity": "critical|high|medium|low", + "category": "architecture|dependencies|configuration|migration|testing", + "message": "问题描述", + "suggestion": "建议", + "score_penalty": 5.0 + }} + ] +}} +``` +""" + + try: + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "你是一位经验丰富的软件架构师。"}, + {"role": "user", "content": prompt} + ], + temperature=0.2, + ) + + import json + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) + if json_match: + ai_result = json.loads(json_match.group(1)) + for issue_data in ai_result.get("issues", []): + if len(issues) < 5: # 限制数量 + severity_map = { + "critical": AuditSeverity.CRITICAL, + "high": AuditSeverity.HIGH, + "medium": AuditSeverity.MEDIUM, + "low": AuditSeverity.LOW, + } + issues.append( + AuditIssue( + severity=severity_map.get(issue_data.get("severity", "low"), AuditSeverity.LOW), + category=issue_data.get("category", "architecture"), + file_path="multiple", + message=issue_data.get("message", ""), + suggestion=issue_data.get("suggestion", ""), + score_penalty=issue_data.get("score_penalty", 3.0), + ) + ) + except Exception as e: + logger.warning(f"AI deep audit failed: {e}") + + return issues + + def _calculate_score(self, issues: List[AuditIssue]) -> float: + """计算审计分数""" + # 初始满分 + score = 100.0 + + # 按权重扣分 + for issue in issues: + weight = self.WEIGHTS.get(issue.category, 10) + penalty = issue.score_penalty * (weight / 100) + score -= penalty + + return max(0.0, score) + + def _generate_summary(self, issues: List[AuditIssue], score: float) -> str: + """生成审计摘要""" + if not issues: + return f"完美!代码审计通过,得分: {score:.1f}/100" + + # 按严重程度统计 + critical = sum(1 for i in issues if i.severity == AuditSeverity.CRITICAL) + high = sum(1 for i in issues if i.severity == AuditSeverity.HIGH) + medium = sum(1 for i in issues if i.severity == AuditSeverity.MEDIUM) + + summary_parts = [f"审计得分: {score:.1f}/100"] + if critical > 0: + summary_parts.append(f"{critical} 个严重问题") + if high > 0: + summary_parts.append(f"{high} 个高优先级问题") + if medium > 0: + summary_parts.append(f"{medium} 个中等问题") + + return " | ".join(summary_parts) + + def _count_issues_by_severity(self, issues: List[AuditIssue]) -> Dict[str, int]: + """按严重程度统计问题""" + counts = {s.value: 0 for s in AuditSeverity} + for issue in issues: + counts[issue.severity.value] += 1 + return counts + + def _count_issues_by_category(self, issues: List[AuditIssue]) -> Dict[str, int]: + """按类别统计问题""" + counts = {} + for issue in issues: + counts[issue.category] = counts.get(issue.category, 0) + 1 + return counts + + def _detect_language(self, file_path: str) -> str: + """检测文件语言""" + ext = file_path.rsplit(".", 1)[-1].lower() + lang_map = { + "py": "python", + "ts": "typescript", + "tsx": "typescript", + "js": "javascript", + "jsx": "javascript", + "vue": "vue", + "sql": "sql", + } + return lang_map.get(ext, "text") + + +# 全局审计器实例 +_auditor = None + + +def get_auditor(llm_service) -> CodeAuditor: + """获取代码审计器实例""" + global _auditor + if _auditor is None: + _auditor = CodeAuditor(llm_service) + return _auditor diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 00000000..fe4a0492 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,218 @@ +""" +Authentication & Authorization Service + +Provides password hashing, JWT token management, and permission checking. +""" + +from datetime import datetime, timedelta +from typing import Optional +import hashlib +import hmac +import os + +# Try to import passlib, fallback to simple hashing +try: + from passlib.context import CryptContext + from jose import JWTError, jwt + HAS_PASSLIB = True +except ImportError: + HAS_PASSLIB = False + # Fallback JWT implementation + try: + import jwt as jwt_lib + jwt = jwt_lib + JWTError = Exception + except ImportError: + jwt = None + JWTError = Exception + +# JWT Configuration +SECRET_KEY = os.getenv("SECRET_KEY", "neural-sync-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 24 +MAX_BCRYPT_PASSWORD_BYTES = 72 + +# Password hashing context (if passlib available) +if HAS_PASSLIB: + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class AuthService: + """Service for authentication operations""" + + @staticmethod + def password_utf8_bytes(password: str) -> int: + return len(str(password or "").encode("utf-8")) + + @staticmethod + def validate_password(password: str) -> None: + if len(str(password or "")) < 6: + raise ValueError("密码至少需要 6 个字符") + if AuthService.password_utf8_bytes(password) > MAX_BCRYPT_PASSWORD_BYTES: + raise ValueError("密码最长支持 72 个 UTF-8 字节,请缩短后重试") + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hashed password""" + if HAS_PASSLIB: + try: + # 优先走 bcrypt(新数据) + if pwd_context.verify(plain_password, hashed_password): + return True + except Exception: + # 旧库可能是历史 SHA256 哈希,回退兼容 + pass + + return hmac.compare_digest( + AuthService._simple_hash(plain_password), + hashed_password, + ) + + # Simple SHA256 fallback for development + return hmac.compare_digest( + AuthService._simple_hash(plain_password), + hashed_password + ) + + @staticmethod + def hash_password(password: str) -> str: + """Hash a password for storage""" + AuthService.validate_password(password) + if HAS_PASSLIB: + try: + return pwd_context.hash(password) + except ValueError as exc: + if "72 bytes" in str(exc).lower() and AuthService.password_utf8_bytes(password) > MAX_BCRYPT_PASSWORD_BYTES: + raise ValueError("密码最长支持 72 个 UTF-8 字节,请缩短后重试") from exc + return AuthService._simple_hash(password) + except Exception: + return AuthService._simple_hash(password) + else: + # Simple SHA256 fallback for development + return AuthService._simple_hash(password) + + @staticmethod + def _simple_hash(password: str) -> str: + """Simple SHA256 hash with salt for development use""" + salt = SECRET_KEY[:16].encode() if SECRET_KEY else b'neural-sync-salt' + return hashlib.sha256(password.encode() + salt).hexdigest() + + @staticmethod + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token""" + if jwt is None: + # Fallback: return a simple token (not recommended for production) + import base64 + import json + token_data = { + **data, + "exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).timestamp() + } + return base64.b64encode(json.dumps(token_data).encode()).decode() + + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + @staticmethod + def verify_token(token: str) -> Optional[str]: + """ + Verify a JWT token and return the username + + Returns None if token is invalid or expired + """ + if jwt is None: + # Fallback: decode simple token + import base64 + import json + try: + token_data = json.loads(base64.b64decode(token).decode()) + if token_data.get("exp", 0) > datetime.utcnow().timestamp(): + return token_data.get("sub") + return None + except: + return None + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + return username + except JWTError: + return None + + +# Permission matrix for role-based access control +PERMISSION_MATRIX = { + "BOSS": [ + "view_all_signals", + "assign_tasks", + "manage_users", + "delete_users", + "manage_domains", + "view_projects", + "view_context", + "view_settings", + "manage_staff", + ], + "ADMIN": [ + "view_all_signals", + "assign_tasks", + "manage_domains", + "view_projects", + "view_context", + "view_settings", + "manage_staff", + ], + "AI": [ + "view_assigned_signals", + "create_experiments", + "view_context", + ], + "FE": [ + "view_assigned_signals", + "view_assigned_projects", + ], + "BE": [ + "view_assigned_signals", + "view_assigned_projects", + ], +} + + +def has_permission(user_role: str, permission: str) -> bool: + """Check if a role has a specific permission""" + roles = parse_role_values(user_role) + return any(permission in PERMISSION_MATRIX.get(role, []) for role in roles) + + +def has_any_role(user_role: str, allowed_roles: list) -> bool: + """Check if user_role is in the list of allowed roles""" + allowed = {str(role).upper() for role in allowed_roles} + return any(role in allowed for role in parse_role_values(user_role)) + + +def parse_role_values(user_role: str | None) -> list[str]: + """Parse single/multi role string into uppercase role tokens.""" + raw = str(user_role or "") + if not raw: + return [] + values: list[str] = [] + for token in raw.split(","): + role = token.strip().upper() + if role and role not in values: + values.append(role) + return values + + +def can_manage_users(user_role: str) -> bool: + """Check if user can manage other users (BOSS only)""" + return has_any_role(user_role, ["BOSS"]) + + +def can_assign_tasks(user_role: str) -> bool: + """Check if user can assign tasks to others""" + return has_any_role(user_role, ["BOSS", "ADMIN"]) diff --git a/backend/app/services/auto_merger.py b/backend/app/services/auto_merger.py new file mode 100644 index 00000000..df92096a --- /dev/null +++ b/backend/app/services/auto_merger.py @@ -0,0 +1,523 @@ +""" +Neural Sync - Auto Merge Service + +自动合并服务 - 严格模式 +- 审计评分 >= 98 分 +- 全量测试通过 +- 二次人工确认 +- 熔断机制 +""" + +import asyncio +import logging +from typing import Optional, Dict, List, Any, Tuple +from pydantic import BaseModel, Field +from datetime import datetime, timedelta +from enum import Enum +from ..events import Event, EventType + +logger = logging.getLogger(__name__) + + +class MergeStatus(str, Enum): + """合并状态""" + PENDING = "pending" + WAITING_CONFIRMATION = "waiting_confirmation" + APPROVED = "approved" + REJECTED = "rejected" + FAILED = "failed" + COOLDOWN = "cooldown" + + +class MergeRequest(BaseModel): + """合并请求""" + signal_id: str + commit_sha: str + task_name: str + audit_score: float = Field(ge=0.0, le=100.0) + test_results: Dict[str, Any] + requester: Optional[str] = None + + +class MergeResponse(BaseModel): + """合并响应""" + status: MergeStatus + success: bool + merge_sha: Optional[str] = None + reason: Optional[str] = None + requires_human_confirmation: bool = False + confirmation_token: Optional[str] = None + cooldown_remaining: Optional[float] = None + details: Dict[str, Any] = Field(default_factory=dict) + + +class ConfirmationRequest(BaseModel): + """人工确认请求""" + token: str + merge_request: MergeRequest + created_at: float + expires_at: float + + +class AutoMergeService: + """ + 自动合并服务 - 严格模式 + + 条件: + 1. 审计评分 >= 98 分 + 2. 全量测试通过 + 3. 二次人工确认 + 4. 不在熔断期内 + + 特性: + - 熔断机制(连续失败后暂停) + - 人工确认流程 + - 回滚支持 + - 事件通知 + """ + + # 严格模式阈值 + AUDIT_SCORE_THRESHOLD = 98.0 + HUMAN_CONFIRMATION_REQUIRED = True + CONFIRMATION_TIMEOUT = 300 # 5 minutes + + # 熔断机制 + MAX_CONSECUTIVE_FAILURES = 3 + FAILURE_COOLDOWN_SECONDS = 3600 # 1 hour + + def __init__(self, git_service=None, plane_service=None, lark_service=None, event_bus=None): + self.git = git_service + self.plane = plane_service + self.lark = lark_service + self.event_bus = event_bus + + # 状态跟踪 + self._failure_count = 0 + self._last_failure_time: Optional[datetime] = None + self._successful_merges = 0 + + # 待确认的请求 + self._pending_confirmations: Dict[str, ConfirmationRequest] = {} + + async def try_auto_merge(self, request: MergeRequest) -> MergeResponse: + """ + 尝试自动合并 + """ + # 1. 检查熔断状态 + if self._is_in_cooldown(): + return MergeResponse( + status=MergeStatus.COOLDOWN, + success=False, + reason=f"系统处于熔断期,请在 {self._get_cooldown_remaining()}s 后重试", + requires_human_confirmation=True, + cooldown_remaining=self._get_cooldown_remaining(), + ) + + # 2. 验证审计评分 + if request.audit_score < self.AUDIT_SCORE_THRESHOLD: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason=f"审计评分 {request.audit_score} 低于阈值 {self.AUDIT_SCORE_THRESHOLD}", + requires_human_confirmation=True, + ) + + # 3. 验证测试结果 + if not self._all_tests_passed(request.test_results): + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="测试未全部通过", + requires_human_confirmation=True, + details={ + "test_results": request.test_results, + "failed_tests": self._get_failed_tests(request.test_results), + }, + ) + + # 4. 创建人工确认请求 + if self.HUMAN_CONFIRMATION_REQUIRED: + confirmation_token = self._generate_confirmation_token() + expires_at = datetime.utcnow().timestamp() + self.CONFIRMATION_TIMEOUT + + confirmation = ConfirmationRequest( + token=confirmation_token, + merge_request=request, + created_at=datetime.utcnow().timestamp(), + expires_at=expires_at, + ) + self._pending_confirmations[confirmation_token] = confirmation + + # 发送确认请求事件 + await self._notify_confirmation_request(confirmation) + + return MergeResponse( + status=MergeStatus.WAITING_CONFIRMATION, + success=False, + reason="等待人工二次确认", + requires_human_confirmation=True, + confirmation_token=confirmation_token, + details={ + "expires_at": expires_at, + "timeout_seconds": self.CONFIRMATION_TIMEOUT, + }, + ) + + # 5. 直接执行自动合并(不需要确认) + return await self._execute_merge(request) + + async def confirm_merge(self, confirmation_token: str, requester: str) -> MergeResponse: + """ + 人工确认合并 + """ + confirmation = self._pending_confirmations.get(confirmation_token) + if not confirmation: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认令牌无效或已过期", + ) + + # 检查是否过期 + if datetime.utcnow().timestamp() > confirmation.expires_at: + del self._pending_confirmations[confirmation_token] + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认请求已过期", + ) + + # 执行合并 + result = await self._execute_merge(confirmation.merge_request) + + # 清理确认请求 + if result.success: + del self._pending_confirmations[confirmation_token] + + return result + + async def reject_merge(self, confirmation_token: str, reason: str) -> MergeResponse: + """ + 拒绝合并 + """ + confirmation = self._pending_confirmations.get(confirmation_token) + if not confirmation: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认令牌无效", + ) + + # 清理确认请求 + del self._pending_confirmations[confirmation_token] + + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason=f"合并被拒绝: {reason}", + ) + + async def _execute_merge(self, request: MergeRequest) -> MergeResponse: + """ + 执行实际的合并操作 + """ + try: + # 1. 执行 Git 合并 + merge_sha = await self._perform_git_merge(request.commit_sha) + + # 2. 推送到远程 + await self._push_to_remote() + + # 3. 更新 Plane 任务状态 + if self.plane: + await self._update_plane_status(request.signal_id, merge_sha) + + # 4. 发送飞书通知 + if self.lark: + await self._send_lark_notification(request, merge_sha) + + # 5. 发送成功事件 + await self._publish_merge_success_event(request, merge_sha) + + # 重置失败计数 + self._reset_failure_count() + self._successful_merges += 1 + + return MergeResponse( + status=MergeStatus.APPROVED, + success=True, + merge_sha=merge_sha, + reason="自动合并成功", + details={ + "successful_merges": self._successful_merges, + }, + ) + + except Exception as e: + logger.error(f"Merge failed: {e}", exc_info=True) + self._record_failure() + + await self._publish_merge_failed_event(request, str(e)) + + return MergeResponse( + status=MergeStatus.FAILED, + success=False, + reason=f"合并失败: {str(e)}", + requires_human_confirmation=True, + ) + + async def _perform_git_merge(self, commit_sha: str) -> str: + """执行 Git 合并""" + # 如果有 git 服务,使用它;否则使用 subprocess + if self.git: + return await self.git.merge(commit_sha) + + # 使用 subprocess 执行 git 命令 + import subprocess + + # 合并提交(不创建合并提交,直接快进) + result = subprocess.run( + ["git", "merge", "--ff", commit_sha], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + # 尝试 cherry-pick + result = subprocess.run( + ["git", "cherry-pick", commit_sha], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise Exception(f"Git merge/cherry-pick failed: {result.stderr}") + + # 获取合并后的 HEAD + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + async def _push_to_remote(self): + """推送到远程仓库""" + import subprocess + + result = subprocess.run( + ["git", "push"], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + raise Exception(f"Git push failed: {result.stderr}") + + async def _update_plane_status(self, signal_id: str, merge_sha: str): + """更新 Plane 任务状态""" + if not self.plane: + return + + try: + # 这里应该根据 signal_id 找到对应的 Plane issue + # 简化实现:假设任务 ID 已知 + await self.plane.update_issue_status( + signal_id, # 或提取的任务 ID + status="Done" + ) + except Exception as e: + logger.warning(f"Failed to update Plane status: {e}") + + async def _send_lark_notification(self, request: MergeRequest, merge_sha: str): + """发送飞书通知""" + if not self.lark: + return + + try: + await self.lark.send_merge_notification( + task_name=request.task_name, + merge_sha=merge_sha, + audit_score=request.audit_score, + ) + except Exception as e: + logger.warning(f"Failed to send Lark notification: {e}") + + async def _notify_confirmation_request(self, confirmation: ConfirmationRequest): + """通知确认请求""" + if self.event_bus: + from .events import EventType + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_REQUESTED, + data={ + "token": confirmation.token, + "task_name": confirmation.merge_request.task_name, + "commit_sha": confirmation.merge_request.commit_sha, + "audit_score": confirmation.merge_request.audit_score, + "expires_at": confirmation.expires_at, + }, + ) + ) + + async def _publish_merge_success_event(self, request: MergeRequest, merge_sha: str): + """发布合并成功事件""" + if self.event_bus: + from .events import EventType + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_SUCCESS, + data={ + "signal_id": request.signal_id, + "commit_sha": request.commit_sha, + "merge_sha": merge_sha, + "task_name": request.task_name, + "audit_score": request.audit_score, + }, + ) + ) + + async def _publish_merge_failed_event(self, request: MergeRequest, error: str): + """发布合并失败事件""" + if self.event_bus: + from .events import EventType + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_FAILED, + data={ + "signal_id": request.signal_id, + "commit_sha": request.commit_sha, + "task_name": request.task_name, + "error": error, + }, + ) + ) + + def _is_in_cooldown(self) -> bool: + """检查是否在熔断期""" + if self._last_failure_time is None: + return False + + elapsed = (datetime.utcnow() - self._last_failure_time).total_seconds() + return elapsed < self.FAILURE_COOLDOWN_SECONDS + + def _get_cooldown_remaining(self) -> float: + """获取熔断剩余时间""" + if self._last_failure_time is None: + return 0.0 + + elapsed = (datetime.utcnow() - self._last_failure_time).total_seconds() + return max(0, self.FAILURE_COOLDOWN_SECONDS - elapsed) + + def _all_tests_passed(self, test_results: Dict[str, Any]) -> bool: + """检查所有测试是否通过""" + if not test_results: + return True # 没有测试结果视为通过 + + # 检查是否有失败的测试 + for test_name, result in test_results.items(): + if isinstance(result, dict): + if result.get("status") == "failed": + return False + if result.get("passed") is False: + return False + elif isinstance(result, bool): + if not result: + return False + + return True + + def _get_failed_tests(self, test_results: Dict[str, Any]) -> List[str]: + """获取失败的测试列表""" + failed = [] + for test_name, result in test_results.items(): + if isinstance(result, dict) and result.get("status") == "failed": + failed.append(test_name) + elif isinstance(result, bool) and not result: + failed.append(test_name) + return failed + + def _generate_confirmation_token(self) -> str: + """生成确认令牌""" + import uuid + return str(uuid.uuid4()) + + def _record_failure(self): + """记录失败""" + self._failure_count += 1 + self._last_failure_time = datetime.utcnow() + + # 检查是否需要进入熔断期 + if self._failure_count >= self.MAX_CONSECUTIVE_FAILURES: + logger.warning( + f"[AutoMerge] {self._failure_count} consecutive failures. " + f"Entering cooldown for {self.FAILURE_COOLDOWN_SECONDS}s" + ) + if self.event_bus: + import asyncio + asyncio.create_task( + self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_COOLDOWN, + data={ + "failure_count": self._failure_count, + "cooldown_seconds": self.FAILURE_COOLDOWN_SECONDS, + }, + ) + ) + ) + + def _reset_failure_count(self): + """重置失败计数""" + self._failure_count = 0 + self._last_failure_time = None + + async def get_pending_confirmations(self) -> List[ConfirmationRequest]: + """获取待确认的请求""" + # 清理过期的确认请求 + now = datetime.utcnow().timestamp() + expired = [ + token for token, conf in self._pending_confirmations.items() + if now > conf.expires_at + ] + for token in expired: + del self._pending_confirmations[token] + + return list(self._pending_confirmations.values()) + + def get_status(self) -> Dict[str, Any]: + """获取服务状态""" + return { + "audit_threshold": self.AUDIT_SCORE_THRESHOLD, + "human_confirmation_required": self.HUMAN_CONFIRMATION_REQUIRED, + "failure_count": self._failure_count, + "successful_merges": self._successful_merges, + "in_cooldown": self._is_in_cooldown(), + "cooldown_remaining": self._get_cooldown_remaining() if self._is_in_cooldown() else 0, + "pending_confirmations": len(self._pending_confirmations), + } + + +# 全局服务实例 +_auto_merger = None + + +def get_auto_merger( + git_service=None, + plane_service=None, + lark_service=None, + event_bus=None, +) -> AutoMergeService: + """获取自动合并服务实例""" + global _auto_merger + if _auto_merger is None: + _auto_merger = AutoMergeService( + git_service=git_service, + plane_service=plane_service, + lark_service=lark_service, + event_bus=event_bus, + ) + return _auto_merger diff --git a/backend/app/services/blocker_detection.py b/backend/app/services/blocker_detection.py new file mode 100644 index 00000000..19eda2ca --- /dev/null +++ b/backend/app/services/blocker_detection.py @@ -0,0 +1,423 @@ +""" +Proactive Blocker Detection Service - 主动式阻塞检测服务 + +核心功能: +1. 扫描代码注释中的阻塞关键词 +2. 自动识别常见的开发阻塞模式 +3. AI 生成解决方案建议 +4. 追踪阻塞解决进度 + +支持的阻塞模式: +- "等待 X 接口完成" +- "这个 API 达不到效果" +- "由于 Y 的限制,暂时跳过" +- "TODO: 等待 Z 确定" +""" +import asyncio +import json +import re +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text + +from ..models import ( + User, Project, Task, Signal, + CognitiveConflict, TruthNode +) +from .llm import LLMService + + +# 阻塞模式正则表达式 +BLOCKER_PATTERNS = [ + r'等待.*接口', + r'等待.*API', + r'等待.*确定', + r'等待.*完成', + r'blocked by', + r'blocked due to', + r'can\'t proceed until', + r'暂时跳过', + r'TODO.*等待', + r'FIXME.*等待', + r'HACK.*临时', + r'达不到效果', + r'无法.*因为', + r'pending.*approval', + r'waiting for', + r'blocked on', +] + + +class BlockerDetectionService: + """主动式阻塞检测服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def scan_code_comment( + self, + code_content: str, + file_path: str, + line_number: int, + project_id: int, + user_id: int, + tenant_id: int = 1 + ) -> Optional[Dict[str, Any]]: + """ + 扫描代码注释,检测阻塞 + + Args: + code_content: 代码内容 + file_path: 文件路径 + line_number: 行号 + project_id: 项目ID + user_id: 用户ID + tenant_id: 租户ID + + Returns: + 如果检测到阻塞,返回阻塞信息;否则返回 None + """ + # 检查是否匹配阻塞模式 + detected_pattern = None + for pattern in BLOCKER_PATTERNS: + if re.search(pattern, code_content, re.IGNORECASE): + detected_pattern = pattern + break + + if not detected_pattern: + return None + + # AI 分析阻塞严重程度和生成解决方案 + analysis = await self._analyze_blocker_with_ai( + code_content=code_content, + file_path=file_path, + pattern=detected_pattern, + tenant_id=tenant_id, + ) + + # 创建阻塞记录(复用 CognitiveConflict 模型) + from sqlalchemy import text + next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 + + blocker = CognitiveConflict( + id=next_conflict_id, + truth_node_id=None, # 阻塞可能不关联特定宪章 + conflict_type='code_blocker', + description=f"代码阻塞检测:\n\n文件:{file_path}:{line_number}\n代码:{code_content[:200]}\n\nAI 分析:{analysis.get('problem_analysis', '')}", + involved_user_ids=json.dumps([user_id]), + status='detected', + tenant_id=tenant_id + ) + self.db.add(blocker) + await self.db.commit() + + # 自动生成任务建议 + suggested_tasks = await self._generate_blocker_resolution_tasks( + blocker_id=blocker.id, + analysis=analysis, + project_id=project_id, + tenant_id=tenant_id + ) + + return { + 'blocker_id': blocker.id, + 'severity': analysis.get('severity', 'medium'), + 'problem_analysis': analysis.get('problem_analysis', ''), + 'suggested_solutions': analysis.get('suggested_solutions', []), + 'suggested_tasks': suggested_tasks + } + + async def detect_task_blockers( + self, + tenant_id: int = 1 + ) -> List[Dict[str, Any]]: + """ + 检测所有任务中的阻塞 + + 扫描所有进行中的任务,检测是否存在阻塞 + """ + from sqlalchemy import text + + # 获取所有进行中的任务 + result = await self.db.execute( + text(""" + SELECT t.id, t.title, t.description, t.status, t.project_id + FROM tasks t + WHERE t.status IN ('pending', 'in_progress') + AND t.tenant_id = :tenant_id + AND (t.updated_at < datetime('now', '-3 days') + OR t.updated_at < datetime('now', '-3 days', 'localtime')) + """), + {"tenant_id": tenant_id} + ) + + tasks = result.fetchall() + + detected_blockers = [] + + for task in tasks: + task_id, title, description, status, project_id = task + + # AI 分析任务是否可能被阻塞 + analysis = await self._analyze_task_for_blockers( + task_id=task_id, + title=title, + description=description, + tenant_id=tenant_id, + ) + + if analysis.get('is_blocked', False): + # 创建阻塞记录 + next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 + + blocker = CognitiveConflict( + id=next_conflict_id, + truth_node_id=None, + conflict_type='task_blocker', + description=f"任务阻塞检测:\n\n任务:{title}\nID: {task_id}\n\nAI 分析:{analysis.get('reason', '')}", + involved_user_ids=json.dumps([]), + status='detected', + tenant_id=tenant_id + ) + self.db.add(blocker) + await self.db.commit() + + detected_blockers.append({ + 'blocker_id': blocker.id, + 'task_id': task_id, + 'task_title': title, + 'severity': analysis.get('severity', 'medium'), + 'reason': analysis.get('reason', ''), + 'suggested_actions': analysis.get('suggested_actions', []) + }) + + return detected_blockers + + async def resolve_blocker( + self, + blocker_id: int, + resolution_note: str, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 标记阻塞为已解决 + + Args: + blocker_id: 阻塞ID + resolution_note: 解决方案说明 + tenant_id: 租户ID + """ + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == blocker_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + blocker = result.scalar_one_or_none() + + if not blocker: + raise ValueError(f"阻塞 {blocker_id} 不存在") + + blocker.status = 'resolved' + blocker.resolved_at = datetime.now(timezone.utc) + blocker.description += f"\n\n解决方案:{resolution_note}" + + await self.db.commit() + + return { + 'blocker_id': blocker_id, + 'status': 'resolved', + 'resolved_at': blocker.resolved_at.isoformat() + } + + async def get_active_blockers( + self, + tenant_id: int = 1 + ) -> List[Dict[str, Any]]: + """ + 获取所有活跃的阻塞 + + Returns: + 阻塞列表,包含严重程度、描述等信息 + """ + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.conflict_type.in_(['code_blocker', 'task_blocker']), + CognitiveConflict.status == 'detected' + ) + ) + blockers = result.scalars().all() + + return [ + { + 'id': b.id, + 'type': b.conflict_type, + 'description': b.description, + 'severity': self._extract_severity(b.description), + 'detected_at': b.created_at.isoformat() if hasattr(b, 'created_at') else None, + 'involved_users': json.loads(b.involved_user_ids) if b.involved_user_ids else [] + } + for b in blockers + ] + + async def _analyze_blocker_with_ai( + self, + code_content: str, + file_path: str, + pattern: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析代码阻塞""" + prompt = f"""分析以下代码注释中提到的阻塞问题: + +文件:{file_path} +匹配到的模式:{pattern} +代码内容: +{code_content} + +请分析: +1. 阻塞的根本原因是什么? +2. 严重程度(high/medium/low)? +3. 可能的解决方案有哪些? +4. 是否需要创建新任务来跟踪? + +以 JSON 格式返回: +{{ + "problem_analysis": "问题分析", + "severity": "high/medium/low", + "suggested_solutions": ["方案1", "方案2"], + "requires_new_task": boolean, + "suggested_task_title": "建议的任务标题" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return { + 'problem_analysis': f'检测到可能的阻塞:{pattern}', + 'severity': 'medium', + 'suggested_solutions': ['请团队负责人评估'], + 'requires_new_task': False + } + + async def _analyze_task_for_blockers( + self, + task_id: int, + title: str, + description: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析任务是否被阻塞""" + prompt = f"""分析以下任务是否可能被阻塞: + +任务标题:{title} +任务描述:{description} + +请判断: +1. 任务是否被阻塞? +2. 如果阻塞,原因是什么? +3. 严重程度(high/medium/low)? +4. 建议的下一步行动? + +以 JSON 格式返回: +{{ + "is_blocked": boolean, + "reason": "阻塞原因(如果阻塞)", + "severity": "high/medium/low", + "suggested_actions": ["行动1", "行动2"] +}} + +如果任务没有被阻塞,is_blocked 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return {'is_blocked': False} + + async def _generate_blocker_resolution_tasks( + self, + blocker_id: int, + analysis: Dict[str, Any], + project_id: int, + tenant_id: int + ) -> List[Dict[str, Any]]: + """生成阻塞解决任务""" + if not analysis.get('requires_new_task', False): + return [] + + from sqlalchemy import text + + # 获取下一个任务ID + next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 + + task_title = analysis.get('suggested_task_title', f'解决阻塞 #{blocker_id}') + task_description = f"""自动生成的阻塞解决任务 + +阻塞原因: +{analysis.get('problem_analysis', '')} + +建议的解决方案: +{chr(10).join(analysis.get('suggested_solutions', []))}""" + + task = Task( + id=next_task_id, + truth_node_id=None, # 不关联特定宪章 + project_id=project_id, + assignee_id=None, + title=task_title, + description=task_description, + required_skill_tag_ids=[], + status='pending', + priority='HIGH' if analysis.get('severity') == 'high' else 'MEDIUM', + estimated_hours=4, + actual_hours=0, + quality_score=0, + tenant_id=tenant_id + ) + self.db.add(task) + await self.db.commit() + + return [{ + 'task_id': task.id, + 'title': task.title, + 'priority': task.priority + }] + + def _extract_severity(self, description: str) -> str: + """从描述中提取严重程度""" + desc_lower = description.lower() + if 'high' in desc_lower or '严重' in desc_lower or 'critical' in desc_lower: + return 'high' + elif 'low' in desc_lower or '轻微' in desc_lower: + return 'low' + return 'medium' + + +# 全局服务实例 +def get_blocker_detection_service(db: AsyncSession) -> BlockerDetectionService: + """获取阻塞检测服务实例""" + return BlockerDetectionService(db) diff --git a/backend/app/services/budget_enforcement_service.py b/backend/app/services/budget_enforcement_service.py new file mode 100644 index 00000000..f6bf65ce --- /dev/null +++ b/backend/app/services/budget_enforcement_service.py @@ -0,0 +1,345 @@ +"""预算强制执行服务 + +实现: +- AI 调用前预算检查 +- 超支硬限制执行 +- 预算使用量累计 +- 80% 阈值预警与超支告警 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Optional + +from sqlalchemy import and_, desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Budget, BudgetAlert + + +class BudgetEnforcementService: + """预算强制执行服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def check_budget_before_ai_call( + self, + tenant_id: int, + estimated_cost_usd: float, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + """AI 调用前预算检查""" + budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) + if not budget: + return { + "allowed": True, + "reason": "no_budget_configured", + "budget_checked": False, + } + + now = datetime.utcnow() + await self._ensure_current_period(budget, now) + + current_usage = float(budget.current_usage_usd or 0.0) + budget_amount = float(budget.budget_amount_usd or 0.0) + if budget_amount <= 0: + return { + "allowed": True, + "reason": "invalid_budget_amount", + "budget_checked": True, + } + + if budget.status == "suspended": + return { + "allowed": False, + "reason": "budget_suspended", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + } + + projected_usage = current_usage + max(estimated_cost_usd, 0.0) + usage_percent = (projected_usage / budget_amount) * 100 + + # 阈值预警(80% 默认) + if usage_percent >= float(budget.alert_threshold_percent or 80.0): + await self.generate_budget_alert( + budget=budget, + alert_type="threshold_reached", + usage_usd=projected_usage, + usage_percent=usage_percent, + message=( + f"预算使用率达到 {usage_percent:.2f}% " + f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" + ), + action_taken=None, + ) + + # 超支硬拦截 + if ( + projected_usage > budget_amount + and bool(budget.hard_limit_enabled) + and budget.status != "overridden" + ): + await self.enforce_spending_limit( + budget=budget, + usage_usd=projected_usage, + usage_percent=usage_percent, + ) + return { + "allowed": False, + "reason": "budget_exceeded_hard_limit", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "projected_usage_usd": round(projected_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + "usage_percent": round(usage_percent, 2), + } + + return { + "allowed": True, + "reason": "within_budget", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "projected_usage_usd": round(projected_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + "usage_percent": round(usage_percent, 2), + } + + async def update_budget_usage_after_ai_call( + self, + tenant_id: int, + actual_cost_usd: float, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + """AI 调用成功后累计预算使用量""" + budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) + if not budget: + return {"updated": False, "reason": "no_budget_configured"} + + now = datetime.utcnow() + await self._ensure_current_period(budget, now) + + cost = max(float(actual_cost_usd or 0.0), 0.0) + budget.current_usage_usd = float(budget.current_usage_usd or 0.0) + cost + + budget_amount = float(budget.budget_amount_usd or 0.0) + usage_percent = (float(budget.current_usage_usd) / budget_amount * 100) if budget_amount > 0 else 0.0 + + if usage_percent >= float(budget.alert_threshold_percent or 80.0): + await self.generate_budget_alert( + budget=budget, + alert_type="threshold_reached", + usage_usd=float(budget.current_usage_usd), + usage_percent=usage_percent, + message=( + f"预算使用率达到 {usage_percent:.2f}% " + f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" + ), + action_taken=None, + ) + + if budget_amount > 0 and float(budget.current_usage_usd) > budget_amount and bool(budget.hard_limit_enabled): + await self.enforce_spending_limit( + budget=budget, + usage_usd=float(budget.current_usage_usd), + usage_percent=usage_percent, + ) + + await self.db.commit() + await self.db.refresh(budget) + + return { + "updated": True, + "budget_id": budget.id, + "current_usage_usd": float(budget.current_usage_usd), + "usage_percent": round(usage_percent, 2), + "status": budget.status, + } + + async def enforce_spending_limit( + self, + budget: Budget, + usage_usd: float, + usage_percent: float, + ) -> None: + """执行超支硬性限制:标记预算为 suspended 并记录告警""" + budget.status = "suspended" + + await self.generate_budget_alert( + budget=budget, + alert_type="exceeded", + usage_usd=usage_usd, + usage_percent=usage_percent, + message=f"预算已超支,当前使用率 {usage_percent:.2f}%", + action_taken="mark_exceeded", + ) + await self.generate_budget_alert( + budget=budget, + alert_type="suspended", + usage_usd=usage_usd, + usage_percent=usage_percent, + message="预算超支,已暂停新的 AI 调用", + action_taken="suspend_ai_calls", + ) + await self.db.commit() + + async def generate_budget_alert( + self, + budget: Budget, + alert_type: str, + usage_usd: float, + usage_percent: float, + message: str, + action_taken: Optional[str], + ) -> BudgetAlert: + """生成预算预警(同类 active 预警存在则复用更新)""" + result = await self.db.execute( + select(BudgetAlert).where( + and_( + BudgetAlert.budget_id == budget.id, + BudgetAlert.alert_type == alert_type, + BudgetAlert.status == "active", + ) + ).order_by(desc(BudgetAlert.created_at)) + ) + existing = result.scalar_one_or_none() + if existing: + existing.usage_usd = usage_usd + existing.usage_percent = usage_percent + existing.message = message + existing.action_taken = action_taken + await self.db.commit() + await self.db.refresh(existing) + return existing + + alert = BudgetAlert( + tenant_id=budget.tenant_id, + budget_id=budget.id, + alert_type=alert_type, + usage_usd=usage_usd, + usage_percent=usage_percent, + message=message, + is_read=False, + action_taken=action_taken, + status="active", + ) + self.db.add(alert) + await self.db.commit() + await self.db.refresh(alert) + return alert + + async def override_budget_limit( + self, + budget_id: int, + tenant_id: int, + operator_user_id: int, + note: str = "", + duration_hours: int = 24, + ) -> dict[str, Any]: + """BOSS/ADMIN 超支解锁""" + result = await self.db.execute( + select(Budget).where( + and_( + Budget.id == budget_id, + Budget.tenant_id == tenant_id, + ) + ) + ) + budget = result.scalar_one_or_none() + if not budget: + raise ValueError(f"预算 {budget_id} 不存在") + + now = datetime.utcnow() + expires_at = now + timedelta(hours=max(duration_hours, 1)) + history = dict(budget.override_history or {}) + records = list(history.get("records", [])) + records.append( + { + "operator_user_id": operator_user_id, + "note": note, + "overridden_at": now.isoformat(), + "expires_at": expires_at.isoformat(), + } + ) + history["records"] = records + budget.override_history = history + budget.status = "overridden" + + await self.db.commit() + await self.db.refresh(budget) + return { + "budget_id": budget.id, + "status": budget.status, + "expires_at": expires_at.isoformat(), + "override_count": len(records), + } + + async def _get_active_budget(self, tenant_id: int, project_id: Optional[int]) -> Optional[Budget]: + """优先读取项目预算,否则回退租户预算""" + if project_id is not None: + result = await self.db.execute( + select(Budget).where( + and_( + Budget.tenant_id == tenant_id, + Budget.budget_type == "project", + Budget.project_id == project_id, + ) + ).order_by(desc(Budget.created_at)) + ) + project_budget = result.scalar_one_or_none() + if project_budget: + return project_budget + + result = await self.db.execute( + select(Budget).where( + and_( + Budget.tenant_id == tenant_id, + Budget.budget_type == "tenant", + ) + ).order_by(desc(Budget.created_at)) + ) + return result.scalar_one_or_none() + + async def _ensure_current_period(self, budget: Budget, now: datetime) -> None: + """若预算周期已过,自动切换到新周期并清零使用量""" + start = budget.current_period_start + end = budget.current_period_end + if start is None or end is None or now >= end: + next_start, next_end = self._compute_period_window(now, budget.period) + budget.current_period_start = next_start + budget.current_period_end = next_end + budget.current_usage_usd = 0.0 + # 周期切换后恢复 active,允许重新消费 + budget.status = "active" + await self.db.commit() + + def _compute_period_window(self, now: datetime, period: str) -> tuple[datetime, datetime]: + period = (period or "monthly").lower() + if period == "yearly": + start = datetime(now.year, 1, 1) + end = datetime(now.year + 1, 1, 1) + return start, end + if period == "quarterly": + quarter = (now.month - 1) // 3 + start_month = quarter * 3 + 1 + start = datetime(now.year, start_month, 1) + if start_month == 10: + end = datetime(now.year + 1, 1, 1) + else: + end = datetime(now.year, start_month + 3, 1) + return start, end + + # default monthly + start = datetime(now.year, now.month, 1) + if now.month == 12: + end = datetime(now.year + 1, 1, 1) + else: + end = datetime(now.year, now.month + 1, 1) + return start, end + diff --git a/backend/app/services/codegen.py b/backend/app/services/codegen.py new file mode 100644 index 00000000..eea6bad0 --- /dev/null +++ b/backend/app/services/codegen.py @@ -0,0 +1,552 @@ +""" +Neural Sync - Code Draft Generation Service + +AI 代码草案生成服务 - 激进策略 +AI 尝试生成所有代码(包括复杂逻辑),但提供详细的 review points +""" + +import asyncio +import os +import re +import subprocess +import logging +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field +from datetime import datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class CodeDraftRequest(BaseModel): + """代码草案生成请求""" + signal_id: str + signal_content: str + strategic_analysis: Dict[str, Any] + target_files: List[str] + project_root: str = "." + + +class FileInfo(BaseModel): + """文件信息""" + path: str + language: str + exists: bool + content: Optional[str] = None + lines_count: int = 0 + + +class GeneratedFile(BaseModel): + """生成的文件""" + path: str + content: str + language: str + diff: str + is_new: bool + review_points: List[str] = [] + + +class CodeDraftResponse(BaseModel): + """代码草案生成响应""" + success: bool + files: List[GeneratedFile] + summary: str + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + warnings: List[str] = Field(default_factory=list) + review_points: List[str] = Field(default_factory=list) + estimated_complexity: str = "low" + generation_time: float = 0.0 + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class CodeDraftService: + """ + AI 代码草案生成服务 - 激进策略 + + 特性: + - 支持全栈代码生成 + - 读取现有代码上下文 + - 生成 unified diff + - 提取人工 review 关键点 + - 深度代码验证 + """ + + def __init__(self, llm_service): + self.llm = llm_service + self._language_map = { + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".vue": "vue", + ".sql": "sql", + ".sh": "bash", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + } + + async def generate_draft(self, request: CodeDraftRequest) -> CodeDraftResponse: + """ + 生成代码草案(激进策略) + """ + start_time = datetime.utcnow().timestamp() + + try: + # 1. 读取目标文件 + file_contexts = await self._read_target_files(request) + + # 2. 分析代码库结构 + repo_context = await self._analyze_repo_structure(request) + + # 3. 构建激进策略的生成 prompt + prompt = self._build_aggressive_prompt(request, file_contexts, repo_context) + + # 4. 调用 LLM 生成代码 + llm_response = await self._call_llm_for_code(prompt) + + # 5. 解析和验证生成的代码 + generated_files = await self._parse_and_validate( + llm_response, file_contexts + ) + + # 6. 生成 unified diff + for file in generated_files: + file.diff = self._generate_diff(file_contexts, file) + + # 7. 提取 review points + review_points = await self._extract_review_points( + generated_files, file_contexts, request + ) + + # 8. 计算置信度和复杂度 + confidence = self._calculate_confidence(generated_files, file_contexts) + complexity = self._estimate_complexity(generated_files) + + # 9. 生成摘要 + summary = self._generate_summary(generated_files, request) + + # 10. 收集警告 + warnings = self._collect_warnings(generated_files, file_contexts) + + generation_time = datetime.utcnow().timestamp() - start_time + + return CodeDraftResponse( + success=True, + files=generated_files, + summary=summary, + confidence=confidence, + warnings=warnings, + review_points=review_points, + estimated_complexity=complexity, + generation_time=generation_time, + metadata={ + "signal_id": request.signal_id, + "files_count": len(generated_files), + "new_files": sum(1 for f in generated_files if f.is_new), + "modified_files": sum(1 for f in generated_files if not f.is_new), + }, + ) + + except Exception as e: + logger.error(f"Code generation failed: {e}", exc_info=True) + return CodeDraftResponse( + success=False, + files=[], + summary=f"代码生成失败: {str(e)}", + confidence=0.0, + warnings=[f"生成过程出错: {str(e)}"], + review_points=[], + generation_time=datetime.utcnow().timestamp() - start_time, + ) + + async def _read_target_files( + self, request: CodeDraftRequest + ) -> Dict[str, FileInfo]: + """读取目标文件内容""" + file_contexts = {} + + for file_path in request.target_files: + full_path = os.path.join(request.project_root, file_path) + + info = FileInfo( + path=file_path, + language=self._detect_language(file_path), + exists=os.path.exists(full_path), + ) + + if info.exists: + try: + with open(full_path, "r", encoding="utf-8") as f: + info.content = f.read() + info.lines_count = len(info.content.split("\n")) + except Exception as e: + logger.warning(f"Failed to read {file_path}: {e}") + info.content = None + + file_contexts[file_path] = info + + return file_contexts + + def _detect_language(self, file_path: str) -> str: + """检测文件语言""" + for ext, lang in self._language_map.items(): + if file_path.endswith(ext): + return lang + return "text" + + async def _analyze_repo_structure(self, request: CodeDraftRequest) -> Dict[str, Any]: + """分析代码库结构""" + repo_context = { + "existing_modules": [], + "imports_available": [], + "patterns_used": [], + } + + try: + # 扫描 Python 文件的导入 + py_files = list( + Path(request.project_root).rglob("*.py") + ) + for py_file in py_files[:20]: # 限制扫描数量 + try: + content = py_file.read_text(encoding="utf-8", errors="ignore") + # 提取导入 + imports = re.findall(r"^import (\w+)|^from (\w+) import", content, re.MULTILINE) + for imp in imports: + module = imp[0] or imp[1] + if module and module not in repo_context["imports_available"]: + repo_context["imports_available"].append(module) + except: + pass + + except Exception as e: + logger.warning(f"Repo structure analysis failed: {e}") + + return repo_context + + def _build_aggressive_prompt( + self, + request: CodeDraftRequest, + file_contexts: Dict[str, FileInfo], + repo_context: Dict[str, Any], + ) -> str: + """构建激进的代码生成 prompt""" + prompt = f"""You are a senior full-stack engineer. Generate COMPLETE, PRODUCTION-READY code based on the following requirements. + +## Signal (Requirement) +**Content**: {request.signal_content} + +## Strategic Analysis +```json +{self._format_json(request.strategic_analysis)} +``` + +## Available Imports/Modules +{', '.join(repo_context['imports_available'][:20])} + +## Existing Code Context +""" + + for file_path, info in file_contexts.items(): + prompt += f"\n### {file_path} ({info.language})\n" + if info.exists and info.content: + # 只显示关键部分(导入、类定义、函数签名) + lines = info.content.split("\n") + key_lines = [] + for line in lines: + stripped = line.strip() + # 保留导入、类/函数定义、装饰器 + if any( + stripped.startswith(p) + for p in ["import ", "from ", "class ", "def ", "@", "async def "] + ): + key_lines.append(line) + elif len(key_lines) < 30: # 保留前30行作为上下文 + key_lines.append(line) + + prompt += "```" + info.language + "\n" + prompt += "\n".join(key_lines) + if len(lines) > 30: + prompt += f"\n... ({len(lines) - 30} more lines)\n" + prompt += "```\n" + else: + prompt += "*(New file to be created)*\n" + + prompt += """ +## Code Generation Requirements +1. **Complete Implementation**: Generate FULL, WORKING code including complex business logic +2. **Error Handling**: Proper exception handling and edge cases +3. **Performance**: Consider performance implications +4. **Type Safety**: Use proper type hints +5. **Comments**: Add concise comments for complex logic +6. **Testing**: Consider testability + +## Output Format +Return a JSON response with this EXACT structure: +```json +{ + "files": [ + { + "path": "relative/path/to/file.ext", + "content": "full file content here", + "is_new": true/false, + "review_points": ["list of key points needing human review"] + } + ], + "summary": "Brief description of changes", + "warnings": ["any potential issues or risks"] +} +``` + +IMPORTANT: +- Generate complete, runnable code +- Handle edge cases and errors +- Follow existing code patterns in the project +- Highlight complex logic in review_points +""" + + return prompt + + async def _call_llm_for_code(self, prompt: str) -> Dict[str, Any]: + """调用 LLM 生成代码""" + try: + # 使用 LLM 服务的聊天接口 + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "You are an expert software engineer."}, + {"role": "user", "content": prompt} + ], + temperature=0.3, # 降低温度以获得更稳定的代码 + ) + + # 解析 JSON 响应 + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) + if json_match: + import json + return json.loads(json_match.group(1)) + + # 尝试直接解析 JSON + import json + return json.loads(response) + + except Exception as e: + logger.error(f"LLM call or parsing failed: {e}") + # 返回错误响应 + return { + "files": [], + "summary": f"LLM 生成失败: {str(e)}", + "warnings": [str(e)] + } + + async def _parse_and_validate( + self, llm_response: Dict[str, Any], file_contexts: Dict[str, FileInfo] + ) -> List[GeneratedFile]: + """解析和验证生成的代码""" + generated_files = [] + + for file_data in llm_response.get("files", []): + file_path = file_data.get("path", "") + content = file_data.get("content", "") + review_points = file_data.get("review_points", []) + + file_info = file_contexts.get(file_path) + if not file_info: + file_info = FileInfo( + path=file_path, + language=self._detect_language(file_path), + exists=False, + ) + + # 基础验证 + validation_warnings = self._validate_code_syntax(content, file_info.language) + if validation_warnings: + review_points.extend(validation_warnings) + + generated_files.append( + GeneratedFile( + path=file_path, + content=content, + language=file_info.language, + diff="", # 稍后生成 + is_new=not file_info.exists, + review_points=review_points, + ) + ) + + return generated_files + + def _validate_code_syntax(self, content: str, language: str) -> List[str]: + """验证代码语法""" + warnings = [] + + try: + if language == "python": + import ast + ast.parse(content) + elif language in ["typescript", "javascript"]: + # 简单的括号匹配检查 + if content.count("(") != content.count(")"): + warnings.append("括号不匹配") + if content.count("{") != content.count("}"): + warnings.append("花括号不匹配") + except SyntaxError as e: + warnings.append(f"语法错误: {str(e)}") + + return warnings + + def _generate_diff( + self, file_contexts: Dict[str, FileInfo], file: GeneratedFile + ) -> str: + """生成 unified diff""" + if file.is_new: + return f"--- /dev/null\n+++ {file.path}\n@@ -0,0 +1,{file.content.count(chr(10))} @@\n" + "\n".join( + f"+{line}" for line in file.content.split("\n") + ) + + # 对于修改的文件 + old_content = file_contexts.get(file.path)?.content or "" + old_lines = old_content.split("\n") + new_lines = file.content.split("\n") + + # 简化的 diff 生成(实际应该使用 difflib) + diff_lines = [f"--- {file.path}", f"+++ {file.path}"] + + # 找出差异的行范围 + changed_ranges = self._find_changed_ranges(old_lines, new_lines) + + for start_old, start_new, length in changed_ranges: + diff_lines.append(f"@@ -{start_old + 1},{length} +{start_new + 1},{length} @@") + for i in range(length): + old_line = old_lines[start_old + i] if start_old + i < len(old_lines) else "" + new_line = new_lines[start_new + i] if start_new + i < len(new_lines) else "" + + if old_line == new_line: + diff_lines.append(f" {new_line}") + elif new_line and not old_line: + diff_lines.append(f"+{new_line}") + elif old_line and not new_line: + diff_lines.append(f"-{old_line}") + else: + diff_lines.append(f"-{old_line}") + diff_lines.append(f"+{new_line}") + + return "\n".join(diff_lines) + + def _find_changed_ranges( + self, old_lines: List[str], new_lines: List[str] + ) -> List[tuple]: + """找出变化的代码行范围""" + # 简化实现:假设整个文件是变化的 + return [(0, 0, max(len(old_lines), len(new_lines)))] + + async def _extract_review_points( + self, generated_files: List[GeneratedFile], + file_contexts: Dict[str, FileInfo], + request: CodeDraftRequest, + ) -> List[str]: + """提取需要人工 review 的关键点""" + review_points = [] + + # 收集所有文件的 review points + for file in generated_files: + review_points.extend(file.review_points) + + # 添加通用 review points + review_points.extend([ + "验证生成的代码是否符合项目的编码规范", + "检查是否有安全漏洞(SQL注入、XSS等)", + "确认复杂业务逻辑的正确性", + "验证错误处理是否完整", + "检查是否有性能优化空间", + ]) + + # 根据战略分析添加特定 review points + strategic_tags = request.strategic_analysis.get("decision_proposal", {}).get("tags", []) + if "security" in strategic_tags: + review_points.append("重点关注安全相关代码") + if "performance" in strategic_tags: + review_points.append("验证性能指标是否达标") + + return list(set(review_points)) # 去重 + + def _calculate_confidence( + self, generated_files: List[GeneratedFile], file_contexts: Dict[str, FileInfo] + ) -> float: + """计算生成置信度""" + if not generated_files: + return 0.0 + + confidence = 0.7 # 基础置信度 + + # 根据文件数量调整 + confidence -= min(len(generated_files) * 0.05, 0.2) + + # 根据警告数量调整 + total_warnings = sum(len(f.review_points) for f in generated_files) + confidence -= min(total_warnings * 0.02, 0.3) + + return max(0.0, min(1.0, confidence)) + + def _estimate_complexity(self, generated_files: List[GeneratedFile]) -> str: + """估算代码复杂度""" + total_lines = sum(len(f.content.split("\n")) for f in generated_files) + + if total_lines < 100: + return "low" + elif total_lines < 300: + return "medium" + else: + return "high" + + def _generate_summary( + self, generated_files: List[GeneratedFile], request: CodeDraftRequest + ) -> str: + """生成变更摘要""" + new_files = [f for f in generated_files if f.is_new] + modified_files = [f for f in generated_files if not f.is_new] + + summary_parts = [ + f"为信号 '{request.signal_content[:50]}...' 生成代码草案", + f"新建文件: {len(new_files)} 个", + f"修改文件: {len(modified_files)} 个", + ] + + if modified_files: + summary_parts.append(f"主要变更: {modified_files[0].path}") + + return " | ".join(summary_parts) + + def _collect_warnings( + self, generated_files: List[GeneratedFile], file_contexts: Dict[str, FileInfo] + ) -> List[str]: + """收集警告信息""" + warnings = [] + + for file in generated_files: + if not file.content.strip(): + warnings.append(f"{file.path}: 文件内容为空") + + # 检查是否有 TODO/FIXME + if "TODO" in file.content or "FIXME" in file.content: + warnings.append(f"{file.path}: 包含未完成的标记") + + return warnings + + def _format_json(self, obj: Any) -> str: + """格式化 JSON""" + import json + return json.dumps(obj, ensure_ascii=False, indent=2) + + +# 全局服务实例 +_codegen_service = None + + +def get_codegen_service(llm_service) -> CodeDraftService: + """获取代码生成服务实例""" + global _codegen_service + if _codegen_service is None: + _codegen_service = CodeDraftService(llm_service) + return _codegen_service diff --git a/backend/app/services/cognitive_conflict.py b/backend/app/services/cognitive_conflict.py new file mode 100644 index 00000000..26475a88 --- /dev/null +++ b/backend/app/services/cognitive_conflict.py @@ -0,0 +1,473 @@ +""" +Cognitive Conflict Detection & Resolution Service - 认知冲突检测与解决服务 + +核心功能: +1. 自动检测团队隐性认知差异 +2. 识别定义不一致(如"留存"vs"DAU") +3. 检测目标冲突(产品说X,开发实现Y) +4. 主动推送对齐提醒 +5. 异步 1v1 认知对齐 +""" +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text + +from ..models import ( + User, Project, TruthNode, Task, + CognitiveConflict, ProjectDomain +) +from .llm import LLMService + + +class CognitiveConflictService: + """认知冲突检测与解决服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def detect_charter_conflicts( + self, + project_id: int, + tenant_id: int = 1 + ) -> List[Dict[str, Any]]: + """ + 检测项目宪章中的潜在冲突 + + 扫描: + 1. 宪章中的成功指标定义是否清晰 + 2. 是否有模糊的目标描述 + 3. 是否有相互冲突的约束 + """ + # 获取项目宪章 + result = await self.db.execute( + select(TruthNode).where( + TruthNode.project_id == project_id, + TruthNode.node_type == 'charter', + TruthNode.tenant_id == tenant_id + ) + ) + charter = result.scalar_one_or_none() + + if not charter: + return [] + + # AI 分析宪章内容 + conflicts = await self._analyze_charter_for_conflicts( + charter.content, + tenant_id=tenant_id, + ) + + # 保存检测到的冲突 + for conflict_data in conflicts: + await self._create_conflict_record( + truth_node_id=charter.id, + conflict_type=conflict_data['type'], + description=conflict_data['description'], + severity=conflict_data['severity'], + suggested_resolution=conflict_data['suggested_resolution'], + tenant_id=tenant_id + ) + + return conflicts + + async def detect_task_charter_mismatch( + self, + task: Task, + charter_content: str, + tenant_id: int = 1 + ) -> Optional[Dict[str, Any]]: + """ + 检测任务与宪章的一致性 + + 检查: + 1. 任务描述是否与宪章目标一致 + 2. 技术选型是否符合约束 + 3. 是否偏离项目方向 + """ + prompt = f"""检查以下任务是否符合项目宪章: + +宪章内容: +{charter_content} + +任务信息: +标题:{task.title} +描述:{task.description} +优先级:{task.priority} +预计工时:{task.estimated_hours} 小时 + +请判断: +1. 任务是否与宪章目标一致? +2. 是否有潜在的偏离? +3. 是否存在隐性冲突? + +以 JSON 格式返回: +{{ + "is_aligned": boolean, + "conflict_type": "目标不一致/技术约束冲突/优先级错配", + "description": "冲突描述", + "severity": "high/medium/low", + "suggestion": "建议调整" +}} + +如果任务与宪章一致,is_aligned 设为 true,conflict_type 为 null。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + project_id=task.project_id if getattr(task, "project_id", None) is not None else None, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + result = json.loads(response.strip()) + + if not result.get('is_aligned', True): + # 创建冲突记录 + await self._create_conflict_record( + truth_node_id=task.truth_node_id, + conflict_type=result.get('conflict_type', 'unknown'), + description=result.get('description', ''), + severity=result.get('severity', 'medium'), + suggested_resolution=result.get('suggestion', ''), + related_task_id=task.id, + tenant_id=tenant_id + ) + return result + + return None + + except Exception as e: + print(f"[CognitiveConflict] Task mismatch detection failed: {e}") + return None + + async def detect_user_definition_conflicts( + self, + project_id: int, + tenant_id: int = 1 + ) -> List[Dict[str, Any]]: + """ + 检测用户定义冲突 + + 例如: + - 产品在宪章中写"用户留存提升10%" + - 开发在实现"DAU提升10%" + - 双方都没有争论,但目标不一致 + """ + # 获取项目的所有宪章和任务 + charters_result = await self.db.execute( + select(TruthNode).where( + TruthNode.project_id == project_id, + TruthNode.node_type == 'charter', + TruthNode.tenant_id == tenant_id + ) + ) + charters = charters_result.scalars().all() + + tasks_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id + ) + ) + tasks = tasks_result.scalars().all() + + # 构建所有定义的上下文 + definitions = [] + + for charter in charters: + definitions.append({ + 'source_type': 'charter', + 'source_id': charter.id, + 'content': charter.content + }) + + for task in tasks: + definitions.append({ + 'source_type': 'task', + 'source_id': task.id, + 'content': f"{task.title}: {task.description}" + }) + + if not definitions: + return [] + + # AI 检测定义冲突 + prompt = f"""分析以下项目定义中是否存在隐性认知冲突: + +{json.dumps(definitions, ensure_ascii=False, indent=2)} + +请查找: +1. 相同概念的不同表述(如"留存"vs"DAU") +2. 相矛盾的目标(如"降低成本"vs"增加功能") +3. 不一致的指标定义 + +以 JSON 格式返回: +[ + {{ + "conflict_type": "定义不一致", + "description": "具体描述冲突", + "involved_sources": ["charter:1", "task:2"], + "severity": "high/medium/low", + "suggested_alignment": "建议的对齐方案" + }} +] + +如果没有发现冲突,返回空数组。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + project_id=project_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + conflicts = json.loads(response.strip()) + + # 保存检测到的冲突 + for conflict in conflicts: + await self._create_conflict_record( + truth_node_id=int(conflict['involved_sources'][0].split(':')[1]), + conflict_type=conflict['conflict_type'], + description=conflict['description'], + severity=conflict['severity'], + suggested_resolution=conflict.get('suggested_alignment', ''), + tenant_id=tenant_id + ) + + return conflicts + + except Exception as e: + print(f"[CognitiveConflict] Definition conflict detection failed: {e}") + return [] + + async def propose_alignment_resolution( + self, + conflict_id: int, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 提议认知冲突解决方案 + + 为冲突双方生成对齐方案: + 1. 分析冲突根因 + 2. 提出妥协方案 + 3. 生成可执行的对齐计划 + """ + # 获取冲突记录 + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = result.scalar_one_or_none() + + if not conflict: + raise ValueError(f"Conflict {conflict_id} not found") + + # AI 生成解决方案 + prompt = f"""为以下认知冲突提供解决方案: + +冲突类型:{conflict.conflict_type} +描述:{conflict.description} + +请提供: +1. 冲突根因分析 +2. 双方都能接受的妥协方案 +3. 具体的对齐步骤 + +以 JSON 格式返回: +{{ + "root_cause": "根本原因", + "resolution_plan": ["步骤1", "步骤2"], + "compromise": "妥协方案", + "expected_outcome": "预期结果" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + resolution = json.loads(response.strip()) + + # 更新冲突状态 + conflict.status = 'resolution_proposed' + conflict.description += f"\n\nAI 建议方案:{resolution.get('compromise', '')}" + + await self.db.commit() + + return resolution + + except Exception as e: + print(f"[CognitiveConflict] Resolution proposal failed: {e}") + return {} + + async def trigger_async_alignment( + self, + conflict_id: int, + user1_id: int, + user2_id: int, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 触发异步 1v1 认知对齐 + + 为冲突双方创建独立的对齐任务: + 1. 发送对齐邀请 + 2. 提供背景信息 + 3. 收集双方观点 + 4. AI 生成共识方案 + """ + # 获取用户信息 + result1 = await self.db.execute( + select(User).where(User.id == user1_id) + ) + user1 = result1.scalar_one_or_none() + + result2 = await self.db.execute( + select(User).where(User.id == user2_id) + ) + user2 = result2.scalar_one_or_none() + + if not user1 or not user2: + raise ValueError("Users not found") + + # 获取冲突详情 + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = result.scalar_one_or_none() + + # TODO: 实际实现需要: + # 1. 发送飞书消息/邮件通知双方 + # 2. 创建对齐任务 + # 3. 记录对齐过程 + + return { + 'alignment_session_id': f"align_{conflict_id}", + 'user1': user1.username, + 'user2': user2.username, + 'conflict': conflict.conflict_type, + 'status': 'initiated' + } + + async def _create_conflict_record( + self, + truth_node_id: int, + conflict_type: str, + description: str, + severity: str, + suggested_resolution: str = "", + related_task_id: Optional[int] = None, + tenant_id: int = 1 + ): + """创建认知冲突记录""" + from sqlalchemy import text + next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 + + conflict = CognitiveConflict( + id=next_conflict_id, + truth_node_id=truth_node_id, + conflict_type=conflict_type, + description=description, + involved_user_ids=json.dumps([]), + status='detected', + tenant_id=tenant_id + ) + self.db.add(conflict) + await self.db.commit() + + async def _analyze_charter_for_conflicts( + self, + charter_content: str, + tenant_id: int, + ) -> List[Dict[str, Any]]: + """AI 分析宪章内容中的冲突""" + prompt = f"""分析以下项目宪章中是否存在潜在冲突或问题: + +{charter_content} + +请检查: +1. 目标是否模糊或相互矛盾? +2. 约束条件是否现实? +3. 成功指标是否可衡量? +4. 是否遗漏重要方面? + +以 JSON 格式返回: +[ + {{ + "type": "目标模糊/约束冲突/指标不可测量", + "description": "具体问题描述", + "severity": "high/medium/low", + "suggested_resolution": "建议的改进方案" + }} +""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return [] + + async def get_detected_conflicts( + self, + tenant_id: int = 1 + ) -> List[Dict[str, Any]]: + """获取所有检测到的冲突""" + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == 'detected' + ) + ) + conflicts = result.scalars().all() + + return [ + { + 'id': str(c.id), + 'type': c.conflict_type, + 'description': c.description, + 'severity': 'medium' if 'high' not in c.description.lower() else 'high', + 'truth_node_id': str(c.truth_node_id) + } + for c in conflicts + ] + + +# 全局服务实例 +def get_cognitive_conflict_service(db: AsyncSession) -> CognitiveConflictService: + """获取认知冲突服务实例""" + return CognitiveConflictService(db) diff --git a/backend/app/services/context_cache.py b/backend/app/services/context_cache.py new file mode 100644 index 00000000..ead979b0 --- /dev/null +++ b/backend/app/services/context_cache.py @@ -0,0 +1,1155 @@ +"""AI Steward 上下文缓存服务(Redis)与失效钩子。""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, case, event, func, inspect, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from ..models import Project, ProjectSignalLink, Signal, Task, User, UserProject, UserWorkload + +try: + import redis.asyncio as redis_async + from redis import Redis as RedisSync + + REDIS_AVAILABLE = True +except Exception: # pragma: no cover + REDIS_AVAILABLE = False + redis_async = None + RedisSync = None + + +SYSTEM_TTL_SECONDS = 30 * 60 +USER_TTL_SECONDS = 5 * 60 +SESSION_TTL_SECONDS = 60 * 60 +PROJECT_LIST_TTL_SECONDS = 10 * 60 +SIGNAL_PENDING_TTL_SECONDS = 5 * 60 +SESSION_MAX_MESSAGES = 20 +DONE_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} +PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理"} + + +def _safe_int(value: Any, fallback: int = 0) -> int: + try: + return int(value) + except Exception: + return fallback + + +def _json_loads(raw: Optional[str], default: Any) -> Any: + if not raw: + return default + try: + return json.loads(raw) + except Exception: + return default + + +class ContextCacheService: + def __init__(self, redis_url: Optional[str] = None) -> None: + self.redis_url = redis_url or os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + + @staticmethod + def _system_key(tenant_id: int) -> str: + return f"pmflow:context:system:{tenant_id}" + + @staticmethod + def _user_key(tenant_id: int, user_id: int) -> str: + return f"pmflow:context:user:{tenant_id}:{user_id}" + + @staticmethod + def _session_key(tenant_id: int, session_id: int) -> str: + return f"pmflow:qa:session:{tenant_id}:{session_id}" + + @staticmethod + def _project_list_key(tenant_id: int) -> str: + return f"pmflow:project:list:{tenant_id}" + + @staticmethod + def _signal_pending_key(tenant_id: int) -> str: + return f"pmflow:signal:pending_count:{tenant_id}" + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis_async.from_url(self.redis_url, decode_responses=True) + return self._redis + + async def _get_json(self, key: str) -> Optional[dict[str, Any]]: + redis = await self._get_redis() + if redis is None: + return None + try: + raw = await redis.get(key) + except Exception: + return None + if not raw: + return None + payload = _json_loads(raw, None) + return payload if isinstance(payload, dict) else None + + async def _set_json(self, key: str, value: dict[str, Any], ttl: int) -> None: + redis = await self._get_redis() + if redis is None: + return + try: + await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) + except Exception: + pass + + @staticmethod + def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool: + if not isinstance(payload, dict): + return False + return isinstance(payload.get("assistant_profile"), dict) and isinstance(payload.get("platform_profile"), dict) + + async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + tid = _safe_int(tenant_id, 1) + key = self._system_key(tid) + cached = await self._get_json(key) + if self._is_system_snapshot_complete(cached): + return cached + + payload = await self._build_system_snapshot(db=db, tenant_id=tid) + await self._set_json(key, payload, SYSTEM_TTL_SECONDS) + return payload + + async def get_user_snapshot( + self, + *, + db: AsyncSession, + tenant_id: int, + user_id: Optional[int], + ) -> dict[str, Any]: + if user_id is None: + return {} + + tid = _safe_int(tenant_id, 1) + uid = _safe_int(user_id, 0) + if uid <= 0: + return {} + + key = self._user_key(tid, uid) + cached = await self._get_json(key) + if cached is not None: + return cached + + payload = await self._build_user_snapshot(db=db, tenant_id=tid, user_id=uid) + await self._set_json(key, payload, USER_TTL_SECONDS) + return payload + + async def _build_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + projects = await self._get_project_list_cached(db=db, tenant_id=tenant_id) + team = await self._build_team_members_snapshot(db=db, tenant_id=tenant_id) + pending = await self._get_pending_signal_cached(db=db, tenant_id=tenant_id) + assistant_profile = self._build_assistant_profile() + platform_profile = self._build_platform_profile() + return { + "tenant_id": tenant_id, + "assistant_profile": assistant_profile, + "platform_profile": platform_profile, + "projects": projects.get("items", []), + "team_members": team, + "pending_signals": pending, + "snapshot_at": datetime.now(timezone.utc).isoformat(), + } + + @staticmethod + def _build_assistant_profile() -> dict[str, Any]: + return { + "name": "PMFlow AI 管家", + "identity": "PMFlow 内置的项目管理与技术协同助手", + "responsibilities": [ + "识别需求并路由到项目/信号", + "结合结构化数据与知识库回答问题", + "辅助任务拆解、分配与优先级判断", + "输出基于证据的系统状态与风险提示", + ], + "principles": [ + "只基于可验证上下文回答", + "证据不足时明确说明不足字段", + "优先使用系统快照中的实时结构化数据", + ], + } + + @staticmethod + def _count_python_files(target: Path) -> int: + if not target.exists() or not target.is_dir(): + return 0 + return sum(1 for p in target.rglob("*.py") if p.is_file()) + + @staticmethod + def _estimate_api_routes(api_dir: Path) -> int: + if not api_dir.exists() or not api_dir.is_dir(): + return 0 + markers = ( + "@router.get(", + "@router.post(", + "@router.put(", + "@router.patch(", + "@router.delete(", + "@router.websocket(", + ) + route_count = 0 + for py_file in api_dir.rglob("*.py"): + if not py_file.is_file() or py_file.name.startswith("__"): + continue + try: + text = py_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + for marker in markers: + route_count += text.count(marker) + return route_count + + @staticmethod + def _detect_database_stack() -> str: + url = str(os.getenv("DATABASE_URL") or "").lower() + if "postgresql" in url: + return "PostgreSQL (pgvector-ready)" + if "mysql" in url: + return "MySQL" + if "sqlite" in url: + return "SQLite" + return "Unknown" + + @staticmethod + def _default_feature_catalog_items() -> list[dict[str, Any]]: + return [ + { + "route": "/workloads", + "name": "团队负载", + "summary": "查看团队成员负载、瓶颈与调配建议。", + "page_component": "WorkloadsPage", + "page_file": "frontend/src/pages/WorkloadsPage.tsx", + "apis": ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], + "aliases": ["workloads", "负载", "工作量", "capacity", "团队负载"], + }, + { + "route": "/projects", + "name": "项目列表", + "summary": "管理项目清单、状态、入口跳转与总览信息。", + "page_component": "ProjectsPage", + "page_file": "frontend/src/pages/ProjectsPage.tsx", + "apis": ["/projects/", "/projects/?summary=1"], + "aliases": ["projects", "项目", "项目列表", "project list"], + }, + { + "route": "/ai-steward", + "name": "AI 管家", + "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", + "page_component": "AIStewardPage", + "page_file": "frontend/src/pages/AIStewardPage.tsx", + "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], + }, + { + "route": "/ai-config", + "name": "智能配置", + "summary": "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", + "page_component": "AIConfigPage", + "page_file": "frontend/src/pages/AIConfigPage.tsx", + "apis": [ + "/ai-steward/config/suggest", + "/feishu/config", + "/wecom/config", + "/rag/knowledge-base/docs", + "/rag/knowledge-base/upload", + "/rag/knowledge-base/urls", + ], + "aliases": [ + "智能配置", + "ai配置", + "配置中心", + "integration", + "集成配置", + "knowledge base", + ], + }, + { + "route": "/skills", + "name": "能力管理", + "summary": "管理能力标签、人员能力画像、任务匹配与推荐。", + "page_component": "SkillsPage", + "page_file": "frontend/src/pages/SkillsPage.tsx", + "apis": ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], + "aliases": ["skills", "能力", "技能", "ability", "能力管理"], + }, + { + "route": "/users", + "name": "用户管理", + "summary": "管理用户档案、角色、状态与权限入口。", + "page_component": "UsersPage", + "page_file": "frontend/src/pages/UsersPage.tsx", + "apis": ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], + "aliases": ["users", "成员管理", "用户", "人员管理"], + }, + ] + + @staticmethod + def _extract_router_import_map(router_text: str) -> dict[str, str]: + mapping: dict[str, str] = {} + for component, page_rel in re.findall( + r'import\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s+"@/pages/([^"]+)";', + str(router_text or ""), + ): + rel = str(page_rel).strip() + if not rel: + continue + if not rel.endswith(".tsx") and not rel.endswith(".ts"): + rel = f"{rel}.tsx" + mapping[str(component).strip()] = f"frontend/src/pages/{rel}" + return mapping + + @staticmethod + def _extract_router_entries(router_text: str) -> list[tuple[str, str]]: + entries: list[tuple[str, str]] = [] + for route, component in re.findall( + r'\{\s*path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)\s*/?>\s*\}', + str(router_text or ""), + ): + clean_route = str(route or "").strip() + if not clean_route: + continue + if not clean_route.startswith("/"): + clean_route = f"/{clean_route}" + entries.append((clean_route, str(component).strip())) + return entries + + @staticmethod + def _extract_sidebar_labels(sidebar_text: str) -> dict[str, str]: + labels: dict[str, str] = {} + for route, label in re.findall( + r'\{\s*to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', + str(sidebar_text or ""), + ): + clean_route = str(route or "").strip() + if clean_route and not clean_route.startswith("/"): + clean_route = f"/{clean_route}" + if clean_route: + labels[clean_route] = str(label or "").strip() + return labels + + @staticmethod + def _route_feature_summary(route: str) -> tuple[str, list[str], list[str]]: + route_map: dict[str, tuple[str, list[str], list[str]]] = { + "/workloads": ( + "查看团队成员负载、瓶颈与调配建议。", + ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], + ["workloads", "负载", "工作量", "capacity"], + ), + "/projects": ( + "管理项目清单、状态、入口跳转与总览信息。", + ["/projects/", "/projects/?summary=1"], + ["projects", "项目", "项目列表", "project list"], + ), + "/ai-steward": ( + "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", + ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + ["ai steward", "ai管家", "问答", "rag", "智能问答"], + ), + "/ai-config": ( + "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", + [ + "/ai-steward/config/suggest", + "/feishu/config", + "/wecom/config", + "/rag/knowledge-base/docs", + "/rag/knowledge-base/upload", + "/rag/knowledge-base/urls", + ], + ["智能配置", "ai配置", "配置中心", "integration", "集成配置", "knowledge base"], + ), + "/skills": ( + "管理能力标签、人员能力画像、任务匹配与推荐。", + ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], + ["skills", "能力", "技能", "ability", "能力管理"], + ), + "/users": ( + "管理用户档案、角色、状态与权限入口。", + ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], + ["users", "成员管理", "用户", "人员管理"], + ), + } + if route in route_map: + return route_map[route] + return ( + "系统功能页面入口。", + [], + [route.strip("/"), route.replace("/", "_").strip("_")], + ) + + @staticmethod + def _normalize_aliases(*groups: list[str]) -> list[str]: + merged: list[str] = [] + for group in groups: + for item in group: + alias = str(item or "").strip().lower() + if not alias: + continue + if alias not in merged: + merged.append(alias) + return merged + + def _build_feature_catalog( + self, + *, + backend_root: Path, + ) -> dict[str, Any]: + workspace_root = backend_root.parent + frontend_src = workspace_root / "frontend" / "src" + router_file = frontend_src / "router" / "index.tsx" + sidebar_file = frontend_src / "components" / "layout" / "Sidebar.tsx" + + if not frontend_src.exists(): + fallback_items = self._default_feature_catalog_items() + return { + "items": fallback_items, + "count": len(fallback_items), + "source": "fallback", + } + + router_text = "" + sidebar_text = "" + try: + router_text = router_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + router_text = "" + try: + sidebar_text = sidebar_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + sidebar_text = "" + + import_map = self._extract_router_import_map(router_text) + router_entries = self._extract_router_entries(router_text) + sidebar_labels = self._extract_sidebar_labels(sidebar_text) + + merged: dict[str, dict[str, Any]] = {} + for route, component in router_entries: + page_file = import_map.get(component) or "" + default_name = component.replace("Page", "") + name = sidebar_labels.get(route) or default_name + summary, apis, aliases = self._route_feature_summary(route) + route_aliases = [route.strip("/"), route.replace("/", "_").strip("_"), component] + merged[route] = { + "route": route, + "name": name, + "summary": summary, + "page_component": component, + "page_file": page_file, + "apis": apis, + "aliases": self._normalize_aliases(aliases, route_aliases, [name]), + } + + for route, label in sidebar_labels.items(): + if route not in merged: + summary, apis, aliases = self._route_feature_summary(route) + merged[route] = { + "route": route, + "name": label, + "summary": summary, + "page_component": "", + "page_file": "", + "apis": apis, + "aliases": self._normalize_aliases(aliases, [route.strip("/"), label]), + } + else: + merged[route]["name"] = label + merged[route]["aliases"] = self._normalize_aliases( + list(merged[route].get("aliases") or []), + [label], + ) + + items = sorted(merged.values(), key=lambda x: str(x.get("route") or "")) + if not items: + items = self._default_feature_catalog_items() + source = "fallback" + else: + source = "frontend_scan" + return { + "items": items, + "count": len(items), + "source": source, + } + + def _build_platform_profile(self) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + workspace_root = backend_root.parent + app_root = backend_root / "app" + api_dir = app_root / "api" + services_dir = app_root / "services" + models_dir = app_root / "models" + schemas_dir = app_root / "schemas" + dependencies_dir = app_root / "dependencies" + core_dir = app_root / "core" + config_dir = app_root / "config" + + module_counts = { + "api": self._count_python_files(api_dir), + "services": self._count_python_files(services_dir), + "models": self._count_python_files(models_dir), + "schemas": self._count_python_files(schemas_dir), + "dependencies": self._count_python_files(dependencies_dir), + "core": self._count_python_files(core_dir), + "config": self._count_python_files(config_dir), + } + app_python_files = self._count_python_files(app_root) + route_count = self._estimate_api_routes(api_dir) + running_in_docker = Path("/.dockerenv").exists() + redis_enabled = bool(str(os.getenv("REDIS_URL") or "").strip()) + feature_catalog = self._build_feature_catalog(backend_root=backend_root) + frontend_src = workspace_root / "frontend" / "src" + frontend_ts_files = 0 + if frontend_src.exists(): + frontend_ts_files = sum( + 1 + for p in frontend_src.rglob("*") + if p.is_file() and p.suffix.lower() in {".ts", ".tsx", ".js", ".jsx"} + ) + + return { + "runtime": "docker" if running_in_docker else "local", + "backend": { + "framework": "FastAPI", + "orm": "SQLAlchemy 2.0", + "python": "3.11+", + "module_counts": module_counts, + "api_route_count_estimate": route_count, + }, + "infrastructure": { + "database": self._detect_database_stack(), + "cache": "Redis" if redis_enabled else "Disabled/Unknown", + }, + "frontend": { + "framework": "React 18 + TypeScript + Vite", + "source_root": str(frontend_src), + "route_count": int(feature_catalog.get("count") or 0), + "source_file_count": int(frontend_ts_files), + }, + "feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [], + "source_scan": { + "backend_root": str(backend_root), + "app_python_files": app_python_files, + "feature_catalog_source": str(feature_catalog.get("source") or "unknown"), + "scanned_at": datetime.now(timezone.utc).isoformat(), + }, + } + + async def _get_project_list_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + key = self._project_list_key(tenant_id) + cached = await self._get_json(key) + if cached is not None: + return cached + + project_rows = ( + await db.execute( + select( + Project.id, + Project.name, + Project.title, + Project.status, + Project.owner_id, + User.name.label("owner_name"), + ) + .outerjoin( + User, + and_( + User.tenant_id == tenant_id, + User.id == Project.owner_id, + ), + ) + .where(Project.tenant_id == tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + ) + ).all() + + project_ids = [int(row.id) for row in project_rows] + signal_count_map: dict[int, int] = {} + if project_ids: + signal_rows = ( + await db.execute( + select(ProjectSignalLink.project_id, func.count(ProjectSignalLink.id)) + .where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id.in_(project_ids), + ) + ) + .group_by(ProjectSignalLink.project_id) + ) + ).all() + signal_count_map = {int(pid): int(cnt or 0) for pid, cnt in signal_rows} + + task_count_map: dict[int, int] = {} + task_done_map: dict[int, int] = {} + if project_ids: + done_case = case((func.lower(Task.status).in_(DONE_TASK_STATUSES), 1), else_=0) + task_rows = ( + await db.execute( + select( + Task.project_id, + func.count(Task.id).label("task_total"), + func.sum(done_case).label("done_count"), + ) + .where( + and_( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + ) + ) + .group_by(Task.project_id) + ) + ).all() + task_count_map = {int(pid): int(total or 0) for pid, total, _ in task_rows} + task_done_map = {int(pid): int(done or 0) for pid, _, done in task_rows} + + items: list[dict[str, Any]] = [] + for row in project_rows: + pid = int(row.id) + task_total = int(task_count_map.get(pid, 0)) + done_total = int(task_done_map.get(pid, 0)) + completion_rate = round((done_total / task_total) * 100, 2) if task_total > 0 else 0.0 + title = str(row.title or row.name or f"Project#{pid}") + items.append( + { + "id": pid, + "name": title, + "status": str(row.status or "unknown"), + "current_stage": str(row.status or "unknown"), + "owner_id": _safe_int(row.owner_id, 0) or None, + "owner_name": str(row.owner_name or "未知"), + "signal_count": int(signal_count_map.get(pid, 0)), + "task_count": task_total, + "completion_rate": completion_rate, + } + ) + + payload = { + "items": items, + "count": len(items), + "snapshot_at": datetime.now(timezone.utc).isoformat(), + } + await self._set_json(key, payload, PROJECT_LIST_TTL_SECONDS) + return payload + + async def _build_team_members_snapshot(self, *, db: AsyncSession, tenant_id: int) -> list[dict[str, Any]]: + users = ( + await db.execute( + select(User.id, User.name, User.role, User.workload).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + ) + ) + ) + ).all() + if not users: + return [] + + user_ids = [int(u.id) for u in users] + workload_rows = ( + await db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).all() + workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} + + project_rows = ( + await db.execute( + select(UserProject.user_id, UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id.in_(user_ids), + ) + ) + ) + ).all() + projects_map: dict[int, list[int]] = {} + for uid, pid in project_rows: + projects_map.setdefault(int(uid), []).append(int(pid)) + + result: list[dict[str, Any]] = [] + for row in users: + uid = int(row.id) + ratio = workload_map.get(uid) + if ratio is None: + ratio = round(float(row.workload or 0) / 100.0, 2) + result.append( + { + "id": uid, + "name": str(row.name or f"User#{uid}"), + "role": str(row.role or "unknown"), + "workload_ratio": float(ratio), + "assigned_projects": projects_map.get(uid, []), + } + ) + return result + + async def _get_pending_signal_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + key = self._signal_pending_key(tenant_id) + cached = await self._get_json(key) + if cached is not None: + return cached + + pending_filter = or_( + Signal.processed.is_(False), + func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), + ) + p0_case = case( + ( + func.lower(Signal.priority).in_({"critical", "p0", "urgent", "highest"}), + 1, + ), + else_=0, + ) + p1_case = case((func.lower(Signal.priority).in_({"high", "p1"}), 1), else_=0) + p2_case = case((func.lower(Signal.priority).in_({"medium", "p2", "normal"}), 1), else_=0) + + row = ( + await db.execute( + select( + func.count(Signal.id), + func.sum(p0_case), + func.sum(p1_case), + func.sum(p2_case), + ).where( + and_( + Signal.tenant_id == tenant_id, + pending_filter, + ) + ) + ) + ).first() + total, p0_count, p1_count, p2_count = row or (0, 0, 0, 0) + + payload = { + "total": int(total or 0), + "p0_count": int(p0_count or 0), + "p1_count": int(p1_count or 0), + "p2_count": int(p2_count or 0), + "snapshot_at": datetime.now(timezone.utc).isoformat(), + } + await self._set_json(key, payload, SIGNAL_PENDING_TTL_SECONDS) + return payload + + async def _build_user_snapshot( + self, + *, + db: AsyncSession, + tenant_id: int, + user_id: int, + ) -> dict[str, Any]: + owned_rows = ( + await db.execute( + select(Project.id).where( + and_( + Project.tenant_id == tenant_id, + Project.owner_id == user_id, + ) + ) + ) + ).scalars().all() + member_rows = ( + await db.execute( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + ).scalars().all() + project_ids = sorted({_safe_int(pid, 0) for pid in [*owned_rows, *member_rows] if _safe_int(pid, 0) > 0}) + + project_rows: list[Any] = [] + if project_ids: + project_rows = ( + await db.execute( + select(Project.id, Project.name, Project.title, Project.status).where( + and_( + Project.tenant_id == tenant_id, + Project.id.in_(project_ids), + ) + ) + ) + ).all() + + open_tasks = ( + await db.execute( + select(Task.id, Task.project_id, Task.title, Task.status, Task.due_date).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(DONE_TASK_STATUSES), + ) + ) + ) + ).all() + + pending_signals: list[dict[str, Any]] = [] + if project_ids: + pending_rows = ( + await db.execute( + select(Signal.id, Signal.title, Signal.priority, Signal.status, Signal.matched_project_id).where( + and_( + Signal.tenant_id == tenant_id, + Signal.matched_project_id.in_(project_ids), + or_( + Signal.processed.is_(False), + func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), + ), + ) + ).limit(60) + ) + ).all() + pending_signals = [ + { + "id": int(row.id), + "title": str(row.title or ""), + "priority": str(row.priority or ""), + "status": str(row.status or ""), + "project_id": _safe_int(row.matched_project_id, 0) or None, + } + for row in pending_rows + ] + + workload_row = ( + await db.execute( + select(UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if workload_row is None: + fallback = ( + await db.execute( + select(User.workload).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + workload_ratio = round(float(fallback or 0) / 100.0, 2) + else: + workload_ratio = float(workload_row or 0.0) + + return { + "user_id": user_id, + "projects": [ + { + "id": int(row.id), + "name": str(row.title or row.name or f"Project#{int(row.id)}"), + "status": str(row.status or "unknown"), + } + for row in project_rows + ], + "assigned_tasks": [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "due_date": row.due_date.isoformat() if row.due_date else None, + } + for row in open_tasks + ], + "pending_signals": pending_signals, + "workload_ratio": workload_ratio, + "snapshot_at": datetime.now(timezone.utc).isoformat(), + } + + async def get_session_messages(self, *, tenant_id: int, session_id: Optional[int]) -> list[dict[str, str]]: + if session_id is None: + return [] + sid = _safe_int(session_id, 0) + if sid <= 0: + return [] + + redis = await self._get_redis() + if redis is None: + return [] + key = self._session_key(_safe_int(tenant_id, 1), sid) + try: + raw_rows = await redis.lrange(key, -SESSION_MAX_MESSAGES, -1) + except Exception: + return [] + messages: list[dict[str, str]] = [] + for raw in raw_rows: + row = _json_loads(raw, None) + if not isinstance(row, dict): + continue + role = str(row.get("role") or "").strip().lower() + content = str(row.get("content") or "").strip() + if role in {"user", "assistant"} and content: + messages.append({"role": role, "content": content}) + return messages + + async def append_session_messages( + self, + *, + tenant_id: int, + session_id: Optional[int], + question: str, + answer: str, + ) -> None: + if session_id is None: + return + sid = _safe_int(session_id, 0) + if sid <= 0: + return + + redis = await self._get_redis() + if redis is None: + return + + key = self._session_key(_safe_int(tenant_id, 1), sid) + user_payload = json.dumps({"role": "user", "content": str(question or "")[:3000]}, ensure_ascii=False) + assistant_payload = json.dumps({"role": "assistant", "content": str(answer or "")[:4000]}, ensure_ascii=False) + try: + pipe = redis.pipeline() + pipe.rpush(key, user_payload, assistant_payload) + pipe.ltrim(key, -SESSION_MAX_MESSAGES, -1) + pipe.expire(key, SESSION_TTL_SECONDS) + await pipe.execute() + except Exception: + return + + async def invalidate_system_context(self, *, tenant_id: int) -> None: + redis = await self._get_redis() + if redis is None: + return + tid = _safe_int(tenant_id, 1) + keys = [ + self._system_key(tid), + self._project_list_key(tid), + self._signal_pending_key(tid), + ] + try: + await redis.delete(*keys) + except Exception: + pass + + async def invalidate_user_context(self, *, tenant_id: int, user_id: int) -> None: + redis = await self._get_redis() + if redis is None: + return + uid = _safe_int(user_id, 0) + if uid <= 0: + return + try: + await redis.delete(self._user_key(_safe_int(tenant_id, 1), uid)) + except Exception: + pass + + @staticmethod + def messages_to_turns(messages: list[dict[str, str]]) -> list[dict[str, str]]: + turns: list[dict[str, str]] = [] + pending_question: Optional[str] = None + for row in messages: + role = str(row.get("role") or "").lower() + content = str(row.get("content") or "") + if role == "user": + if pending_question: + turns.append({"question": pending_question, "answer": ""}) + pending_question = content + continue + if role == "assistant": + if pending_question is None: + continue + turns.append({"question": pending_question, "answer": content}) + pending_question = None + if pending_question: + turns.append({"question": pending_question, "answer": ""}) + return turns + + +class ContextCacheInvalidator: + def __init__(self, redis_url: Optional[str] = None) -> None: + self.redis_url = redis_url or os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + + def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url or RedisSync is None: + return None + if self._redis is None: + self._redis = RedisSync.from_url( + self.redis_url, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + return self._redis + + @staticmethod + def _system_key(tenant_id: int) -> str: + return ContextCacheService._system_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _project_list_key(tenant_id: int) -> str: + return ContextCacheService._project_list_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _signal_pending_key(tenant_id: int) -> str: + return ContextCacheService._signal_pending_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _user_key(tenant_id: int, user_id: int) -> str: + return ContextCacheService._user_key(_safe_int(tenant_id, 1), _safe_int(user_id, 0)) + + def invalidate( + self, + *, + tenant_id: int, + invalidate_system: bool, + invalidate_project_list: bool, + invalidate_signal_pending: bool, + user_ids: Optional[set[int]] = None, + ) -> None: + redis = self._get_redis() + if redis is None: + return + + tid = _safe_int(tenant_id, 1) + keys: list[str] = [] + if invalidate_system: + keys.append(self._system_key(tid)) + if invalidate_project_list: + keys.append(self._project_list_key(tid)) + if invalidate_signal_pending: + keys.append(self._signal_pending_key(tid)) + if user_ids: + for uid in user_ids: + if _safe_int(uid, 0) > 0: + keys.append(self._user_key(tid, uid)) + if not keys: + return + try: + redis.delete(*keys) + except Exception: + return + + +def register_context_cache_invalidation_hooks() -> None: + if getattr(register_context_cache_invalidation_hooks, "_registered", False): + return + + invalidator = ContextCacheInvalidator() + + @event.listens_for(Session, "after_flush") + def _collect_cache_invalidation(session: Session, _flush_context) -> None: # pragma: no cover + payload = session.info.get("_pmflow_cache_invalidation") + if not isinstance(payload, dict): + payload = { + "tenant_ids": set(), + "invalidate_system": False, + "invalidate_project_list": False, + "invalidate_signal_pending": False, + "user_ids": set(), + } + session.info["_pmflow_cache_invalidation"] = payload + + objects = set(session.new).union(session.dirty).union(session.deleted) + for obj in objects: + tenant_id = _safe_int(getattr(obj, "tenant_id", 0), 0) + if tenant_id > 0: + payload["tenant_ids"].add(tenant_id) + + if isinstance(obj, Project): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + continue + + if isinstance(obj, ProjectSignalLink): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + continue + + if isinstance(obj, Signal): + payload["invalidate_system"] = True + payload["invalidate_signal_pending"] = True + continue + + if isinstance(obj, Task): + payload["invalidate_system"] = True + state = inspect(obj) + assignee_hist = state.attrs.assignee_id.history if hasattr(state.attrs, "assignee_id") else None + if assignee_hist is not None: + for old_value in assignee_hist.deleted: + uid = _safe_int(old_value, 0) + if uid > 0: + payload["user_ids"].add(uid) + for new_value in assignee_hist.added: + uid = _safe_int(new_value, 0) + if uid > 0: + payload["user_ids"].add(uid) + uid = _safe_int(getattr(obj, "assignee_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, UserProject): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + uid = _safe_int(getattr(obj, "user_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, User): + payload["invalidate_system"] = True + uid = _safe_int(getattr(obj, "id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, UserWorkload): + payload["invalidate_system"] = True + uid = _safe_int(getattr(obj, "user_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + @event.listens_for(Session, "after_commit") + def _flush_cache_invalidation(session: Session) -> None: # pragma: no cover + payload = session.info.pop("_pmflow_cache_invalidation", None) + if not isinstance(payload, dict): + return + tenant_ids = payload.get("tenant_ids") or set() + if not tenant_ids: + tenant_ids = {_safe_int(os.getenv("DEFAULT_TENANT_ID", "1"), 1)} + for tenant_id in tenant_ids: + invalidator.invalidate( + tenant_id=_safe_int(tenant_id, 1), + invalidate_system=bool(payload.get("invalidate_system")), + invalidate_project_list=bool(payload.get("invalidate_project_list")), + invalidate_signal_pending=bool(payload.get("invalidate_signal_pending")), + user_ids=set(payload.get("user_ids") or set()), + ) + + @event.listens_for(Session, "after_rollback") + def _clear_cache_invalidation(session: Session) -> None: # pragma: no cover + session.info.pop("_pmflow_cache_invalidation", None) + + setattr(register_context_cache_invalidation_hooks, "_registered", True) + + +context_cache_service = ContextCacheService() diff --git a/backend/app/services/cost_optimization_service.py b/backend/app/services/cost_optimization_service.py new file mode 100644 index 00000000..47ddc402 --- /dev/null +++ b/backend/app/services/cost_optimization_service.py @@ -0,0 +1,183 @@ +"""成本优化服务(P1)""" + +from typing import Any, Dict, Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AICallLog, CostTracking, ModelRequirementBinding, ModelVersion, TruthNode + + +class CostOptimizationService: + """面向宪章维度的成本优化建议。""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def analyze_cost_optimization( + self, + truth_node_id: int, + tenant_id: int, + period: str = "weekly", + project_id: Optional[int] = None, + ) -> Dict[str, Any]: + if project_id is not None: + await self.validate_truth_node_in_project( + truth_node_id=truth_node_id, + tenant_id=tenant_id, + project_id=project_id, + ) + tracking = await self._get_tracking(truth_node_id, tenant_id, period) + if not tracking: + return { + "truth_node_id": truth_node_id, + "period": period, + "monthly_cost": 0.0, + "recommendations": [], + "total_potential_savings_percent": 0, + } + + monthly_cost = float(tracking.actual_cost_usd or 0.0) * self._period_multiplier(period) + recs = [] + recs.extend(await self._recommend_model_switch(truth_node_id, tenant_id)) + recs.extend(await self._recommend_cache_and_batching(truth_node_id, tenant_id)) + + total_pct = sum(int(item.get("estimated_savings_percent", 0)) for item in recs) + total_pct = min(total_pct, 60) + + result = { + "truth_node_id": truth_node_id, + "period": period, + "monthly_cost": round(monthly_cost, 2), + "recommendations": recs, + "total_potential_savings_percent": total_pct, + "estimated_monthly_savings_usd": round(monthly_cost * total_pct / 100, 2), + } + tracking.optimization_suggestions = result + await self.db.commit() + return result + + async def validate_truth_node_in_project( + self, truth_node_id: int, tenant_id: int, project_id: int + ) -> TruthNode: + result = await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tenant_id, + ) + ) + ) + truth_node = result.scalar_one_or_none() + if not truth_node: + raise ValueError(f"宪章 {truth_node_id} 不存在") + if truth_node.project_id != project_id: + raise ValueError( + f"宪章 {truth_node_id} 不属于项目 {project_id},拒绝跨项目分析" + ) + return truth_node + + async def _get_tracking( + self, truth_node_id: int, tenant_id: int, period: str + ) -> Optional[CostTracking]: + result = await self.db.execute( + select(CostTracking).where( + and_( + CostTracking.truth_node_id == truth_node_id, + CostTracking.tenant_id == tenant_id, + CostTracking.period == period, + ) + ) + ) + return result.scalar_one_or_none() + + async def _recommend_model_switch( + self, truth_node_id: int, tenant_id: int + ) -> list[Dict[str, Any]]: + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.truth_node_id == truth_node_id, + ModelRequirementBinding.tenant_id == tenant_id, + ) + ) + ) + bindings = result.scalars().all() + if not bindings: + return [] + + current = bindings[0] + model = await self._get_model(current.model_version_id) + if not model or not model.cost_per_1k_tokens: + return [] + + cheaper = await self._find_cheaper_compatible_model(float(model.cost_per_1k_tokens)) + if not cheaper: + return [] + + return [ + { + "type": "model_switch", + "priority": "high", + "description": f"可从 {model.name} 切换到 {cheaper.name}(更低 token 成本)", + "estimated_savings_percent": 25, + } + ] + + async def _recommend_cache_and_batching( + self, truth_node_id: int, tenant_id: int + ) -> list[Dict[str, Any]]: + # 当前日志没有强关联 truth_node 字段,按租户统计调用频次近似分析。 + call_count_res = await self.db.execute( + select(func.count(AICallLog.id)).where(AICallLog.tenant_id == str(tenant_id)) + ) + call_count = int(call_count_res.scalar() or 0) + + recs: list[Dict[str, Any]] = [ + { + "type": "cache", + "priority": "high", + "description": "启用/提升相似请求缓存命中率(去重 prompt + TTL 分层)", + "estimated_savings_percent": 15, + } + ] + if call_count > 200: + recs.append( + { + "type": "batching", + "priority": "medium", + "description": "批量聚合低优先级推理请求,减少并发和重复上下文开销", + "estimated_savings_percent": 10, + } + ) + return recs + + async def _get_model(self, model_version_id: int) -> Optional[ModelVersion]: + result = await self.db.execute( + select(ModelVersion).where(ModelVersion.id == model_version_id) + ) + return result.scalar_one_or_none() + + async def _find_cheaper_compatible_model( + self, current_cost_per_1k_tokens: float + ) -> Optional[ModelVersion]: + result = await self.db.execute( + select(ModelVersion) + .where(ModelVersion.is_active == True) # noqa: E712 + .order_by(ModelVersion.cost_per_1k_tokens.asc()) + ) + candidates = result.scalars().all() + for m in candidates: + cost = float(m.cost_per_1k_tokens or 0.0) + if cost > 0 and cost < current_cost_per_1k_tokens: + return m + return None + + def _period_multiplier(self, period: str) -> int: + if period == "daily": + return 30 + if period == "weekly": + return 4 + if period == "monthly": + return 1 + return 1 diff --git a/backend/app/services/daily_briefing.py b/backend/app/services/daily_briefing.py new file mode 100644 index 00000000..aa80e2d3 --- /dev/null +++ b/backend/app/services/daily_briefing.py @@ -0,0 +1,480 @@ +""" +Daily Progress Briefing Service - 每日进度简报服务 + +核心功能: +1. 为每个角色生成个性化简报 +2. 智能过滤无关信息 +3. 根据角色优先级排序内容 +4. 替代传统日报/周报 + +个性化规则: +- AI工程师:关注模型更新、技术信号、实验结果 +- 后端开发:关注API变更、任务阻塞、依赖更新 +- 前端开发:关注UI需求、设计变更、接口更新 +- BOSS/ADMIN:关注整体进度、风险、决策点 +""" +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text, func, and_ + +from ..models import ( + User, Project, Task, Signal, CognitiveConflict, + TruthNode, UserRole, ProjectSignalLink +) +from .llm import LLMService + + +def _parse_involved_user_ids(raw_value: Any) -> List[int]: + """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" + if raw_value is None: + return [] + data = raw_value + if isinstance(data, (bytes, bytearray)): + data = data.decode("utf-8", errors="ignore") + if isinstance(data, str): + text = data.strip() + if not text: + return [] + try: + data = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + data = [v.strip() for v in text.split(",") if v.strip()] + if not isinstance(data, list): + data = [data] + + parsed: List[int] = [] + for item in data: + if item is None or isinstance(item, bool): + continue + try: + parsed.append(int(item)) + except (TypeError, ValueError): + continue + return parsed + + +class DailyBriefingService: + """每日进度简报服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def generate_personalized_briefing( + self, + user_id: int, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 生成个性化每日简报 + + 简报内容根据用户角色自动过滤: + - 只显示相关项目的内容 + - 按优先级排序 + - 突出风险和需要行动的事项 + """ + # 获取用户信息 + user_result = await self.db.execute( + select(User).where(User.id == user_id) + ) + user = user_result.scalar_one_or_none() + if not user: + raise ValueError(f"User {user_id} not found") + + # 计算时间范围 + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + + # 根据角色收集相关信息 + relevant_data = await self._collect_relevant_data( + user=user, + since=yesterday, + tenant_id=tenant_id + ) + + # AI 生成个性化摘要 + summary = await self._generate_ai_summary( + user=user, + data=relevant_data, + tenant_id=tenant_id, + ) + + return { + 'user_id': user_id, + 'user_role': self._role_code(user.role), + 'date': now.strftime('%Y-%m-%d'), + 'summary': summary, + 'highlights': relevant_data.get('highlights', []), + 'your_tasks': relevant_data.get('your_tasks', []), + 'risks': relevant_data.get('risks', []), + 'signals': relevant_data.get('signals', []), + 'conflicts': relevant_data.get('conflicts', []), + 'action_items': relevant_data.get('action_items', []), + 'team_updates': relevant_data.get('team_updates', []) + } + + async def _collect_relevant_data( + self, + user: User, + since: datetime, + tenant_id: int + ) -> Dict[str, Any]: + """收集与用户相关的数据""" + + data = { + 'highlights': [], + 'your_tasks': [], + 'risks': [], + 'signals': [], + 'conflicts': [], + 'action_items': [], + 'team_updates': [] + } + + # 获取用户相关的项目 + user_projects = await self._get_user_projects(user.id, tenant_id) + + if not user_projects: + return data + + project_ids = [p.id for p in user_projects] + role_code = self._role_code(user.role) + + # 1. 获取用户的任务更新 + task_result = await self.db.execute( + select(Task, Project).join( + Project, + and_(Project.id == Task.project_id, Project.tenant_id == tenant_id), + ).where( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + Task.updated_at >= since + ) + ) + task_rows = task_result.all() + + for task, project in task_rows: + # 我的任务 + if task.assignee_id == user.id: + data['your_tasks'].append({ + 'id': task.id, + 'title': task.title, + 'status': task.status, + 'priority': task.priority, + 'project': project.title, + 'updated_at': task.updated_at.isoformat() if hasattr(task, 'updated_at') else None + }) + + # 高优先级或阻塞的任务加入 highlights + if task.priority == 'HIGH' or task.status == 'blocked': + data['highlights'].append({ + 'type': 'task', + 'priority': 'high', + 'content': f'任务"{task.title}"状态: {task.status}', + 'project': project.title + }) + + # 2. 获取风险(阻塞的任务) + for task, project in task_rows: + if task.status == 'blocked': + data['risks'].append({ + 'type': 'task_blocked', + 'task': task.title, + 'project': project.title, + 'severity': 'high' + }) + + # 3. 获取新信号(根据角色过滤) + signal_result = await self.db.execute( + select(Signal).join( + ProjectSignalLink, + ProjectSignalLink.signal_id == Signal.id + ).where( + Signal.tenant_id == tenant_id, + ProjectSignalLink.project_id.in_(project_ids), + Signal.created_at >= since, + Signal.impact_score >= 0.7 # 只显示高影响信号 + ).order_by( + Signal.impact_score.desc() + ).limit(10) + ) + signals = signal_result.scalars().all() + + for signal in signals: + # 根据角色过滤信号内容 + if self._is_signal_relevant_to_role(signal, role_code): + data['signals'].append({ + 'id': signal.id, + 'source': signal.source, + 'content': signal.content[:200], + 'impact_score': signal.impact_score, + 'priority': signal.priority + }) + + # 高影响信号加入 highlights + if signal.impact_score >= 0.9: + data['highlights'].append({ + 'type': 'signal', + 'priority': 'high', + 'content': f'高影响信号: {signal.content[:100]}', + 'source': signal.source + }) + + # 4. 获取认知冲突 + conflict_result = await self.db.execute( + select(CognitiveConflict).join( + TruthNode, + TruthNode.id == CognitiveConflict.truth_node_id + ).where( + CognitiveConflict.tenant_id == tenant_id, + TruthNode.project_id.in_(project_ids), + CognitiveConflict.status == 'active', + CognitiveConflict.created_at >= since + ) + ) + conflicts = conflict_result.scalars().all() + + for conflict in conflicts: + involved_users = _parse_involved_user_ids(conflict.involved_user_ids) + # 如果用户参与了冲突,或是管理员 + if user.id in involved_users or role_code in [UserRole.ADMIN.value, UserRole.BOSS.value]: + data['conflicts'].append({ + 'id': conflict.id, + 'type': conflict.conflict_type, + 'description': conflict.description[:200], + 'severity': 'high' if 'critical' in conflict.description.lower() else 'medium' + }) + + # 冲突加入 highlights + data['highlights'].append({ + 'type': 'conflict', + 'priority': 'high', + 'content': f'认知冲突: {conflict.conflict_type}', + 'project_id': conflict.truth_node_id + }) + + # 5. 生成行动项 + if data['your_tasks']: + for task in data['your_tasks'][:3]: + if task['status'] in ['pending', 'in_progress']: + data['action_items'].append({ + 'type': 'task', + 'content': f'继续推进任务: {task["title"]}', + 'priority': task['priority'] + }) + + if data['conflicts']: + data['action_items'].append({ + 'type': 'conflict', + 'content': f'解决 {len(data["conflicts"])} 个认知冲突', + 'priority': 'high' + }) + + # 6. 团队更新(其他用户的相关任务) + team_task_result = await self.db.execute( + select(Task, Project).join( + Project, + and_(Project.id == Task.project_id, Project.tenant_id == tenant_id), + ).where( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + Task.assignee_id != user.id, + Task.updated_at >= since + ) + ) + team_tasks = team_task_result.all() + + for task, project in team_tasks[:5]: + data['team_updates'].append({ + 'assignee_id': task.assignee_id, + 'task': task.title, + 'status': task.status, + 'project': project.title + }) + + return data + + async def _get_user_projects( + self, + user_id: int, + tenant_id: int + ) -> List[Project]: + """获取用户相关的项目""" + # 获取用户参与的所有项目 + result = await self.db.execute( + select(Project).where( + Project.tenant_id == tenant_id, + Project.status == 'active' + ) + ) + return result.scalars().all() + + def _is_signal_relevant_to_role( + self, + signal: Signal, + user_role: Any + ) -> bool: + """判断信号是否与用户角色相关""" + content = signal.content.lower() + role_code = self._role_code(user_role) + _ = signal.source.lower() + + # AI工程师关注的内容 + if role_code == UserRole.AI.value: + ai_keywords = ['model', 'llm', 'gpt', 'embedding', 'vector', '训练', '推理', 'ollama', 'langchain'] + if any(kw in content for kw in ai_keywords): + return True + + # 后端开发关注的内容 + elif role_code in {UserRole.BE.value, "BACKEND"}: + backend_keywords = ['api', 'backend', 'server', 'database', 'sql', '后端', '接口', '服务'] + if any(kw in content for kw in backend_keywords): + return True + + # 前端开发关注的内容 + elif role_code in {UserRole.FE.value, "FRONTEND"}: + frontend_keywords = ['ui', 'frontend', 'react', 'vue', 'component', '前端', '界面', '交互'] + if any(kw in content for kw in frontend_keywords): + return True + + # 管理员看到所有高影响信号 + elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}: + return signal.impact_score >= 0.8 + + # 默认返回中等影响以上的信号 + return signal.impact_score >= 0.7 + + async def _generate_ai_summary( + self, + user: User, + data: Dict[str, Any], + tenant_id: int, + ) -> str: + """AI 生成个性化摘要""" + role_code = self._role_code(user.role) + prompt = f"""为以下用户生成每日工作摘要: + +用户角色:{role_code} +用户名:{user.username} + +今日概况: +- 你的任务数:{len(data.get('your_tasks', []))} +- 新信号数:{len(data.get('signals', []))} +- 活跃冲突数:{len(data.get('conflicts', []))} +- 风险数:{len(data.get('risks', []))} + +重点事项: +{json.dumps(data.get('highlights', []), ensure_ascii=False, indent=2)[:500]} + +请生成一段简洁的每日摘要(100字以内),突出最重要的信息和行动项。""" + + try: + summary = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + return summary.strip() + except: + # 降级:生成简单摘要 + parts = [] + if data.get('your_tasks'): + parts.append(f"你有 {len(data['your_tasks'])} 个任务需要关注") + if data.get('risks'): + parts.append(f"注意 {len(data['risks'])} 个风险点") + if data.get('conflicts'): + parts.append(f"需要解决 {len(data['conflicts'])} 个认知冲突") + return ";".join(parts) if parts else "今日无重要更新" + + @staticmethod + def _role_code(raw_role: Any) -> str: + if raw_role is None: + return "" + value = getattr(raw_role, "value", raw_role) + return str(value or "").strip().upper() + + async def generate_team_briefing( + self, + project_id: int, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 生成团队简报(给管理者) + + 包含: + - 整体进度 + - 每个成员的工作量 + - 风险和阻塞 + - 需要决策的事项 + """ + # 获取项目信息 + project_result = await self.db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id + ) + ) + project = project_result.scalar_one_or_none() + if not project: + raise ValueError(f"Project {project_id} not found") + + # 获取项目所有任务 + task_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id + ) + ) + tasks = task_result.scalars().all() + + # 统计任务状态 + status_counts = {} + for task in tasks: + status_counts[task.status] = status_counts.get(task.status, 0) + 1 + + # 获取阻塞的任务 + blocked_tasks = [t for t in tasks if t.status == 'blocked'] + + # 获取项目相关信号 + signal_result = await self.db.execute( + select(Signal).join( + ProjectSignalLink, + ProjectSignalLink.signal_id == Signal.id + ).where( + Signal.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id + ).order_by( + Signal.created_at.desc() + ).limit(10) + ) + signals = signal_result.scalars().all() + + return { + 'project_id': project_id, + 'project_name': project.title, + 'generated_at': datetime.now(timezone.utc).isoformat(), + 'task_summary': { + 'total': len(tasks), + 'by_status': status_counts, + 'completion_rate': status_counts.get('done', 0) / len(tasks) if tasks else 0 + }, + 'blocked_tasks': [ + {'id': t.id, 'title': t.title, 'assignee_id': t.assignee_id} + for t in blocked_tasks + ], + 'recent_signals': [ + {'id': s.id, 'content': s.content[:100], 'impact_score': s.impact_score} + for s in signals[:5] + ], + 'needs_attention': len(blocked_tasks) > 0 + } + + +# 全局服务实例 +def get_daily_briefing_service(db: AsyncSession) -> DailyBriefingService: + """获取每日简报服务实例""" + return DailyBriefingService(db) diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py new file mode 100644 index 00000000..02f324de --- /dev/null +++ b/backend/app/services/domain.py @@ -0,0 +1,448 @@ +""" +Neural Sync 2.0 - Project Domain Service + +项目域服务 - 管理动态项目域 +每个域有独立的信号源、知识库和配置 +""" + +import asyncio +import logging +from typing import Optional, Dict, List, Any +from pydantic import BaseModel, Field +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class DomainConfig(BaseModel): + """项目域配置""" + signal_sources: List[str] = Field(default_factory=lambda: ["hackernews"]) + ai_config: Dict[str, Any] = Field(default_factory=dict) + audit_threshold: float = 98.0 + auto_merge_enabled: bool = False + webhook_urls: Dict[str, str] = Field(default_factory=dict) + + +class ProjectDomainCreate(BaseModel): + """创建项目域请求""" + name: str + slug: str + description: Optional[str] = None + config: Optional[DomainConfig] = None + is_default: bool = False + + +class ProjectDomainUpdate(BaseModel): + """更新项目域请求""" + name: Optional[str] = None + description: Optional[str] = None + config: Optional[DomainConfig] = None + is_active: Optional[bool] = None + is_default: Optional[bool] = None + + +class ProjectDomainResponse(BaseModel): + """项目域响应""" + id: str + name: str + slug: str + description: Optional[str] + config: DomainConfig + is_active: bool + is_default: bool + signal_count: int + context_node_count: int + experiment_count: int + created_at: datetime + updated_at: datetime + last_synced_at: Optional[datetime] + + +class DomainSyncProgress(BaseModel): + """域同步进度""" + domain_id: str + phase: str # connecting, loading_knowledge, syncing_signals, complete + progress: float # 0.0 - 1.0 + message: str + + +class ProjectDomainService: + """ + 项目域服务 + + 功能: + - CRUD 操作 + - 域切换和知识同步 + - 统计信息更新 + - 默认域管理 + """ + + def __init__(self, db_session: AsyncSession, event_bus=None): + self.db = db_session + self.event_bus = event_bus + self._active_domain_id: Optional[str] = None + + async def create_domain(self, request: ProjectDomainCreate) -> ProjectDomainResponse: + """创建新项目域""" + from ..models import ProjectDomain + import uuid + + # 检查 slug 唯一性 + existing = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.slug == request.slug) + ) + if existing.scalar_one_or_none(): + raise ValueError(f"Slug '{request.slug}' already exists") + + # 如果设置为默认域,取消其他域的默认状态 + if request.is_default: + await self._clear_default_domains() + + # 序列化配置 + config_dict = request.config.model_dump() if request.config else {} + + domain = ProjectDomain( + name=request.name, + slug=request.slug, + description=request.description, + config=config_dict, + is_default=request.is_default, + ) + + self.db.add(domain) + await self.db.commit() + await self.db.refresh(domain) + + # 发布域创建事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_CREATED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return self._to_response(domain) + + async def get_domain(self, domain_id: str) -> Optional[ProjectDomainResponse]: + """获取单个域""" + from ..models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return None + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.id == domain_uuid) + ) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def get_domain_by_slug(self, slug: str) -> Optional[ProjectDomainResponse]: + """通过 slug 获取域""" + from ..models import ProjectDomain + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.slug == slug) + ) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def list_domains( + self, include_inactive: bool = False + ) -> List[ProjectDomainResponse]: + """列出所有域""" + from ..models import ProjectDomain + + query = select(ProjectDomain) + if not include_inactive: + query = query.where(ProjectDomain.is_active == True) + + query = query.order_by(ProjectDomain.is_default.desc(), ProjectDomain.created_at.asc()) + + result = await self.db.execute(query) + domains = result.scalars().all() + + return [self._to_response(d) for d in domains] + + async def update_domain( + self, domain_id: str, request: ProjectDomainUpdate + ) -> Optional[ProjectDomainResponse]: + """更新域""" + from ..models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return None + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.id == domain_uuid) + ) + domain = result.scalar_one_or_none() + + if not domain: + return None + + # 更新字段 + if request.name is not None: + domain.name = request.name + if request.description is not None: + domain.description = request.description + if request.config is not None: + domain.config = request.config.model_dump() + if request.is_active is not None: + domain.is_active = request.is_active + if request.is_default is not None and request.is_default != domain.is_default: + if request.is_default: + await self._clear_default_domains() + domain.is_default = request.is_default + + domain.updated_at = datetime.utcnow() + + await self.db.commit() + await self.db.refresh(domain) + + # 发布域更新事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_UPDATED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return self._to_response(domain) + + async def delete_domain(self, domain_id: str) -> bool: + """删除域""" + from ..models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return False + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.id == domain_uuid) + ) + domain = result.scalar_one_or_none() + + if not domain: + return False + + # 不允许删除默认域 + if domain.is_default: + raise ValueError("Cannot delete default domain") + + await self.db.delete(domain) + await self.db.commit() + + # 发布域删除事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_DELETED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return True + + async def switch_domain(self, domain_id: str) -> DomainSyncProgress: + """ + 切换活动域 + + 流程: + 1. 验证域存在且激活 + 2. 开始知识同步(1.5s 动画) + 3. 更新活动域 ID + 4. 返回同步进度 + """ + from ..models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + raise ValueError("Invalid domain ID") + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.id == domain_uuid) + ) + domain = result.scalar_one_or_none() + + if not domain: + raise ValueError("Domain not found") + + if not domain.is_active: + raise ValueError("Domain is not active") + + # 更新最后同步时间 + domain.last_synced_at = datetime.utcnow() + await self.db.commit() + + # 设置活动域 + self._active_domain_id = str(domain.id) + + # 发布域切换事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_SWITCHED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + # 模拟同步进度(1.5s) + return DomainSyncProgress( + domain_id=str(domain.id), + phase="complete", + progress=1.0, + message=f"已切换到 {domain.name}", + ) + + async def get_default_domain(self) -> Optional[ProjectDomainResponse]: + """获取默认域""" + from ..models import ProjectDomain + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.is_default == True) + ) + domain = result.scalar_one_or_none() + + if not domain: + # 如果没有默认域,返回第一个激活域 + result = await self.db.execute( + select(ProjectDomain) + .where(ProjectDomain.is_active == True) + .order_by(ProjectDomain.created_at.asc()) + .limit(1) + ) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def update_domain_stats(self, domain_id: str) -> None: + """更新域统计信息""" + from ..models import ProjectDomain, Signal, ContextNode, Experiment + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.id == domain_uuid) + ) + domain = result.scalar_one_or_none() + + if not domain: + return + + # TODO: 需要添加域关联到 Signal, ContextNode, Experiment + # 当前使用全局计数 + signal_count = await self.db.execute(select(Signal)) + domain.signal_count = len(signal_count.scalars().all()) + + context_count = await self.db.execute(select(ContextNode)) + domain.context_node_count = len(context_count.scalars().all()) + + experiment_count = await self.db.execute(select(Experiment)) + domain.experiment_count = len(experiment_count.scalars().all()) + + await self.db.commit() + + async def get_active_domain_id(self) -> Optional[str]: + """获取当前活动域 ID""" + if self._active_domain_id: + return self._active_domain_id + + # 如果没有设置,返回默认域 + default = await self.get_default_domain() + if default: + self._active_domain_id = default.id + return default.id + + return None + + async def _clear_default_domains(self) -> None: + """清除所有域的默认状态""" + from ..models import ProjectDomain + + result = await self.db.execute( + select(ProjectDomain).where(ProjectDomain.is_default == True) + ) + domains = result.scalars().all() + + for domain in domains: + domain.is_default = False + + await self.db.commit() + + def _to_response(self, domain) -> ProjectDomainResponse: + """转换为响应模型""" + config = DomainConfig(**domain.config) if domain.config else DomainConfig() + + return ProjectDomainResponse( + id=str(domain.id), + name=domain.name, + slug=domain.slug, + description=domain.description, + config=config, + is_active=domain.is_active, + is_default=domain.is_default, + signal_count=domain.signal_count, + context_node_count=domain.context_node_count, + experiment_count=domain.experiment_count, + created_at=domain.created_at, + updated_at=domain.updated_at, + last_synced_at=getattr(domain, 'last_synced_at', None), + ) + + +# 全局服务实例 +_domain_service = None + + +def get_domain_service(db_session: AsyncSession, event_bus=None) -> ProjectDomainService: + """获取项目域服务实例""" + return ProjectDomainService(db_session, event_bus) diff --git a/backend/app/services/event_workflow.py b/backend/app/services/event_workflow.py new file mode 100644 index 00000000..01596669 --- /dev/null +++ b/backend/app/services/event_workflow.py @@ -0,0 +1,614 @@ +""" +Event-Driven Workflow Engine - 事件驱动工作流引擎 + +核心功能: +1. 替代固定 Sprint 迭代,实现实时事件驱动迭代 +2. 市场变化可在10分钟内反映到项目优先级 +3. 自动触发任务重排、依赖更新 +4. 推送实时通知给相关角色 + +工作流事件: +- 高影响信号到达 → 自动重新评估项目优先级 +- 阻塞检测 → 触发替代方案生成 +- 认知冲突 → 触发对齐流程 +- 技术栈变更 → 自动更新任务依赖 +- 竞品动态 → 触发战略调整 +""" +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Callable +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text, update + +from ..models import ( + User, Project, Task, Signal, CognitiveConflict, + TruthNode, ProjectSignalLink, UserRole +) +from .llm import LLMService +from ..events import EventType, Event, get_event_bus + + +class WorkflowEventType: + """工作流事件类型""" + HIGH_IMPACT_SIGNAL = "high_impact_signal" + BLOCKER_DETECTED = "blocker_detected" + COGNITIVE_CONFLICT = "cognitive_conflict" + TECH_STACK_CHANGE = "tech_stack_change" + COMPETITOR_UPDATE = "competitor_update" + TASK_COMPLETED = "task_completed" + TASK_BLOCKED = "task_blocked" + PROJECT_RISK = "project_risk" + + +class EventDrivenWorkflowEngine: + """事件驱动工作流引擎""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + self.event_bus = get_event_bus() + + # 注册工作流处理器 + self._handlers = { + WorkflowEventType.HIGH_IMPACT_SIGNAL: self._handle_high_impact_signal, + WorkflowEventType.BLOCKER_DETECTED: self._handle_blocker_detected, + WorkflowEventType.COGNITIVE_CONFLICT: self._handle_cognitive_conflict, + WorkflowEventType.TECH_STACK_CHANGE: self._handle_tech_stack_change, + WorkflowEventType.COMPETITOR_UPDATE: self._handle_competitor_update, + WorkflowEventType.TASK_COMPLETED: self._handle_task_completed, + WorkflowEventType.TASK_BLOCKED: self._handle_task_blocked, + WorkflowEventType.PROJECT_RISK: self._handle_project_risk, + } + + async def trigger_workflow( + self, + event_type: str, + event_data: Dict[str, Any], + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 触发工作流 + + Args: + event_type: 事件类型 + event_data: 事件数据 + tenant_id: 租户ID + + Returns: + 处理结果 + """ + handler = self._handlers.get(event_type) + if not handler: + return {'status': 'ignored', 'reason': 'Unknown event type'} + + try: + result = await handler(event_data, tenant_id) + + # 发布到事件总线 + await self.event_bus.publish( + EventType.WORKFLOW_TRIGGERED, + { + 'workflow_type': event_type, + 'result': result, + 'timestamp': datetime.now(timezone.utc).isoformat() + } + ) + + return { + 'status': 'processed', + 'event_type': event_type, + 'result': result + } + + except Exception as e: + return { + 'status': 'error', + 'event_type': event_type, + 'error': str(e) + } + + async def _handle_high_impact_signal( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理高影响信号 + + 动作: + 1. 评估对关联项目的影响 + 2. 调整项目优先级 + 3. 创建响应任务 + 4. 通知相关角色 + """ + signal_id = event_data.get('signal_id') + impact_threshold = 0.85 + + # 获取信号 + signal_result = await self.db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id + ) + ) + signal = signal_result.scalar_one_or_none() + if not signal: + raise ValueError(f"Signal {signal_id} not found") + + # 获取关联的项目 + link_result = await self.db.execute( + select(ProjectSignalLink).where( + ProjectSignalLink.signal_id == signal_id, + ProjectSignalLink.tenant_id == tenant_id + ) + ) + links = link_result.scalars().all() + + affected_projects = [] + created_tasks = [] + + for link in links: + project_result = await self.db.execute( + select(Project).where(Project.id == link.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + continue + + # AI 评估影响 + impact = await self._assess_signal_impact_on_project(signal, project) + + if impact.get('should_adjust_priority', False): + # 调整项目优先级 + old_priority = project.priority if hasattr(project, 'priority') else 'medium' + new_priority = impact.get('suggested_priority', 'high') + + if hasattr(project, 'priority'): + project.priority = new_priority + await self.db.commit() + + affected_projects.append({ + 'project_id': project.id, + 'project_name': project.title, + 'old_priority': old_priority, + 'new_priority': new_priority, + 'reason': impact.get('reason', '') + }) + + # 创建响应任务 + if impact.get('needs_response_task', False): + task = await self._create_response_task( + project=project, + signal=signal, + impact=impact, + tenant_id=tenant_id + ) + created_tasks.append({ + 'task_id': task.id, + 'title': task.title, + 'priority': task.priority + }) + + return { + 'affected_projects': affected_projects, + 'created_tasks': created_tasks, + 'message': f'处理了 {len(affected_projects)} 个受影响的项目' + } + + async def _handle_blocker_detected( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理阻塞检测 + + 动作: + 1. AI 生成替代方案 + 2. 创建绕过任务 + 3. 通知相关人员 + """ + blocker_id = event_data.get('blocker_id') + task_id = event_data.get('task_id') + + # 获取阻塞信息 + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == blocker_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + blocker = conflict_result.scalar_one_or_none() + + if not blocker: + raise ValueError(f"Blocker {blocker_id} not found") + + # AI 生成解决方案 + prompt = f"""为以下阻塞生成绕过方案: + +阻塞描述:{blocker.description} + +请提供: +1. 问题根本原因分析 +2. 3个可行的绕过方案 +3. 每个方案的预估工作量 +4. 推荐的最优方案 + +以 JSON 格式返回。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + # 解析 AI 响应(简化处理) + return { + 'blocker_id': blocker_id, + 'alternatives_generated': True, + 'ai_response': response + } + except: + return { + 'blocker_id': blocker_id, + 'alternatives_generated': False, + 'message': 'AI 方案生成失败' + } + + async def _handle_cognitive_conflict( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理认知冲突 + + 动作: + 1. 识别涉及的用户 + 2. 发送对齐邀请 + 3. 调度异步对齐会话 + """ + conflict_id = event_data.get('conflict_id') + + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = conflict_result.scalar_one_or_none() + + if not conflict: + raise ValueError(f"Conflict {conflict_id} not found") + + # 获取涉及的用户 + involved_user_ids = json.loads(conflict.involved_user_ids) if conflict.involved_user_ids else [] + + return { + 'conflict_id': conflict_id, + 'involved_users': involved_user_ids, + 'alignment_session_created': len(involved_user_ids) >= 2, + 'message': '对齐邀请已发送' + } + + async def _handle_tech_stack_change( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理技术栈变更 + + 动作: + 1. 识别受影响的任务 + 2. 更新任务描述和依赖 + 3. 重新估算工时 + """ + project_id = event_data.get('project_id') + new_tech = event_data.get('new_tech', '') + + # 获取项目的所有任务 + task_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + Task.status.in_(['pending', 'in_progress']) + ) + ) + tasks = task_result.scalars().all() + + affected_tasks = [] + for task in tasks: + # AI 判断任务是否受影响 + is_affected = await self._is_task_affected_by_tech_change(task, new_tech) + if is_affected: + affected_tasks.append({ + 'task_id': task.id, + 'task_title': task.title, + 'impact': 'high' + }) + + return { + 'project_id': project_id, + 'new_tech': new_tech, + 'affected_tasks': affected_tasks, + 'message': f'识别出 {len(affected_tasks)} 个受影响的任务' + } + + async def _handle_competitor_update( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理竞品动态 + + 动作: + 1. 评估战略影响 + 2. 调整项目方向 + 3. 创建应对任务 + """ + signal_content = event_data.get('signal_content', '') + competitor_name = event_data.get('competitor_name', '竞品') + + # AI 分析影响 + prompt = f"""分析以下竞品动态的战略影响: + +竞品:{competitor_name} +动态:{signal_content} + +请评估: +1. 对我们的威胁等级(high/medium/low) +2. 建议的应对措施 +3. 是否需要调整项目方向 + +以 JSON 格式返回。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + return { + 'competitor': competitor_name, + 'threat_assessment': response, + 'strategic_adjustment_recommended': True + } + except: + return { + 'competitor': competitor_name, + 'threat_assessment': 'medium', + 'message': 'AI 评估失败' + } + + async def _handle_task_completed( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理任务完成 + + 动作: + 1. 检查依赖任务,解锁可开始的 + 2. 更新项目进度 + 3. 触发后续任务 + """ + task_id = event_data.get('task_id') + + # 获取任务 + task_result = await self.db.execute( + select(Task).where( + Task.id == task_id, + Task.tenant_id == tenant_id + ) + ) + task = task_result.scalar_one_or_none() + + if not task: + raise ValueError(f"Task {task_id} not found") + + # 查找依赖此任务的其他任务 + # 这里简化处理:查找同项目的待办任务 + dependent_result = await self.db.execute( + select(Task).where( + Task.project_id == task.project_id, + Task.tenant_id == tenant_id, + Task.status == 'pending', + Task.id != task_id + ).limit(5) + ) + dependents = dependent_result.scalars().all() + + return { + 'completed_task_id': task_id, + 'project_id': task.project_id, + 'unlocked_tasks': [ + {'id': t.id, 'title': t.title} + for t in dependents + ], + 'message': f'解锁了 {len(dependents)} 个待办任务' + } + + async def _handle_task_blocked( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理任务阻塞 + + 动作: + 1. 标记任务状态 + 2. 触发阻塞检测工作流 + 3. 通知项目负责人 + """ + task_id = event_data.get('task_id') + blocker_reason = event_data.get('reason', 'Unknown') + + # 更新任务状态 + await self.db.execute( + update(Task) + .where(Task.id == task_id, Task.tenant_id == tenant_id) + .values(status='blocked') + ) + await self.db.commit() + + return { + 'task_id': task_id, + 'blocker_reason': blocker_reason, + 'status': 'blocked', + 'workflow_triggered': True + } + + async def _handle_project_risk( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理项目风险 + + 动作: + 1. 评估风险等级 + 2. 创建缓解任务 + 3. 通知管理层 + """ + project_id = event_data.get('project_id') + risk_type = event_data.get('risk_type', 'general') + risk_description = event_data.get('description', '') + + # 获取项目 + project_result = await self.db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id + ) + ) + project = project_result.scalar_one_or_none() + + if not project: + raise ValueError(f"Project {project_id} not found") + + return { + 'project_id': project_id, + 'project_name': project.title if project else 'Unknown', + 'risk_type': risk_type, + 'risk_level': 'high', + 'mitigation_required': True, + 'message': '风险已记录,等待处理' + } + + # ==================== Helper Methods ==================== + + async def _assess_signal_impact_on_project( + self, + signal: Signal, + project: Project + ) -> Dict[str, Any]: + """AI 评估信号对项目的影响""" + prompt = f"""评估以下信号对项目的影响: + +项目:{project.title} +描述:{project.description} + +信号: +来源:{signal.source} +内容:{signal.content} +影响分数:{signal.impact_score} + +请判断: +1. 是否需要调整项目优先级? +2. 建议的优先级是什么? +3. 是否需要创建响应任务? +4. 原因是什么? + +以 JSON 格式返回: +{{ + "should_adjust_priority": boolean, + "suggested_priority": "high/medium/low", + "needs_response_task": boolean, + "reason": "原因说明" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=signal.tenant_id if getattr(signal, "tenant_id", None) is not None else None, + project_id=project.id if project else None, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return { + 'should_adjust_priority': signal.impact_score > 0.9, + 'suggested_priority': 'high' if signal.impact_score > 0.9 else 'medium', + 'needs_response_task': signal.impact_score > 0.85, + 'reason': f'信号影响分数 {signal.impact_score}' + } + + async def _create_response_task( + self, + project: Project, + signal: Signal, + impact: Dict[str, Any], + tenant_id: int + ): + """创建响应任务""" + from sqlalchemy import text + + next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 + + task = Task( + id=next_task_id, + truth_node_id=None, + project_id=project.id, + assignee_id=None, + title=f'响应信号: {signal.content[:50]}', + description=f"""自动生成的响应任务 + +触发信号: +来源:{signal.source} +内容:{signal.content} + +影响评估:{impact.get('reason', '')} + +建议行动: +请根据信号内容制定具体的响应方案。""", + required_skill_tag_ids=[], + status='pending', + priority='HIGH', + estimated_hours=8, + actual_hours=0, + quality_score=0, + tenant_id=tenant_id + ) + self.db.add(task) + await self.db.commit() + await self.db.refresh(task) + return task + + async def _is_task_affected_by_tech_change( + self, + task: Task, + new_tech: str + ) -> bool: + """判断任务是否受技术变更影响""" + # 简化处理:检查任务描述中是否包含相关技术关键词 + task_keywords = task.title.lower() + ' ' + task.description.lower() + # 这里可以更复杂,使用 AI 判断 + return new_tech.lower() in task_keywords + + +# 全局服务实例 +def get_event_workflow_engine(db: AsyncSession) -> EventDrivenWorkflowEngine: + """获取事件驱动工作流引擎实例""" + return EventDrivenWorkflowEngine(db) diff --git a/backend/app/services/experiment_sync_service.py b/backend/app/services/experiment_sync_service.py new file mode 100644 index 00000000..0e2a13f0 --- /dev/null +++ b/backend/app/services/experiment_sync_service.py @@ -0,0 +1,300 @@ +"""实验结果同步服务(P1)""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Set + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Experiment, ExperimentTruthNodeSync, TruthNode + + +class ExperimentSyncService: + """实验结果 -> 宪章同步,支持自动应用、人工审核与回滚。""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def auto_sync_experiment_result( + self, + experiment_id: int, + truth_node_id: int, + tenant_id: int, + project_id: Optional[int] = None, + sync_type: str = "result_sync", + auto_apply_threshold: float = 0.8, + ) -> Dict[str, Any]: + experiment = await self._get_experiment(experiment_id, tenant_id) + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + if not experiment or not truth_node: + raise ValueError("实验或宪章不存在") + if project_id is not None: + await self.validate_experiment_truth_same_project( + experiment=experiment, + truth_node=truth_node, + tenant_id=tenant_id, + project_id=project_id, + ) + + confidence = self._calc_confidence(experiment.current_score) + sync_data = self._build_sync_data(experiment, truth_node, sync_type) + auto_apply = confidence >= auto_apply_threshold + + record = ExperimentTruthNodeSync( + tenant_id=tenant_id, + experiment_id=experiment.id, + experiment_external_id=str(experiment.id), + truth_node_id=truth_node.id, + sync_type=sync_type, + sync_data=sync_data, + confidence_score=confidence, + auto_applied=auto_apply, + apply_status="pending", + ) + + if auto_apply: + await self._apply_sync_to_truth_node(record, truth_node) + record.apply_status = "applied" + + self.db.add(record) + await self.db.commit() + await self.db.refresh(record) + + return { + "sync_id": record.id, + "confidence_score": record.confidence_score, + "auto_applied": record.auto_applied, + "apply_status": record.apply_status, + "sync_data": record.sync_data, + } + + async def validate_experiment_truth_same_project( + self, + experiment: Experiment, + truth_node: TruthNode, + tenant_id: int, + project_id: int, + ) -> None: + """交叉校验实验与宪章均属于指定项目。 + + 注意:Experiment 当前无 project_id 字段,使用 linked_context_nodes 与项目宪章集合交叉验证。 + """ + if truth_node.project_id != project_id: + raise ValueError( + f"宪章 {truth_node.id} 不属于项目 {project_id},拒绝跨项目同步" + ) + + # 强约束优先:Experiment.project_id 命中则直接使用。 + if experiment.project_id is not None: + if int(experiment.project_id) != int(project_id): + raise ValueError( + f"实验 {experiment.id} 不属于项目 {project_id},拒绝跨项目同步" + ) + return + + project_truth_nodes = await self._get_project_truth_node_ids(project_id, tenant_id) + if not project_truth_nodes: + raise ValueError(f"项目 {project_id} 下无可用宪章,无法校验实验归属") + + linked = experiment.linked_context_nodes or [] + linked_ids = self._normalize_ids(linked) + if not linked_ids: + raise ValueError( + "实验未关联任何上下文节点(linked_context_nodes 为空),无法通过项目归属校验" + ) + + if linked_ids.isdisjoint(project_truth_nodes): + raise ValueError( + f"实验 {experiment.id} 未关联项目 {project_id} 下宪章,拒绝跨项目同步" + ) + + # 迁移期兼容:可推断归属时补写 project_id,减少后续重复推断。 + experiment.project_id = project_id + + async def _get_project_truth_node_ids(self, project_id: int, tenant_id: int) -> Set[int]: + result = await self.db.execute( + select(TruthNode.id).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ) + ) + return {int(row[0]) for row in result.all()} + + def _normalize_ids(self, raw: Any) -> Set[int]: + ids: Set[int] = set() + if isinstance(raw, list): + for item in raw: + try: + ids.add(int(item)) + except (TypeError, ValueError): + continue + return ids + + async def review_sync( + self, + sync_id: int, + tenant_id: int, + reviewer_id: int, + action: str, + review_notes: Optional[str] = None, + ) -> Dict[str, Any]: + record = await self._get_sync_record(sync_id, tenant_id) + if not record: + raise ValueError("同步记录不存在") + if record.apply_status == "applied" and action == "approve": + return {"sync_id": record.id, "apply_status": record.apply_status} + + record.reviewed_by = reviewer_id + record.reviewed_at = datetime.now(timezone.utc) + record.review_notes = review_notes + + if action == "approve": + truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) + if truth_node: + await self._apply_sync_to_truth_node(record, truth_node) + record.apply_status = "applied" + else: + record.apply_status = "rejected" + + await self.db.commit() + await self.db.refresh(record) + return { + "sync_id": record.id, + "apply_status": record.apply_status, + "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, + } + + async def rollback_sync( + self, + sync_id: int, + tenant_id: int, + reviewer_id: int, + rollback_notes: Optional[str] = None, + ) -> Dict[str, Any]: + record = await self._get_sync_record(sync_id, tenant_id) + if not record: + raise ValueError("同步记录不存在") + if record.apply_status != "applied": + raise ValueError("仅已应用记录支持回滚") + + truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) + if not truth_node: + raise ValueError("关联宪章不存在") + + snapshot = (record.sync_data or {}).get("previous_truth_content") + if snapshot is None: + raise ValueError("缺少回滚快照,无法回滚") + + truth_node.content = snapshot + truth_node.version = int(truth_node.version or 1) + 1 + + record.apply_status = "requires_review" + record.reviewed_by = reviewer_id + record.reviewed_at = datetime.now(timezone.utc) + record.review_notes = rollback_notes or "manual rollback" + + await self.db.commit() + await self.db.refresh(record) + return { + "sync_id": record.id, + "apply_status": record.apply_status, + "rolled_back": True, + } + + async def list_sync_records( + self, + tenant_id: int, + truth_node_id: Optional[int] = None, + limit: int = 50, + ) -> Dict[str, Any]: + query = select(ExperimentTruthNodeSync).where( + ExperimentTruthNodeSync.tenant_id == tenant_id + ) + if truth_node_id is not None: + query = query.where(ExperimentTruthNodeSync.truth_node_id == truth_node_id) + query = query.order_by(ExperimentTruthNodeSync.created_at.desc()).limit(limit) + + result = await self.db.execute(query) + items = result.scalars().all() + return { + "items": [ + { + "id": i.id, + "experiment_id": i.experiment_id, + "truth_node_id": i.truth_node_id, + "sync_type": i.sync_type, + "confidence_score": i.confidence_score, + "auto_applied": i.auto_applied, + "apply_status": i.apply_status, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in items + ] + } + + async def _get_experiment(self, experiment_id: int, tenant_id: int) -> Optional[Experiment]: + result = await self.db.execute( + select(Experiment).where( + and_(Experiment.id == experiment_id, Experiment.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def _get_truth_node(self, truth_node_id: int, tenant_id: int) -> Optional[TruthNode]: + result = await self.db.execute( + select(TruthNode).where( + and_(TruthNode.id == truth_node_id, TruthNode.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def _get_sync_record( + self, sync_id: int, tenant_id: int + ) -> Optional[ExperimentTruthNodeSync]: + result = await self.db.execute( + select(ExperimentTruthNodeSync).where( + and_(ExperimentTruthNodeSync.id == sync_id, ExperimentTruthNodeSync.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + def _calc_confidence(self, current_score: Optional[float]) -> float: + score = float(current_score or 0.0) + if score > 1: + score /= 100.0 + return round(max(0.0, min(1.0, score)), 2) + + def _build_sync_data( + self, experiment: Experiment, truth_node: TruthNode, sync_type: str + ) -> Dict[str, Any]: + return { + "sync_type": sync_type, + "experiment_summary": { + "id": experiment.id, + "title": experiment.title, + "status": experiment.status, + "target_metric": experiment.target_metric, + "current_score": experiment.current_score, + }, + "previous_truth_content": truth_node.content, + } + + async def _apply_sync_to_truth_node( + self, record: ExperimentTruthNodeSync, truth_node: TruthNode + ) -> None: + content = truth_node.content if isinstance(truth_node.content, dict) else {} + if not isinstance(content, dict): + content = {"raw": str(truth_node.content)} + sync_section = content.get("experiment_sync", {}) + sync_section["last_sync"] = { + "sync_id": record.id, + "sync_type": record.sync_type, + "confidence_score": record.confidence_score, + "experiment_summary": (record.sync_data or {}).get("experiment_summary", {}), + "applied_at": datetime.now(timezone.utc).isoformat(), + } + content["experiment_sync"] = sync_section + truth_node.content = content + truth_node.version = int(truth_node.version or 1) + 1 diff --git a/backend/app/services/feishu_integration.py b/backend/app/services/feishu_integration.py new file mode 100644 index 00000000..df2a01ed --- /dev/null +++ b/backend/app/services/feishu_integration.py @@ -0,0 +1,1873 @@ +""" +Feishu Integration Service - 飞书集成服务 + +功能: +1. 接收飞书群聊消息事件 +2. AI 判断消息是否为有价值的信号 +3. 自动创建信号并关联到项目 +4. 推送通知给相关角色 + +飞书开放平台配置: +- App ID: 从飞书开放平台获取 +- App Secret: 从飞书开放平台获取 +- 事件接收: 统一使用长连接模式 +""" +import asyncio +import json +import hashlib +import re +import time +from uuid import uuid4 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +import httpx +from sqlalchemy import and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ..models import ( + User, + Project, + Signal, + ProjectSignalLink, + Integration, + FeishuReplyOutbox, + AIProcessingLog, +) +from ..database import AsyncSessionLocal +from .notification_service import NotificationService +from .signal_capture import ProactiveSignalCaptureService +from .llm import LLMService +from .prompt_profile_service import prompt_profile_service + +CN_TZ = timezone(timedelta(hours=8)) +WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] +OUTBOX_MAX_ATTEMPTS = 3 +OUTBOX_STALE_SENDING_SECONDS = 120 + + +class FeishuIntegrationService: + """飞书集成服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + # 从数据库获取飞书配置 + self.app_id = None + self.app_secret = None + self.encrypt_key = None + self.verification_token = None + self.event_mode = "long_connection" + self.tenant_id = 1 + + @staticmethod + def _normalize_event_mode(_: Optional[str] = None) -> str: + return "long_connection" + + async def load_config(self, tenant_id: int = 1): + """从数据库加载飞书配置""" + self.tenant_id = tenant_id + + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu', + Integration.is_active.is_(True), + ) + ) + integration = result.scalar_one_or_none() + + if integration: + if isinstance(integration.config, str): + config = json.loads(integration.config) if integration.config else {} + else: + config = integration.config or {} + self.app_id = config.get('app_id') + self.app_secret = config.get('app_secret') + self.encrypt_key = None + self.verification_token = None + self.event_mode = self._normalize_event_mode(config.get('event_mode')) + else: + self.app_id = None + self.app_secret = None + self.encrypt_key = None + self.verification_token = None + self.event_mode = 'long_connection' + + def is_configured(self) -> bool: + """检查是否已配置""" + return bool(self.app_id and self.app_secret) + + async def save_config( + self, + app_id: str, + app_secret: str, + encrypt_key: str, + verification_token: str, + event_mode: str = "long_connection", + tenant_id: int = 1 + ): + """保存飞书配置到数据库""" + mode = self._normalize_event_mode(event_mode) + config = { + 'app_id': app_id, + 'app_secret': app_secret, + 'encrypt_key': "", + 'verification_token': "", + 'event_mode': mode, + } + + # 查找或创建集成记录 + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu' + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.config = config + integration.is_active = True + else: + integration = Integration( + type='feishu', + config=config, + is_active=True, + tenant_id=tenant_id + ) + self.db.add(integration) + + await self.db.commit() + + # 更新内存配置 + self.app_id = app_id + self.app_secret = app_secret + self.encrypt_key = None + self.verification_token = None + self.event_mode = mode + self.tenant_id = tenant_id + + async def handle_message_event( + self, + event_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理飞书消息事件 + + 支持的事件类型: + - message.receive_v1: 收到群聊消息 + - im.message.receive_v1: 收到群聊消息(schema 2.0) + """ + try: + event_type = (event_data.get('type') or '').strip() + + # 飞书 schema 2.0 会返回 im.message.receive_v1 + if event_type in {'message.receive_v1', 'im.message.receive_v1'}: + return await self._handle_message_receive(event_data) + + return { + 'code': 0, + 'msg': f'Event type {event_type} not handled' + } + + except Exception as e: + print(f"[Feishu] Handle event error: {e}") + return { + 'code': 1, + 'msg': str(e) + } + + async def _handle_message_receive( + self, + event: Dict[str, Any] + ) -> Dict[str, Any]: + """处理收到的消息事件""" + state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" + ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" + message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} + chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} + sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} + message_id = (message.get("message_id") or "").strip() or "unknown" + self._emit_state_transition( + trace_id=state_trace_id, + state="RECEIVED", + message_id=message_id, + message_type=str(message.get("message_type") or "unknown"), + ) + sender_type = str(sender.get("sender_type") or "").strip().lower() + if sender_type in {"app", "bot"}: + return {'code': 0, 'msg': 'Ignore bot/app message'} + + mentioned_bot_names = self._extract_mentioned_bot_names(message) + + # 解析消息内容与附件元数据 + message_payload = self._extract_message_payload(event) + message_content = str(message_payload.get("text") or "").strip() or None + message_attachments = ( + message_payload.get("attachments") + if isinstance(message_payload.get("attachments"), list) + else [] + ) + await self._record_ingress_trace( + ingress_source=ingress_source, + trace_id=state_trace_id, + message_id=message_id, + event=event, + message_content=message_content or "", + ) + if not message_content: + print( + f"[Feishu] message.receive_v1 ignored: no text content, " + f"message_type={message.get('message_type')}, keys={list(message.keys())}" + ) + chat_id = chat.get('chat_id') or message.get('chat_id') or '' + if mentioned_bot_names and chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'), + mention_name=(sender.get('sender_name') or sender.get('name') or '你'), + text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", + trace_id=state_trace_id, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="empty_text_prompt", + ) + return {'code': 0, 'msg': 'No text content'} + + # 获取发送者信息 + sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {} + sender_open_id = (sender_id.get('open_id') or "").strip() or None + sender_name = await self._resolve_sender_display_name( + sender=sender, + sender_id=sender_id, + ) + + # 获取群聊信息 + chat_id = chat.get('chat_id') or message.get('chat_id') or '' + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" + captured_at = self._extract_message_datetime(message) + occurred_at_iso = ( + captured_at.isoformat() if isinstance(captured_at, datetime) else None + ) + message_attachments = self._normalize_ingress_attachment_records( + message_attachments, + occurred_at=occurred_at_iso, + source_message_id=(message.get("message_id") or "").strip() or None, + source_chat_id=chat_id or None, + ) + + # 使用信号捕获服务处理 + capture_service = ProactiveSignalCaptureService(self.db) + + try: + result = await capture_service.capture_from_feishu( + group_chat_id=chat_id, + message_content=message_content, + sender=sender_name, + tenant_id=self.tenant_id, + proposer_open_id=sender_open_id, + captured_at=captured_at, + mentioned_bot_names=mentioned_bot_names, + feishu_message_id=(message.get("message_id") or "").strip() or None, + chat_type=chat_type, + ) + await self._persist_signal_attachments_from_ingress( + signal_id=result.get("signal_id"), + attachments=message_attachments, + ) + except Exception as exc: + self._emit_state_transition( + trace_id=state_trace_id, + state="ERROR_REPLY", + reason="capture_exception", + error=str(exc), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=state_trace_id, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="error_template", + ) + return { + "code": 0, + "msg": "error_handled", + "data": { + "status": "error", + "classification": "error", + "ai_trace_id": state_trace_id, + }, + } + + print( + "[Feishu] Message processed: " + f"status={result.get('status')}, " + f"classification={result.get('classification')}, " + f"linked_projects={result.get('linked_project_ids', [])}, " + f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " + f"ai_trace_id={result.get('ai_trace_id')}" + ) + scene = self._classify_message_scene(capture_result=result) + self._emit_state_transition( + trace_id=state_trace_id, + state="CLASSIFIED", + scene=scene, + status=str(result.get("status") or "unknown"), + classification=str(result.get("classification") or "unknown"), + ) + if str(result.get("status") or "").lower() != "error": + self._emit_state_transition( + trace_id=state_trace_id, + state="COMMITTED", + ai_trace_id=str(result.get("ai_trace_id") or ""), + ) + + reply_route = "none" + if scene == "execute": + if self._is_duplicate_capture_result(result): + self._emit_state_transition( + trace_id=state_trace_id, + state="ROUTED", + route="duplicate_suppressed", + ) + else: + self._emit_state_transition( + trace_id=state_trace_id, + state="ROUTED", + route="tool_first_execute", + ) + deterministic_reply = str(result.get("direct_reply_text") or "").strip() + if not deterministic_reply: + deterministic_reply = self._build_execution_receipt_reply( + message_content=message_content, + capture_result=result, + ) + deterministic_reply, guard_reason = self._guard_execute_reply_text_with_reason( + deterministic_reply, + trace_id=state_trace_id, + ) + if guard_reason: + asyncio.create_task( + self._record_execute_guard_alert( + trace_id=state_trace_id, + message_id=message_id, + message_content=message_content, + capture_result=result, + guard_reason=guard_reason, + ) + ) + if not deterministic_reply and str(result.get("status") or "").lower() == "error": + deterministic_reply = self._build_error_reply_text() + if chat_id and deterministic_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=deterministic_reply, + trace_id=state_trace_id, + ) + reply_route = "execute_template" + elif scene == "qa": + self._emit_state_transition( + trace_id=state_trace_id, + state="ROUTED", + route="qa_consult", + ) + if chat_id and bool(result.get("direct_reply_required")): + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=str(result.get("direct_reply_text") or "已收到,正在处理。"), + trace_id=state_trace_id, + ) + reply_route = "qa_direct" + elif ( + chat_id + and self._should_reply_to_mention( + mentioned_bot_names=mentioned_bot_names, + message_content=message_content, + ) + ): + await self._reply_with_active_role_prompt( + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + message_content=message_content, + capture_result=result, + trace_id=state_trace_id, + ) + reply_route = "qa_llm" + else: + self._emit_state_transition( + trace_id=state_trace_id, + state="ERROR_REPLY", + reason="scene_error", + capture_status=str(result.get("status") or "unknown"), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=state_trace_id, + ) + reply_route = "error_template" + + if reply_route != "none": + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route=reply_route, + ) + await self._record_message_trace( + trace_id=state_trace_id, + message_id=message_id, + chat_type=chat_type, + message_content=message_content, + capture_result=result, + reply_source=reply_route, + ) + + return { + 'code': 0, + 'msg': 'success', + 'data': { + 'status': result.get('status'), + 'classification': result.get('classification'), + 'linked_project_ids': result.get('linked_project_ids', []), + 'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []), + 'ai_trace_id': result.get('ai_trace_id'), + 'state_trace_id': state_trace_id, + 'scene': scene, + }, + } + + async def _record_ingress_trace( + self, + *, + ingress_source: str, + trace_id: str, + message_id: str, + event: Dict[str, Any], + message_content: str, + ) -> None: + try: + message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {} + chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {} + sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {} + sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {} + sender_name = ( + str(sender.get("sender_name") or sender.get("name") or "").strip() + or str(sender_id.get("open_id") or "").strip() + or "unknown" + ) + chat_id = str(chat.get("chat_id") or message.get("chat_id") or "").strip() or None + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip() or None + + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(message_content or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "ingress_source": ingress_source, + "event_type": str(event.get("type") or "")[:120], + "chat_id": chat_id, + "chat_type": chat_type, + "sender_name": sender_name[:200], + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="ingress", + normalized_text=(message_content or "")[:8000] or None, + route=ingress_source[:80], + intent=None, + executor="feishu_ingress", + operation_code=None, + operation_result={ + "message_type": str(message.get("message_type") or "")[:80], + "chat_id": chat_id, + "chat_type": chat_type, + "sender_name": sender_name[:200], + }, + reply_source=None, + routing_decision="event_received", + routing_reason=f"Feishu ingress via {ingress_source}", + reasoning_bullets=[ + f"trace_id={trace_id}", + f"message_id={message_id}", + f"source={ingress_source}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] ingress trace write failed: trace_id={trace_id}, error={exc}") + + @staticmethod + def _classify_message_scene(*, capture_result: Dict[str, Any]) -> str: + if not isinstance(capture_result, dict): + return "error" + status = str(capture_result.get("status") or "").strip().lower() + classification = str(capture_result.get("classification") or "").strip().lower() + if status in {"captured", "ignored"}: + return "execute" + if status == "answered" or classification in {"question", "qa"}: + return "qa" + if status == "error" or classification == "error": + return "error" + return "qa" + + @staticmethod + def _build_error_reply_text() -> str: + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + + @staticmethod + def _emit_state_transition(trace_id: str, state: str, **payload: Any) -> None: + safe_trace = str(trace_id or "unknown") + safe_state = str(state or "unknown") + details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" + print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}") + + async def _reply_with_active_role_prompt( + self, + *, + chat_id: str, + sender_open_id: Optional[str], + sender_name: str, + message_content: str, + capture_result: Dict[str, Any], + trace_id: Optional[str] = None, + ) -> None: + """被 @ 时回复一条有人味的执行反馈。""" + deterministic_time_reply = self._build_time_truth_reply(message_content) + if deterministic_time_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=deterministic_time_reply, + trace_id=trace_id, + ) + return + + execution_receipt = self._build_execution_receipt_reply( + message_content=message_content, + capture_result=capture_result, + ) + execution_receipt = self._guard_execute_reply_text( + execution_receipt, + trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), + ) + if execution_receipt: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=execution_receipt, + trace_id=trace_id, + ) + return + + butler_reply = self._build_butler_decision_reply(capture_result=capture_result) + if butler_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=butler_reply, + trace_id=trace_id, + ) + return + + try: + active_role = await prompt_profile_service.get_active_role(self.db, tenant_id=self.tenant_id) + role = active_role.get("role") if isinstance(active_role, dict) else {} + role_name = str((role or {}).get("role_name") or "产品经理(任务分派)").strip() + system_prompt = str((role or {}).get("system_prompt") or "").strip() + if len(system_prompt) < 20: + system_prompt = ( + "你是 PMFlow 的任务分派产品经理。" + "请用简洁、专业、有温度的中文回复用户,先确认需求,再给可执行下一步。" + ) + + status = str(capture_result.get("status") or "unknown") + classification = str(capture_result.get("classification") or "unknown") + reason = str(capture_result.get("reason") or "").strip() + ai_trace_id = str(capture_result.get("ai_trace_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids", []) + linked_projects_text = ", ".join([str(x) for x in linked_project_ids[:3]]) if isinstance(linked_project_ids, list) else "" + now_cn = datetime.now(CN_TZ) + now_cn_text = ( + f"{now_cn.year}年{now_cn.month}月{now_cn.day}日 " + f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}" + ) + + prompt = f""" +{system_prompt} + +你正在飞书群里以「{role_name}」身份回复用户。请基于以下上下文,生成一条 35-140 字中文回复: +- 当前系统时间(北京时间):{now_cn_text} +- 提及用户:{sender_name} +- 用户消息:{(message_content or '')[:600]} +- 系统处理状态:{status} +- 分类结果:{classification} +- 处理原因:{reason or "无"} +- 处理追踪ID:{ai_trace_id or "无"} +- 关联项目ID:{linked_projects_text or '无'} + +回复要求: +1. 语气自然、专业,避免机器口吻。 +2. 先确认你已处理,再给 1-3 条下一步建议。 +3. 若信息不足,请明确要补充什么。 +4. 如果用户询问“日期/今天/时间/几点/星期”,必须基于“当前系统时间(北京时间)”直接回答,不得编造历史日期。 +5. 不要输出 Markdown,不要输出 JSON。 +只输出最终回复正文。 +""".strip() + + llm = LLMService() + raw_reply = await llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_humanized_reply", + temperature=0.4, + max_tokens=180, + ) + reply_text = self._clean_reply_text(raw_reply) + if (not reply_text) or self._looks_like_provider_error(reply_text): + reply_text = self._fallback_reply_text(capture_result) + except Exception as exc: + print(f"[Feishu] generate mention reply failed: {exc}") + reply_text = self._fallback_reply_text(capture_result) + + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=reply_text, + trace_id=trace_id, + ) + + async def _safe_reply_text( + self, + *, + chat_id: str, + text: str, + mention_open_id: Optional[str] = None, + mention_name: Optional[str] = None, + trace_id: Optional[str] = None, + ) -> None: + try: + plain_text = self._normalize_outgoing_reply_text(text) + mention_prefix = self._build_mention_prefix( + open_id=mention_open_id, + display_name=mention_name, + ) + final_text = f"{mention_prefix}{plain_text}".strip() + outbox_id = await self._enqueue_reply_outbox( + chat_id=chat_id, + message_text=final_text, + trace_id=trace_id, + ) + asyncio.create_task(self._dispatch_outbox_entry(outbox_id=outbox_id)) + except Exception as exc: + print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") + try: + plain_text = self._normalize_outgoing_reply_text(text) + mention_prefix = self._build_mention_prefix( + open_id=mention_open_id, + display_name=mention_name, + ) + await self.send_message_to_group(chat_id=chat_id, message=f"{mention_prefix}{plain_text}".strip()) + except Exception as send_exc: + print(f"[Feishu] send fallback failed: chat_id={chat_id}, error={send_exc}") + + async def _enqueue_reply_outbox( + self, + *, + chat_id: str, + message_text: str, + trace_id: Optional[str], + ) -> int: + async with AsyncSessionLocal() as db: + row = FeishuReplyOutbox( + tenant_id=int(self.tenant_id or 1), + trace_id=(trace_id or "")[:64] or None, + chat_id=(chat_id or "")[:128], + message_text=(message_text or "")[:2000] or "已收到,正在处理。", + status="pending", + attempts=0, + next_retry_at=None, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return int(row.id) + + async def _dispatch_outbox_entry(self, *, outbox_id: int) -> None: + await self.__class__._dispatch_outbox_entry_by_id(outbox_id=outbox_id) + + @classmethod + async def _dispatch_outbox_entry_by_id(cls, *, outbox_id: int) -> None: + for _ in range(OUTBOX_MAX_ATTEMPTS + 1): + now = datetime.utcnow() + should_wait_seconds = 0.0 + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + status = str(row.status or "").lower() + if status == "sent": + return + if status == "failed": + return + if status == "sending": + stale_seconds = 0.0 + marker = row.updated_at or row.created_at + if isinstance(marker, datetime): + try: + marker_naive = marker.replace(tzinfo=None) if marker.tzinfo else marker + stale_seconds = max(0.0, (now - marker_naive).total_seconds()) + except Exception: + stale_seconds = 0.0 + if stale_seconds >= OUTBOX_STALE_SENDING_SECONDS: + row.status = "retrying" + row.next_retry_at = now + await db.commit() + else: + return + if row.next_retry_at and row.next_retry_at > now: + should_wait_seconds = max(0.2, min(30.0, (row.next_retry_at - now).total_seconds())) + else: + row.status = "sending" + row.attempts = int(row.attempts or 0) + 1 + row.last_error = None + await db.commit() + + if should_wait_seconds > 0: + await asyncio.sleep(should_wait_seconds) + continue + + send_error: Optional[str] = None + async with AsyncSessionLocal() as db_send: + row = ( + await db_send.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + try: + service = FeishuIntegrationService(db_send) + await service.load_config(tenant_id=int(row.tenant_id)) + await service.send_message_to_group( + chat_id=str(row.chat_id or ""), + message=str(row.message_text or ""), + ) + row.status = "sent" + row.sent_at = datetime.utcnow() + row.next_retry_at = None + row.last_error = None + await db_send.commit() + return + except Exception as exc: + send_error = str(exc) + + async with AsyncSessionLocal() as db_retry: + row = ( + await db_retry.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + attempts = int(row.attempts or 0) + if attempts >= OUTBOX_MAX_ATTEMPTS: + row.status = "failed" + row.next_retry_at = None + else: + row.status = "retrying" + delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1))) + row.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds) + row.last_error = (send_error or "unknown_send_error")[:1000] + await db_retry.commit() + + if attempts >= OUTBOX_MAX_ATTEMPTS: + return + await asyncio.sleep(min(5, 2 * max(1, attempts))) + + @classmethod + async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: + now = datetime.utcnow() + fetch_limit = max(1, min(200, int(limit))) + async with AsyncSessionLocal() as db: + due_rows = ( + await db.execute( + select(FeishuReplyOutbox.id) + .where( + FeishuReplyOutbox.status.in_(("pending", "retrying")), + or_( + FeishuReplyOutbox.next_retry_at.is_(None), + FeishuReplyOutbox.next_retry_at <= now, + ), + ) + .order_by(FeishuReplyOutbox.id.asc()) + .limit(fetch_limit) + ) + ).scalars().all() + + if not due_rows: + return 0 + + await asyncio.gather( + *(cls._dispatch_outbox_entry_by_id(outbox_id=int(outbox_id)) for outbox_id in due_rows), + return_exceptions=True, + ) + return len(due_rows) + + @staticmethod + def _clean_reply_text(value: Optional[str]) -> str: + text = str(value or "").strip() + if text.startswith("```"): + text = text.replace("```", "").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + return text[:500] + + @classmethod + def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: + text = cls._clean_reply_text(value) + if (not text) or cls._looks_like_provider_error(text): + return cls._build_error_reply_text() + return text[:500] + + @staticmethod + def _looks_like_provider_error(text: str) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + error_signals = ( + "[error]", + "request was aborted", + "request aborted", + "connection aborted", + "operation aborted", + "context canceled", + "context cancelled", + "cancelled", + "canceled", + "rate limit", + "too many requests", + "429", + "quota", + "insufficient_quota", + "insufficient quota", + "api key", + "authentication failed", + "service unavailable", + "upstream error", + "upstream request", + "bad gateway", + "gateway timeout", + "internal server error", + "connection reset", + "econnreset", + "socket hang up", + ) + if any(flag in lowered for flag in error_signals): + return True + + if len(lowered) <= 160: + compact = lowered.replace("\n", " ").strip() + ascii_chars = sum(1 for ch in compact if ord(ch) < 128) + ascii_ratio = ascii_chars / max(1, len(compact)) + generic_error_tokens = ("error", "failed", "abort", "timeout", "exception") + if ascii_ratio >= 0.9 and any(token in compact for token in generic_error_tokens): + return True + + return False + + @staticmethod + def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: + status = str(capture_result.get("status") or "") + classification = str(capture_result.get("classification") or "") + if status == "captured": + if classification in {"project", "new_project"}: + return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。" + return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。" + return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。" + + @staticmethod + def _build_butler_decision_reply(capture_result: Dict[str, Any]) -> Optional[str]: + if not isinstance(capture_result, dict): + return None + status = str(capture_result.get("status") or "").strip().lower() + if status != "captured": + return None + + results = capture_result.get("results") + if not isinstance(results, list) or not results: + return None + + auto_results = [row for row in results if str((row or {}).get("route_status") or "") == "auto_processed"] + pending_results = [row for row in results if str((row or {}).get("route_status") or "") == "pending"] + classification = str(capture_result.get("classification") or "signal").strip().lower() + signal_id = str(capture_result.get("signal_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids") or [] + project_hint = "" + if isinstance(linked_project_ids, list) and linked_project_ids: + project_hint = f",并关联项目 #{linked_project_ids[0]}" + + if pending_results: + if classification in {"project", "new_project"}: + return ( + "我已先完成项目候选入队,当前有不确定项需要你确认:" + "1) 归属项目/是否新建 2) 优先级 3) 截止时间。" + "确认后我会自动继续推进。" + ) + return ( + "我已先完成信号入队,当前判断存在不确定项(多半是项目归属或范围边界)。" + "请你补充:归属项目、优先级、期望完成时间,我会立即自动续跑。" + ) + + if auto_results: + if classification in {"project", "new_project"}: + return ( + f"已自动完成项目流处理{project_hint}。" + "后续我会继续按管家流程拆解与分派,除非你要求人工接管。" + ) + if signal_id: + return ( + f"已自动完成信号流处理(信号#{signal_id}{project_hint})。" + "这是明确需求场景,我会继续自动推进后续动作。" + ) + return "已自动完成信号流处理。后续我会继续自动推进,只有不确定项才会向你确认。" + + return None + + @staticmethod + def _build_execution_receipt_reply( + *, + message_content: Optional[str], + capture_result: Dict[str, Any], + ) -> Optional[str]: + """ + 对“已执行”场景优先给简短回执,避免 LLM 输出教程式长文或二次处理建议。 + """ + if not isinstance(capture_result, dict): + return None + status = str(capture_result.get("status") or "").strip().lower() + if status not in {"captured", "ignored"}: + return None + + if status == "ignored": + reason = str(capture_result.get("reason") or "").strip().lower() + if reason == "duplicate_filtered": + return "这条消息我已处理过,未重复入库。" + if reason in {"non_requirement_noise", "empty_or_emoji_only"}: + return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。" + return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。" + + text = str(message_content or "").strip() + strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理") + strict_mode = any(k in text for k in strict_keywords) + primary_route_status = "" + results = capture_result.get("results") + if isinstance(results, list) and results: + first = results[0] if isinstance(results[0], dict) else {} + primary_route_status = str((first or {}).get("route_status") or "").strip().lower() + + if primary_route_status == "auto_done" or primary_route_status == "auto_processed": + return "已自动处理完成并入库,后续动作已继续自动推进。" + if primary_route_status == "confirmed": + return "已按确认结果执行并入库。" + if primary_route_status in {"queued_for_decision", "pending"}: + clarification_message = str(capture_result.get("clarification_message") or "").strip() + if clarification_message: + return clarification_message + return "已完成入库,当前进入待确认队列。" + if primary_route_status == "dismissed": + return "该消息已记录,但当前标记为忽略,不会继续执行。" + if primary_route_status == "failed": + return "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。" + if primary_route_status == "undone": + return "上一次自动结果已撤销,当前不会继续执行。" + + signal_id = str(capture_result.get("signal_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids") + project_id: Optional[str] = None + if isinstance(linked_project_ids, list) and linked_project_ids: + project_id = str(linked_project_ids[0]).strip() or None + if not project_id: + candidate = capture_result.get("created_project_id") + if candidate is not None: + project_id = str(candidate).strip() or None + + if signal_id and project_id: + if strict_mode: + return f"已按你的要求仅执行入库:信号#{signal_id},项目#{project_id}。" + return f"已执行完成:信号#{signal_id} 已入库并关联项目#{project_id}。" + if signal_id: + if strict_mode: + return f"已按你的要求仅执行入库:信号#{signal_id}。" + return f"已执行完成:信号#{signal_id} 已入库。" + if project_id: + if strict_mode: + return f"已按你的要求执行:项目#{project_id} 已关联。" + return f"已执行完成:已关联项目#{project_id}。" + return "已执行完成。" + + @staticmethod + def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]: + lowered = str(text or "").strip().lower() + if not lowered: + return None + forbidden = ( + "curl", + "调用方式", + "接口教程", + "api 调用", + "请求示例", + "postman", + "sdk 调用", + ) + for token in forbidden: + if token in lowered: + return token + return None + + @classmethod + def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool: + return cls._detect_forbidden_execute_keyword(text) is not None + + def _guard_execute_reply_text_with_reason( + self, + text: Optional[str], + *, + trace_id: str, + ) -> tuple[Optional[str], Optional[str]]: + if not text: + return text, None + hit = self._detect_forbidden_execute_keyword(text) + if not hit: + return text, None + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="execute_template_guard", + reason="forbidden_keyword_detected", + keyword=hit, + ) + return ( + "已执行完成并入库。如需我继续动作,请直接补充目标、对象和时限。", + hit, + ) + + def _guard_execute_reply_text(self, text: Optional[str], *, trace_id: str) -> Optional[str]: + sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id) + return sanitized + + async def _record_execute_guard_alert( + self, + *, + trace_id: str, + message_id: str, + message_content: str, + capture_result: Dict[str, Any], + guard_reason: str, + ) -> None: + try: + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(message_content or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "guard_reason": guard_reason, + "status": capture_result.get("status"), + "classification": capture_result.get("classification"), + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="execute_guard", + routing_decision="execute_reply_guard_triggered", + routing_reason=f"执行态回复命中禁词({guard_reason}),已切换模板模式。", + reasoning_bullets=[ + "执行态不允许输出调用教程类文本。", + "本次回复已被熔断为固定模板。", + f"trace_id={trace_id}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + recipients = ( + await db.execute( + select(User.id).where( + and_( + User.tenant_id == int(self.tenant_id or 1), + User.is_active.is_(True), + ) + ).limit(5) + ) + ).scalars().all() + await NotificationService(db).create_notifications( + tenant_id=int(self.tenant_id or 1), + user_ids=[int(uid) for uid in recipients], + category="alert", + title="执行态回复熔断已触发", + content=( + f"trace_id={trace_id}\n" + f"message_id={message_id}\n" + f"原因:命中禁词 {guard_reason}\n" + "系统已自动切换为固定模板回复。" + )[:2000], + source="feishu_guard", + entity_type="feishu_message", + entity_id=None, + target_url="/ai-logs", + commit=False, + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] execute guard alert write failed: trace_id={trace_id}, error={exc}") + + async def _record_message_trace( + self, + *, + trace_id: str, + message_id: str, + chat_type: str, + message_content: str, + capture_result: Dict[str, Any], + reply_source: str, + ) -> None: + try: + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(str(capture_result.get("normalized_text") or message_content) or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "chat_type": chat_type, + "normalized_text": str(capture_result.get("normalized_text") or message_content)[:2000], + "route": capture_result.get("route"), + "db_mutations": capture_result.get("db_mutations") or [], + "reply_source": reply_source or "none", + "reply_text": str(capture_result.get("direct_reply_text") or "")[:500], + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="reply_trace", + routing_decision=str(capture_result.get("route") or "reply_trace")[:64], + routing_reason="飞书消息 reply trace", + reasoning_bullets=[ + f"chat_type={chat_type}", + f"reply_source={reply_source or 'none'}", + f"trace_id={trace_id}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] message trace write failed: trace_id={trace_id}, error={exc}") + + @staticmethod + def _build_mention_prefix(*, open_id: Optional[str], display_name: Optional[str]) -> str: + user_open_id = str(open_id or "").strip() + if not user_open_id: + return "" + safe_name = str(display_name or "你").replace("<", "").replace(">", "").strip()[:24] or "你" + return f'{safe_name} ' + + @staticmethod + def _is_duplicate_capture_result(result: Dict[str, Any]) -> bool: + if not isinstance(result, dict): + return False + status = str(result.get("status") or "").strip().lower() + if status != "ignored": + return False + reason = str(result.get("reason") or "").strip().lower() + classification = str(result.get("classification") or "").strip().lower() + return reason == "duplicate_filtered" or classification == "duplicate" + + @staticmethod + def _should_reply_to_mention(*, mentioned_bot_names: List[str], message_content: Optional[str]) -> bool: + # 仅在明确 @PMFlow(而不是 @all / @其他成员)时触发自动回复 + normalized_names = {str(x or "").strip().lower() for x in mentioned_bot_names if str(x or "").strip()} + if any(name in {"pmflow", "_user_1"} for name in normalized_names): + return True + + # 兼容文本中的 @ 语法(不同消息类型返回格式不一致) + text = str(message_content or "").strip().lower() + if not text: + return False + if "@pmflow" in text or "@_user_1" in text: + return True + return False + + @staticmethod + def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]: + text = str(message_content or "").strip().lower() + if not text: + return None + time_keywords = ("今天", "日期", "时间", "几号", "几点", "星期", "周几", "当前日期") + if not any(keyword in text for keyword in time_keywords): + return None + + now_cn = datetime.now(CN_TZ) + today_text = ( + f"{now_cn.year}年{now_cn.month}月{now_cn.day}日" + f"({WEEKDAY_CN[now_cn.weekday()]}){now_cn.strftime('%H:%M:%S')}" + ) + if "2023年11月7日" in text or "2023-11-07" in text: + return ( + f"我这边已校准为北京时间 {today_text}。" + "你看到“2023年11月7日”通常是客户端缓存或本机时区异常," + "建议刷新会话并检查系统时区是否为 Asia/Shanghai。" + ) + return ( + f"我这边当前北京时间是 {today_text}。" + "如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。" + ) + + async def _resolve_sender_display_name( + self, + *, + sender: Dict[str, Any], + sender_id: Dict[str, Any], + ) -> str: + sender_name = (sender.get("sender_name") or "").strip() + if sender_name: + return sender_name + for key in ("name", "en_name"): + value = (sender.get(key) or "").strip() + if value: + return value + + open_id = (sender_id.get("open_id") or "").strip() + if open_id: + remote_name = await self._fetch_user_name_by_open_id(open_id) + if remote_name: + return remote_name + return f"飞书用户({open_id[:8]})" + + user_id = (sender_id.get("user_id") or "").strip() + if user_id: + return f"飞书用户({user_id[:8]})" + return "未知用户" + + async def _fetch_user_name_by_open_id(self, open_id: str) -> Optional[str]: + safe_open_id = (open_id or "").strip() + if not safe_open_id: + return None + try: + access_token = await self._get_tenant_access_token() + except Exception as exc: + print(f"[Feishu] fetch user name skipped: token unavailable: {exc}") + return None + + url = f"https://open.feishu.cn/open-apis/contact/v3/users/{safe_open_id}" + headers = {"Authorization": f"Bearer {access_token}"} + params = {"user_id_type": "open_id"} + try: + async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: + resp = await client.get(url, headers=headers, params=params) + resp.raise_for_status() + payload = resp.json() or {} + if payload.get("code") != 0: + print( + "[Feishu] fetch user name failed: " + f"code={payload.get('code')} msg={payload.get('msg')}" + ) + return None + user = payload.get("data", {}).get("user", {}) if isinstance(payload.get("data", {}), dict) else {} + name = (user.get("name") or user.get("en_name") or "").strip() + return name or None + except Exception as exc: + print(f"[Feishu] fetch user name failed: {exc}") + return None + + @staticmethod + def _extract_message_datetime(message: Dict[str, Any]) -> Optional[datetime]: + if not isinstance(message, dict): + return None + raw = message.get("create_time") or message.get("update_time") + if raw is None: + return None + try: + ts = int(str(raw).strip()) + if ts > 10_000_000_000: + ts = ts / 1000.0 + return datetime.fromtimestamp(ts, tz=timezone.utc) + except Exception: + return None + + @staticmethod + def _extract_mentioned_bot_names(message: Dict[str, Any]) -> List[str]: + if not isinstance(message, dict): + return [] + mentions = message.get("mentions", []) + names: List[str] = [] + if isinstance(mentions, list): + for item in mentions: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + if name not in names: + names.append(name) + + content = message.get("content") + text = "" + if isinstance(content, dict): + text = str(content.get("text") or "") + elif isinstance(content, str): + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + text = str(parsed.get("text") or "") + else: + text = content + except Exception: + text = content + + if text: + for raw in re.findall(r"]*>([^<]+)", text, flags=re.IGNORECASE): + name = str(raw or "").strip() + if name and name not in names: + names.append(name) + for raw in re.findall(r"@([^\s@]+)", text): + name = str(raw or "").strip() + if name and name not in names: + names.append(name) + return names + + @staticmethod + def _build_attachment_placeholder_text(attachments: List[Dict[str, Any]]) -> str: + if not attachments: + return "" + names: List[str] = [] + for item in attachments[:3]: + attachment_type = str(item.get("attachment_type") or "附件").strip() + file_name = str(item.get("file_name") or "").strip() + names.append(file_name or attachment_type) + suffix = "、".join(names) + return f"[附件消息] {suffix}" if suffix else "[附件消息]" + + @staticmethod + def _normalize_ingress_attachment_records( + items: List[Dict[str, Any]], + *, + occurred_at: Optional[str] = None, + source_message_id: Optional[str] = None, + source_chat_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + for idx, raw in enumerate(items): + if not isinstance(raw, dict): + continue + attachment_type = str(raw.get("attachment_type") or raw.get("type") or "file").strip().lower() + if attachment_type in {"img", "picture"}: + attachment_type = "image" + if attachment_type in {"doc", "docs"}: + attachment_type = "document" + if attachment_type not in {"image", "file", "media", "document"}: + attachment_type = "file" + + file_key = str(raw.get("file_key") or "").strip() or None + image_key = str(raw.get("image_key") or "").strip() or None + media_id = str(raw.get("media_id") or "").strip() or None + download_url = str(raw.get("download_url") or raw.get("url") or "").strip() or None + attachment_id = ( + str(raw.get("attachment_id") or raw.get("id") or file_key or image_key or media_id or "") + .strip() + ) + if not attachment_id: + attachment_id = f"attachment-{idx + 1}" + if not any([file_key, image_key, media_id, download_url]): + continue + if attachment_id in seen_ids: + continue + seen_ids.add(attachment_id) + + file_size = raw.get("file_size") if raw.get("file_size") is not None else raw.get("size") + try: + file_size = int(str(file_size).strip()) if file_size is not None else None + except (TypeError, ValueError): + file_size = None + file_name = ( + str(raw.get("file_name") or raw.get("name") or raw.get("title") or "").strip() + or f"{attachment_type}-{attachment_id}" + ) + mime_type = str(raw.get("mime_type") or raw.get("mime") or "").strip() or None + normalized.append( + { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": file_name, + "mime_type": mime_type, + "file_size": file_size, + "file_key": file_key, + "image_key": image_key, + "media_id": media_id, + "download_url": download_url, + "source": str(raw.get("source") or "feishu").strip() or "feishu", + "occurred_at": str(raw.get("occurred_at") or occurred_at or "").strip() or None, + "source_message_id": str(raw.get("source_message_id") or source_message_id or "").strip() or None, + "source_chat_id": str(raw.get("source_chat_id") or source_chat_id or "").strip() or None, + } + ) + return normalized + + def _extract_message_payload(self, event: Dict[str, Any]) -> Dict[str, Any]: + """提取消息文本与附件元数据。""" + message = event.get("message", {}) + if not isinstance(message, dict) and isinstance(event.get("event", {}), dict): + message = event.get("event", {}).get("message", {}) + if not isinstance(message, dict): + return {"text": None, "attachments": [], "message_type": ""} + + message_type = str(message.get("message_type") or "").strip().lower() + content = message.get("content", "") + content_json: Dict[str, Any] = {} + if isinstance(content, dict): + content_json = content + elif isinstance(content, str) and content.strip(): + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + content_json = parsed + except Exception: + if message_type == "text": + return {"text": content.strip() or None, "attachments": [], "message_type": message_type} + + text_parts: List[str] = [] + attachments: List[Dict[str, Any]] = [] + + if message_type == "text": + text = str(content_json.get("text") or "").strip() + return {"text": text or None, "attachments": [], "message_type": message_type} + + if message_type == "post": + post = content_json.get("post", {}) if isinstance(content_json, dict) else {} + zh_cn = post.get("zh_cn", {}) if isinstance(post, dict) else {} + elements = zh_cn.get("content", []) if isinstance(zh_cn, dict) else [] + if (not elements) and isinstance(content_json, dict): + direct_elements = content_json.get("content", []) + if isinstance(direct_elements, list): + elements = direct_elements + for row in elements: + row_items = row if isinstance(row, list) else [row] + for elem in row_items: + if not isinstance(elem, dict): + continue + tag = str(elem.get("tag") or "").strip().lower() + if tag == "text": + text = str(elem.get("text") or "").strip() + if text: + text_parts.append(text) + elif tag in {"img", "image"}: + attachments.append( + { + "attachment_type": "image", + "image_key": elem.get("image_key"), + "file_name": elem.get("title") or "图片附件", + } + ) + elif tag in {"file", "media"}: + attachments.append( + { + "attachment_type": "media" if tag == "media" else "file", + "file_key": elem.get("file_key"), + "media_id": elem.get("media_id"), + "file_name": elem.get("file_name") or elem.get("title"), + "file_size": elem.get("file_size"), + } + ) + elif message_type in {"image", "file", "media"}: + attachments.append( + { + "attachment_type": message_type, + "image_key": content_json.get("image_key"), + "file_key": content_json.get("file_key"), + "media_id": content_json.get("media_id"), + "file_name": content_json.get("file_name") or content_json.get("title"), + "file_size": content_json.get("file_size"), + "mime_type": content_json.get("file_type") or content_json.get("mime_type"), + } + ) + text = str(content_json.get("text") or content_json.get("caption") or "").strip() + if text: + text_parts.append(text) + elif isinstance(content_json, dict): + guessed_keys = { + "image_key": content_json.get("image_key"), + "file_key": content_json.get("file_key"), + "media_id": content_json.get("media_id"), + "download_url": content_json.get("download_url"), + } + if any(v for v in guessed_keys.values()): + attachments.append( + { + "attachment_type": "file", + "image_key": guessed_keys.get("image_key"), + "file_key": guessed_keys.get("file_key"), + "media_id": guessed_keys.get("media_id"), + "download_url": guessed_keys.get("download_url"), + "file_name": content_json.get("file_name") or content_json.get("title"), + "file_size": content_json.get("file_size"), + "mime_type": content_json.get("mime_type"), + } + ) + text = str(content_json.get("text") or "").strip() + if text: + text_parts.append(text) + + normalized_attachments = self._normalize_ingress_attachment_records(attachments) + merged_text = " ".join([part for part in text_parts if part]).strip() + if not merged_text and normalized_attachments: + merged_text = self._build_attachment_placeholder_text(normalized_attachments) + return { + "text": merged_text or None, + "attachments": normalized_attachments, + "message_type": message_type, + } + + def _extract_message_content(self, event: Dict[str, Any]) -> Optional[str]: + payload = self._extract_message_payload(event) + text = str(payload.get("text") or "").strip() + return text or None + + async def _persist_signal_attachments_from_ingress( + self, + *, + signal_id: Any, + attachments: List[Dict[str, Any]], + ) -> None: + if not attachments: + return + try: + signal_int = int(str(signal_id).strip()) + except (TypeError, ValueError, AttributeError): + return + signal = ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == int(self.tenant_id or 1), + Signal.id == signal_int, + ) + ) + ) + ).scalar_one_or_none() + if signal is None: + return + + try: + payload = signal.strategic_analysis + strategic: Dict[str, Any] = {} + if isinstance(payload, dict): + strategic = dict(payload) + elif isinstance(payload, str) and payload.strip(): + parsed = json.loads(payload) + if isinstance(parsed, dict): + strategic = parsed + + source_context = strategic.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + existing = source_context.get("attachments") + existing_rows = existing if isinstance(existing, list) else [] + merged = self._normalize_ingress_attachment_records( + [*(row for row in existing_rows if isinstance(row, dict)), *attachments] + ) + if not merged: + return + source_context["attachments"] = merged + strategic["source_context"] = source_context + signal.strategic_analysis = json.dumps(strategic, ensure_ascii=False) + await self.db.commit() + except Exception as exc: + print(f"[Feishu] persist signal attachments failed: signal_id={signal_int}, error={exc}") + try: + await self.db.rollback() + except Exception: + pass + + async def send_message_to_group( + self, + chat_id: str, + message: str + ) -> Dict[str, Any]: + """ + 发送消息到飞书群聊 + + 用于推送通知、简报等 + """ + if not self.is_configured(): + raise ValueError("飞书未配置") + + # 获取 tenant_access_token + access_token = await self._get_tenant_access_token() + + # 发送消息 API + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + message_data = { + 'receive_id': chat_id, + 'msg_type': 'text', + 'content': json.dumps({'text': message}) + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post( + url, + headers=headers, + params={'receive_id_type': 'chat_id'}, + json=message_data, + ) + payload: Dict[str, Any] + try: + payload = resp.json() + except Exception: + payload = {"http_status": resp.status_code, "raw": resp.text[:500]} + if resp.status_code >= 400: + raise ValueError( + f"飞书发送失败(http={resp.status_code}): {payload}" + ) + if int(payload.get("code", -1)) != 0: + raise ValueError( + f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" + ) + return payload + + async def _get_tenant_access_token(self) -> str: + """获取 tenant_access_token""" + if not self.app_id or not self.app_secret: + raise ValueError("飞书未配置") + + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + headers = {'Content-Type': 'application/json'} + data = { + 'app_id': self.app_id, + 'app_secret': self.app_secret + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + try: + resp = await client.post(url, headers=headers, json=data) + except httpx.RequestError as exc: + raise ValueError(f"访问飞书 auth 接口失败: {exc}") from exc + + try: + result = resp.json() + except Exception: + result = {"http_status": resp.status_code, "raw": resp.text[:500]} + + if resp.status_code >= 400: + raise ValueError( + f"获取 tenant_access_token 失败(http={resp.status_code}): {result}" + ) + + if result.get('code') == 0 and result.get('tenant_access_token'): + return result.get('tenant_access_token') + + raise ValueError( + "获取 tenant_access_token 失败: " + f"code={result.get('code')} msg={result.get('msg') or result.get('message')} payload={result}" + ) + + async def download_attachment_bytes(self, attachment: Dict[str, Any]) -> Dict[str, Any]: + """通过飞书开放平台代理下载附件。""" + if not self.is_configured(): + raise ValueError("飞书未配置") + access_token = await self._get_tenant_access_token() + attachment_type = str(attachment.get("attachment_type") or "file").strip().lower() + file_name = str(attachment.get("file_name") or "").strip() or None + media_type = str(attachment.get("mime_type") or "").strip() or None + + image_key = str(attachment.get("image_key") or "").strip() + file_key = str(attachment.get("file_key") or "").strip() + media_id = str(attachment.get("media_id") or "").strip() + download_url = str(attachment.get("download_url") or "").strip() + + url = "" + if download_url: + url = download_url + elif attachment_type == "image" and image_key: + url = f"https://open.feishu.cn/open-apis/im/v1/images/{image_key}" + elif file_key: + url = f"https://open.feishu.cn/open-apis/im/v1/files/{file_key}" + elif media_id: + url = f"https://open.feishu.cn/open-apis/im/v1/files/{media_id}" + + if not url: + raise ValueError("附件缺少可下载 key,无法代理下载") + + headers = {"Authorization": f"Bearer {access_token}"} + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + try: + resp = await client.get(url, headers=headers, follow_redirects=True) + except httpx.RequestError as exc: + raise ValueError(f"飞书附件下载请求失败: {exc}") from exc + + if resp.status_code >= 400: + body_preview = resp.text[:300] if resp.text else "" + raise ValueError( + f"飞书附件下载失败(http={resp.status_code}): {body_preview}" + ) + + content = resp.content or b"" + if not content: + raise ValueError("飞书附件下载为空内容") + + response_content_type = str(resp.headers.get("content-type") or "").strip() + content_type = media_type or response_content_type or "application/octet-stream" + return { + "content": content, + "content_type": content_type, + "file_name": file_name, + } + + async def send_rich_card( + self, + chat_id: str, + title: str, + content: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 发送富文本卡片消息 + + 用于发送格式化的简报、通知等 + """ + if not self.is_configured(): + raise ValueError("飞书未配置") + + access_token = await self._get_tenant_access_token() + + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + # 构造卡片内容 + card_content = { + 'config': {'wide_screen_mode': True}, + 'header': { + 'title': { + 'content': title, + 'tag': 'plain_text' + } + }, + 'elements': [] + } + + for item in content: + if item.get('type') == 'text': + card_content['elements'].append({ + 'tag': 'div', + 'text': { + 'content': item.get('text', ''), + 'tag': 'lark_md' + } + }) + elif item.get('type') == 'divider': + card_content['elements'].append({'tag': 'hr'}) + + message_data = { + 'receive_id': chat_id, + 'msg_type': 'interactive', + 'content': json.dumps(card_content) + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post( + url, + headers=headers, + params={'receive_id_type': 'chat_id'}, + json=message_data, + ) + payload: Dict[str, Any] + try: + payload = resp.json() + except Exception: + payload = {"http_status": resp.status_code, "raw": resp.text[:500]} + if resp.status_code >= 400: + raise ValueError( + f"飞书发送失败(http={resp.status_code}): {payload}" + ) + if int(payload.get("code", -1)) != 0: + raise ValueError( + f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" + ) + return payload + + +# 全局服务实例 +_feishu_service: Optional[FeishuIntegrationService] = None + + +async def dispatch_pending_feishu_replies(limit: int = 30) -> int: + """后台补偿发送:扫描 pending/retrying outbox 并触发派发。""" + return await FeishuIntegrationService.dispatch_pending_outbox_entries(limit=limit) + + +def get_feishu_service(db: AsyncSession) -> FeishuIntegrationService: + """获取飞书服务实例""" + return FeishuIntegrationService(db) diff --git a/backend/app/services/feishu_long_connection.py b/backend/app/services/feishu_long_connection.py new file mode 100644 index 00000000..44e50544 --- /dev/null +++ b/backend/app/services/feishu_long_connection.py @@ -0,0 +1,546 @@ +"""Feishu 长连接事件接收管理器。""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +import importlib.util +import json +import os +import threading +import time +from typing import Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.future import select + +from ..core.logging import get_logger +from ..models import Integration +from .feishu_integration import FeishuIntegrationService + +logger = get_logger(__name__) +DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS = 3 +DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS = 1.0 + + +class FeishuLongConnectionManager: + """管理飞书长连接 SDK 生命周期与事件消费。""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._worker_task: Optional[asyncio.Task[None]] = None + self._supervisor_task: Optional[asyncio.Task[None]] = None + self._thread: Optional[threading.Thread] = None + self._client: Any = None + self._boot_lock = asyncio.Lock() + + self._running = False + self._sdk_available = self._check_sdk_available() + self._configured = False + self._event_mode = "long_connection" + self._tenant_id = 1 + self._last_error: Optional[str] = None + self._last_error_code: Optional[str] = None + self._last_error_retryable = False + self._last_event_at: Optional[str] = None + self._last_auth_at: Optional[str] = None + self._last_connect_at: Optional[str] = None + self._last_disconnect_at: Optional[str] = None + self._restart_attempts = 0 + self._next_retry_at = 0.0 + self._stopping = False + self._max_retry_attempts = self._read_retry_max_attempts() + self._retry_base_seconds = self._read_retry_base_seconds() + + async def start(self) -> None: + """启动长连接(若未配置或未安装 SDK 则仅记录状态)。""" + if self._running: + return + + self._stopping = False + self._running = True + self._loop = asyncio.get_running_loop() + self._worker_task = asyncio.create_task( + self._event_worker(), + name="feishu-long-connection-worker", + ) + self._supervisor_task = asyncio.create_task( + self._connection_supervisor(), + name="feishu-long-connection-supervisor", + ) + + started = await self._ensure_connection(reason="startup") + if started: + logger.info("feishu_long_connection_started", tenant_id=self._tenant_id, event_mode=self._event_mode) + + async def stop(self) -> None: + """停止长连接。""" + self._stopping = True + self._running = False + + current_client = self._client + if current_client is not None: + try: + await asyncio.to_thread(self._stop_client_blocking, current_client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_stop_failed: {exc}") + + self._client = None + + if self._supervisor_task: + self._supervisor_task.cancel() + try: + await self._supervisor_task + except asyncio.CancelledError: + pass + self._supervisor_task = None + + if self._worker_task: + self._worker_task.cancel() + try: + await self._worker_task + except asyncio.CancelledError: + pass + self._worker_task = None + + if self._thread and self._thread.is_alive(): + await asyncio.to_thread(self._thread.join, 3) + self._thread = None + + while not self._queue.empty(): + try: + self._queue.get_nowait() + self._queue.task_done() + except asyncio.QueueEmpty: + break + + logger.info("feishu_long_connection_stopped") + self._stopping = False + + async def restart(self) -> None: + """重启长连接(用于配置变更后重连)。""" + await self.stop() + await self.start() + + def status(self) -> dict[str, Any]: + """返回当前运行状态,供 API 展示。""" + return { + "running": self._running, + "tenant_id": self._tenant_id, + "event_mode": self._event_mode, + "sdk_available": self._sdk_available, + "configured": self._configured, + "worker_alive": bool(self._worker_task and not self._worker_task.done()), + "supervisor_alive": bool(self._supervisor_task and not self._supervisor_task.done()), + "thread_alive": bool(self._thread and self._thread.is_alive()), + "last_error": self._last_error, + "last_error_code": self._last_error_code, + "last_error_retryable": self._last_error_retryable, + "last_event_at": self._last_event_at, + "last_auth_at": self._last_auth_at, + "last_connect_at": self._last_connect_at, + "last_disconnect_at": self._last_disconnect_at, + "restart_attempts": self._restart_attempts, + "next_retry_at": ( + datetime.fromtimestamp(self._next_retry_at, tz=timezone.utc).isoformat() + if self._next_retry_at > 0 + else None + ), + "retry_policy": { + "max_attempts": self._max_retry_attempts, + "base_seconds": self._retry_base_seconds, + }, + } + + def _check_sdk_available(self) -> bool: + # 只做可用性探测,避免在主线程提前 import lark_oapi, + # 否则 SDK 会把全局 event loop 绑定到 uvicorn loop,导致线程内 run_until_complete 报错。 + return importlib.util.find_spec("lark_oapi") is not None + + @staticmethod + def _read_retry_max_attempts() -> int: + raw = os.getenv( + "FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS", + str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS), + ) + try: + parsed = int(str(raw).strip()) + except Exception: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS + # 强制最多 3 次,避免无限重连雪崩。 + return min(3, max(1, parsed)) + + @staticmethod + def _read_retry_base_seconds() -> float: + raw = os.getenv( + "FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS", + str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS), + ) + try: + parsed = float(str(raw).strip()) + except Exception: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS + if parsed <= 0: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS + return parsed + + @staticmethod + def _stop_client_blocking(client: Any) -> None: + stop_fn = getattr(client, "stop", None) + if callable(stop_fn): + stop_fn() + + async def _load_active_config(self) -> Optional[dict[str, Any]]: + async with self._session_factory() as db: + result = await db.execute( + select(Integration).where( + Integration.type == "feishu", + Integration.is_active.is_(True), + ).order_by(Integration.updated_at.desc(), Integration.id.desc()) + ) + integration = result.scalars().first() + if not integration: + return None + + config = integration.config + parsed: dict[str, Any] + if isinstance(config, str): + try: + parsed = json.loads(config) if config else {} + except Exception: + parsed = {} + else: + parsed = config or {} + parsed["tenant_id"] = int(getattr(integration, "tenant_id", 1) or 1) + return parsed + + async def _ensure_connection(self, *, reason: str) -> bool: + async with self._boot_lock: + if not self._running: + return False + if self._thread and self._thread.is_alive(): + return True + if self._client is not None: + try: + await asyncio.to_thread(self._stop_client_blocking, self._client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_stale_client_stop_failed: {exc}") + finally: + self._client = None + + config = await self._load_active_config() + self._event_mode = "long_connection" + self._tenant_id = int((config or {}).get("tenant_id") or 1) + app_id = str((config or {}).get("app_id") or "").strip() + app_secret = str((config or {}).get("app_secret") or "").strip() + self._configured = bool(app_id and app_secret) + + if not self._configured: + self._clear_error() + logger.info("feishu_long_connection_not_configured") + return False + + if not self._sdk_available: + self._set_error("lark_oapi not installed", code="sdk_missing", retryable=False) + logger.warning("feishu_long_connection_sdk_missing: pip install lark-oapi") + return False + + try: + await self._preflight_auth(tenant_id=self._tenant_id) + self._last_auth_at = datetime.now(timezone.utc).isoformat() + except Exception as exc: + code, retryable = self._classify_error(exc) + self._set_error(str(exc), code=code, retryable=retryable) + if retryable: + self._schedule_retry() + logger.warning( + "feishu_long_connection_preflight_failed", + tenant_id=self._tenant_id, + error_code=code, + retryable=retryable, + reason=reason, + error=str(exc), + ) + return False + + self._clear_error() + self._last_connect_at = datetime.now(timezone.utc).isoformat() + self._last_disconnect_at = None + self._restart_attempts = 0 + self._next_retry_at = 0.0 + self._start_ws_thread(app_id, app_secret) + return True + + async def _preflight_auth(self, *, tenant_id: int) -> None: + async with self._session_factory() as db: + service = FeishuIntegrationService(db) + await service.load_config(tenant_id=tenant_id) + await service._get_tenant_access_token() + + async def _connection_supervisor(self) -> None: + while self._running: + try: + await asyncio.sleep(3) + except asyncio.CancelledError: + break + + if not self._running or self._stopping: + continue + if not self._configured or self._event_mode != "long_connection": + continue + if not self._sdk_available: + continue + if self._thread and self._thread.is_alive(): + continue + if self._last_error_code and not self._last_error_retryable: + continue + + now = time.time() + if self._next_retry_at > now: + continue + + await self._ensure_connection(reason="supervisor") + + @staticmethod + def _classify_error(exc: Exception) -> tuple[str, bool]: + text = str(exc or "").strip().lower() + if "401" in text or "403" in text or "unauthorized" in text or "invalid app" in text: + return "auth_failed", False + if any(marker in text for marker in ("timed out", "timeout", "econnreset", "connection reset", "network", "dns", "temporary failure")): + return "network_error", True + if "certificate" in text or "ssl" in text or "tls" in text: + return "network_error", True + return "client_error", True + + def _set_error(self, message: str, *, code: str, retryable: bool) -> None: + self._last_error = str(message or "").strip() or None + self._last_error_code = code + self._last_error_retryable = retryable + + def _clear_error(self) -> None: + self._last_error = None + self._last_error_code = None + self._last_error_retryable = False + + def _schedule_retry(self) -> None: + if self._restart_attempts >= self._max_retry_attempts: + self._last_error_retryable = False + self._next_retry_at = 0.0 + logger.error( + "feishu_long_connection_retry_exhausted", + tenant_id=self._tenant_id, + attempts=self._restart_attempts, + max_attempts=self._max_retry_attempts, + last_error_code=self._last_error_code, + last_error=self._last_error, + ) + return + self._restart_attempts += 1 + backoff = self._retry_base_seconds * (2 ** max(0, self._restart_attempts - 1)) + self._next_retry_at = time.time() + backoff + + def _start_ws_thread(self, app_id: str, app_secret: str) -> None: + self._thread = threading.Thread( + target=self._run_ws_client, + args=(app_id, app_secret), + name="feishu-long-connection", + daemon=True, + ) + self._thread.start() + + def _run_ws_client(self, app_id: str, app_secret: str) -> None: + """在独立线程中运行 SDK 客户端(SDK start 是阻塞调用)。""" + try: + import lark_oapi as lark # type: ignore + + builder = lark.EventDispatcherHandler.builder("", "") + register_receive = getattr(builder, "register_p2_im_message_receive_v1", None) + if not callable(register_receive): + raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") + builder = register_receive(self._on_message_event) + + # 明确注册高频非业务事件,避免 SDK 抛 processor not found 干扰可观测性。 + register_read = getattr(builder, "register_p2_im_message_message_read_v1", None) + if callable(register_read): + builder = register_read(self._on_ignored_event) + register_reaction_create = getattr(builder, "register_p2_im_message_reaction_created_v1", None) + if callable(register_reaction_create): + builder = register_reaction_create(self._on_ignored_event) + register_reaction_delete = getattr(builder, "register_p2_im_message_reaction_deleted_v1", None) + if callable(register_reaction_delete): + builder = register_reaction_delete(self._on_ignored_event) + register_recalled = getattr(builder, "register_p2_im_message_recalled_v1", None) + if callable(register_recalled): + builder = register_recalled(self._on_ignored_event) + + event_handler = builder.build() + self._client = lark.ws.Client( + app_id, + app_secret, + event_handler=event_handler, + log_level=lark.LogLevel.INFO, + ) + self._client.start() + except Exception as exc: # pragma: no cover + code, retryable = self._classify_error(exc) + self._set_error(str(exc), code=code, retryable=retryable) + if retryable and self._running and not self._stopping: + self._schedule_retry() + logger.error( + "feishu_long_connection_client_failed", + tenant_id=self._tenant_id, + error_code=code, + retryable=retryable, + error=str(exc), + ) + finally: + if self._client is not None: + try: + self._stop_client_blocking(self._client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}") + self._client = None + if self._running and not self._stopping: + self._last_disconnect_at = datetime.now(timezone.utc).isoformat() + if not self._last_error_code: + self._set_error("feishu long connection thread exited", code="thread_exited", retryable=True) + self._schedule_retry() + logger.warning("feishu_long_connection_thread_exited", tenant_id=self._tenant_id) + self._thread = None + + def _on_message_event(self, data: Any) -> None: + """SDK 回调:将事件安全投递到主事件循环。""" + if not self._running or not self._loop: + return + + try: + payload = self._to_dict(data) + message_id = "" + event_type = "" + try: + event = payload.get("event", {}) if isinstance(payload, dict) else {} + header = payload.get("header", {}) if isinstance(payload, dict) else {} + message = event.get("message", {}) if isinstance(event, dict) else {} + message_id = str(message.get("message_id") or "").strip() + event_type = str( + (header.get("event_type") if isinstance(header, dict) else "") + or payload.get("event_type") + or payload.get("type") + or "" + ).strip() + except Exception: + pass + logger.info( + "feishu_long_connection_event_received: " + f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}" + ) + self._loop.call_soon_threadsafe(self._queue.put_nowait, payload) + except Exception as exc: # pragma: no cover + self._set_error(str(exc), code="enqueue_error", retryable=True) + logger.warning(f"feishu_long_connection_enqueue_failed: {exc}") + + def _on_ignored_event(self, data: Any) -> None: + """显式忽略非业务事件,避免 SDK 报 processor not found。""" + if not self._running: + return + payload = self._to_dict(data) + event_type = "" + try: + header = payload.get("header", {}) if isinstance(payload, dict) else {} + event_type = str( + (header.get("event_type") if isinstance(header, dict) else "") + or payload.get("event_type") + or payload.get("type") + or "" + ).strip() + except Exception: + pass + logger.info(f"feishu_long_connection_event_ignored: event_type={event_type or 'unknown'}") + + async def _event_worker(self) -> None: + while self._running: + try: + payload = await self._queue.get() + except asyncio.CancelledError: + break + + try: + await self._process_payload(payload) + self._last_event_at = datetime.now(timezone.utc).isoformat() + except Exception as exc: + self._set_error(str(exc), code="event_process_failed", retryable=True) + logger.warning(f"feishu_long_connection_process_failed: {exc}") + finally: + self._queue.task_done() + + async def _process_payload(self, payload: dict[str, Any]) -> None: + event_data = self._extract_event_data(payload) + if not event_data: + logger.info("feishu_long_connection_event_ignored: empty_event") + return + + event_type = str(event_data.get("type") or "").strip() + if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}: + logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}") + return + + async with self._session_factory() as db: + service = FeishuIntegrationService(db) + await service.load_config(tenant_id=self._tenant_id) + await service.handle_message_event(event_data) + + def _extract_event_data(self, payload: dict[str, Any]) -> dict[str, Any]: + event_data = payload.get("event") + if not isinstance(event_data, dict): + event_data = {} + + header = payload.get("header") + event_type: Optional[str] = None + if isinstance(header, dict): + event_type = header.get("event_type") + if not event_type: + event_type = payload.get("event_type") or payload.get("type") + + if event_type: + event_data = {**event_data, "type": event_type} + + if event_data: + event_data = {**event_data, "_ingress_source": "long_connection"} + + return event_data + + def _to_dict(self, value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [self._to_dict(item) for item in value] + if isinstance(value, dict): + return {k: self._to_dict(v) for k, v in value.items()} + to_dict = getattr(value, "to_dict", None) + if callable(to_dict): + return self._to_dict(to_dict()) + if hasattr(value, "__dict__"): + return { + k: self._to_dict(v) + for k, v in value.__dict__.items() + if not k.startswith("_") + } + return str(value) + + +_manager: Optional[FeishuLongConnectionManager] = None + + +def init_feishu_long_connection_manager( + session_factory: async_sessionmaker[AsyncSession], +) -> FeishuLongConnectionManager: + global _manager + if _manager is None: + _manager = FeishuLongConnectionManager(session_factory) + return _manager + + +def get_feishu_long_connection_manager() -> Optional[FeishuLongConnectionManager]: + return _manager diff --git a/backend/app/services/feishu_signal_pipeline.py b/backend/app/services/feishu_signal_pipeline.py new file mode 100644 index 00000000..407608eb --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline.py @@ -0,0 +1,5986 @@ +"""飞书消息分流与决策队列服务。""" +from __future__ import annotations + +import json +import hashlib +import re +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import and_, delete, func, or_, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import ( + PROMPT_FEISHU_LEVEL_CLASSIFICATION, + PROMPT_FEISHU_PROJECT_PREFILL, + PROMPT_FEISHU_SIGNAL_EXTRACTION, +) +from ..core.logging import get_logger +from ..models import ( + FeishuItem, + AIProcessingLog, + FeishuReplyOutbox, + Integration, + Project, + ProjectSignalLink, + ProjectSuggestion, + Signal, + SignalEdit, + User, +) +from .llm import LLMService +from .adaptive_rag_service import AdaptiveRAGService +from .notification_service import NotificationService +from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence + +logger = get_logger(__name__) + +PROJECT_PRIORITY_ORDER = { + "P0": 4, + "P1": 3, + "P2": 2, + "P3": 1, + "high": 3, + "medium": 2, + "low": 1, +} + +SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"} +PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE) +SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE) +TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)") +OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)") +INDEPENDENT_REQUIREMENT_RE = re.compile( + r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)", + re.IGNORECASE, +) +SIMPLE_SIGNAL_REQUEST_RE = re.compile( + r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)", + re.IGNORECASE, +) +PROJECT_COMPLEXITY_RE = re.compile( + r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)", + re.IGNORECASE, +) +QUESTION_REQUIREMENT_VERB_RE = re.compile( + r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)", + re.IGNORECASE, +) +QUESTION_REQUIREMENT_OBJECT_RE = re.compile( + r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)", + re.IGNORECASE, +) +PROJECT_LIST_QUERY_RE = re.compile( + r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)", + re.IGNORECASE, +) +PROJECT_ID_QUERY_RE = re.compile( + r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", + re.IGNORECASE, +) +OPENCLAW_PROJECT_CREATE_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", + re.IGNORECASE, +) +OPENCLAW_COMMAND_VERB_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", + re.IGNORECASE, +) +OPENCLAW_COMMAND_OBJECT_RE = re.compile( + r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", + re.IGNORECASE, +) +FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE) +FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE) +FOLLOWUP_STATUS_RE = re.compile( + r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)", + re.IGNORECASE, +) +FOLLOWUP_REFERENCE_RE = re.compile( + r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)", + re.IGNORECASE, +) +OBVIOUS_QUESTION_RE = re.compile( + r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|在哪|哪一个|哪几个)", + re.IGNORECASE, +) +FEISHU_BOT_MENTION_RE = re.compile(r"(?:]*>[^<]*|@pmflow\b|@_user_1\b)", re.IGNORECASE) +FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*") +FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") +FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE) +EXTERNAL_INTEGRATION_RE = re.compile( + r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", + re.IGNORECASE, +) +CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE) +TASK_CREATE_RE = re.compile( + r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务", + re.IGNORECASE, +) +PROJECT_CREATE_GATE_RE = re.compile( + r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)", + re.IGNORECASE, +) +HEALTHCHECK_RE = re.compile( + r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$", + re.IGNORECASE, +) +SHORT_CONFIRM_RE = re.compile( + r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$", + re.IGNORECASE, +) +CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE) +CORRECTION_CUE_RE = re.compile( + r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)", + re.IGNORECASE, +) + +ROUTING_AUTO_CREATE_PROJECT = "auto_create_project" +ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal" +ROUTING_PENDING_LOW_CONFIDENCE = "pending_low_confidence" +ROUTING_PENDING_COLD_START = "pending_cold_start" +ROUTING_PENDING_UNCERTAIN_PROJECT = "pending_uncertain_project" +ROUTING_DUPLICATE_FILTERED = "duplicate_filtered" +ROUTING_NOISE_FILTERED = "noise_filtered" +ROUTING_FAILED = "failed" +ROUTING_DIRECT_QA = "direct_qa" +ROUTING_OPENCLAW_EXECUTED = "openclaw_executed" +ROUTING_OPENCLAW_BLOCKED = "openclaw_blocked" +ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query" +ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm" +ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked" +ROUTING_INTENT_GATE_QUERY = "intent_gate_query" +ROUTING_INTENT_GATE_HEALTHCHECK = "intent_gate_healthcheck" +ROUTING_INTENT_GATE_CLARIFICATION = "intent_gate_clarification" +ROUTING_INTENT_GATE_EXTERNAL_CONNECT = "intent_gate_external_connect" +ROUTING_OPERATION_COMMITTED = "operation_committed" +ROUTING_OPERATION_FAILED = "operation_failed" + +INTENT_QUERY = "query" +INTENT_SMALLTALK = "smalltalk" +INTENT_HEALTHCHECK = "healthcheck" +INTENT_CREATE_PROJECT = "create_project" +INTENT_CREATE_TASK = "create_task" +INTENT_SIGNAL_INTAKE = "signal_intake" +INTENT_EXTERNAL_CONNECT = "external_connect" +INTENT_CLARIFICATION_REQUIRED = "clarification_required" + +ROUTE_STATUS_CLASSIFIED = "classified" +ROUTE_STATUS_QUEUED = "queued_for_decision" +ROUTE_STATUS_AUTO_RUNNING = "auto_running" +ROUTE_STATUS_AUTO_DONE = "auto_done" +ROUTE_STATUS_FAILED = "failed" +ROUTE_STATUS_DISMISSED = "dismissed" +ROUTE_STATUS_UNDONE = "undone" + +ACTIVE_ITEM_ROUTE_STATUSES = { + ROUTE_STATUS_CLASSIFIED, + ROUTE_STATUS_QUEUED, + ROUTE_STATUS_AUTO_RUNNING, + "pending", +} +AUTO_DONE_ITEM_ROUTE_STATUSES = { + ROUTE_STATUS_AUTO_DONE, + "auto_processed", + "confirmed", +} + +DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 +DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 +DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 + + +class _LevelSplitItemSchema(BaseModel): + level: Literal["project", "signal"] + content: str = Field(min_length=1, max_length=4000) + + +class _LevelClassificationSchema(BaseModel): + build_intent_score: float = Field(ge=0.0, le=1.0) + scope_score: float = Field(ge=0.0, le=1.0) + timeline_score: float = Field(ge=0.0, le=1.0) + owner_score: float = Field(ge=0.0, le=1.0) + project_score: float = Field(ge=0.0, le=1.0) + level: Literal["project", "signal"] + level_confidence: float = Field(ge=0.0, le=1.0) + level_reasoning: str = Field(min_length=1, max_length=600) + should_split: bool = False + split_items: list[_LevelSplitItemSchema] = Field(default_factory=list, max_length=2) + + +class _SignalExtractionSchema(BaseModel): + title: str = Field(min_length=1, max_length=200) + type: Literal["竞品动态", "用户反馈", "项目风险", "新需求"] + priority: Literal["P0", "P1", "P2", "P3"] + matched_project_id: Optional[int] = None + summary: str = Field(min_length=1, max_length=3000) + reasoning: list[str] = Field(min_length=1, max_length=3) + confidence: float = Field(ge=0.0, le=1.0) + recommended_action: Literal["enter_only", "create_charter", "trigger_alert"] + + +class _ProjectPrefillSchema(BaseModel): + suggested_name: str = Field(min_length=1, max_length=255) + suggested_description: str = Field(min_length=1, max_length=4000) + suggested_owner: str = Field(min_length=1, max_length=120) + suggested_timeline: str = Field(min_length=1, max_length=120) + confidence: float = Field(ge=0.0, le=1.0) + reasoning: list[str] = Field(min_length=1, max_length=3) + + +def _normalize_reasoning(value: Any, max_items: int = 3) -> list[str]: + if isinstance(value, list): + rows = [str(x).strip() for x in value if str(x).strip()] + return rows[:max_items] + if isinstance(value, str): + parts = [p.strip(" -•\t") for p in value.splitlines() if p.strip()] + return parts[:max_items] + return [] + + +def _safe_json_object(value: str) -> dict[str, Any]: + raw = (value or "").strip() + if raw.startswith("```"): + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _validate_schema( + schema: type[BaseModel], + payload: dict[str, Any], + *, + context: str, +) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError(f"{context}_schema_invalid: payload_not_object") + try: + if hasattr(schema, "model_validate"): + model = schema.model_validate(payload) # type: ignore[attr-defined] + else: + model = schema.parse_obj(payload) # type: ignore[attr-defined] + except ValidationError as exc: + raise ValueError(f"{context}_schema_invalid: {str(exc)[:400]}") from exc + if hasattr(model, "model_dump"): + return model.model_dump() # type: ignore[no-any-return] + return model.dict() # type: ignore[no-any-return] + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float(default) + + +def _safe_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on", "是", "真"}: + return True + if text in {"0", "false", "no", "n", "off", "否", "假"}: + return False + return default + + +def _safe_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _normalize_level_value(value: Any, default: str = "signal") -> str: + text = str(value or "").strip().lower() + if not text: + return default + if text in {"project", "project-level", "project_level", "new_project"}: + return "project" + if text in {"signal", "signal-level", "signal_level"}: + return "signal" + if text in {"项目", "项目级", "立项", "新项目"}: + return "project" + if text in {"信号", "信号级", "需求", "需求级", "事件"}: + return "signal" + if "project" in text or "项目" in text or "立项" in text: + return "project" + if "signal" in text or "信号" in text or "需求" in text: + return "signal" + return default + + +def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key not in data: + continue + value = data.get(key) + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + return value + return None + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _compact_text(value: Any, max_len: int = 160) -> str: + text = str(value or "").strip() + if len(text) <= max_len: + return text + return f"{text[: max_len - 3]}..." + + +def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + return _safe_json_object(value) + return {} + + +def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is not None: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + return value + timedelta(minutes=minutes) + + +class FeishuSignalPipelineService: + """飞书消息的两段式分流与路由。""" + + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + self.llm = LLMService() + + @staticmethod + def _normalize_route_status(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {ROUTE_STATUS_CLASSIFIED, ROUTE_STATUS_QUEUED, ROUTE_STATUS_AUTO_RUNNING, ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_FAILED, ROUTE_STATUS_DISMISSED, ROUTE_STATUS_UNDONE}: + return text + if text == "pending": + return ROUTE_STATUS_QUEUED + if text in {"auto_processed", "confirmed"}: + return ROUTE_STATUS_AUTO_DONE + if text == "dismissed": + return ROUTE_STATUS_DISMISSED + return text or ROUTE_STATUS_FAILED + + @classmethod + def _is_active_route_status(cls, value: Any) -> bool: + return cls._normalize_route_status(value) in { + ROUTE_STATUS_CLASSIFIED, + ROUTE_STATUS_QUEUED, + ROUTE_STATUS_AUTO_RUNNING, + } + + @staticmethod + def _is_healthcheck_message(message: str) -> bool: + return bool(HEALTHCHECK_RE.search(str(message or "").strip())) + + @staticmethod + def _is_short_confirmation_message(message: str) -> bool: + return bool(SHORT_CONFIRM_RE.search(str(message or "").strip())) + + @staticmethod + def _is_correction_message(message: str) -> bool: + return bool(CORRECTION_CUE_RE.search(str(message or "").strip())) + + @staticmethod + def _looks_like_continue_followup(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if FOLLOWUP_CREATE_CONTINUE_RE.search(text): + return bool(FOLLOWUP_REFERENCE_RE.search(text)) + return bool(FOLLOWUP_CONTINUE_RE.search(text)) + + @staticmethod + def _looks_like_task_create_request(message: str) -> bool: + return bool(TASK_CREATE_RE.search(str(message or "").strip())) + + @staticmethod + def _looks_like_project_create_request(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if PROJECT_CREATE_GATE_RE.search(text): + return True + return bool(OPENCLAW_PROJECT_CREATE_RE.search(text)) + + @staticmethod + def _looks_like_external_connect_request(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text)) + + @staticmethod + def _can_execute_high_value_action(user: Optional[User]) -> bool: + if user is None: + return False + return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"} + + @staticmethod + def _stable_positive_int(*parts: Any) -> int: + raw = "::".join(str(part or "").strip() for part in parts if str(part or "").strip()) + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() + return int(digest[:12], 16) + + @staticmethod + def _normalize_key_fragment(value: str, *, max_len: int = 80) -> str: + normalized = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").strip().lower()) + return normalized[:max_len] + + @classmethod + def _build_intent_fragment(cls, message: str) -> str: + text = str(message or "").strip() + if not text: + return "generic" + if EXTERNAL_INTEGRATION_RE.search(text): + return "external_integration" + if CROSS_SYSTEM_RE.search(text): + return "cross_system" + if PROJECT_COMPLEXITY_RE.search(text): + return "project_setup" + if SIMPLE_SIGNAL_REQUEST_RE.search(text): + return "feature_change" + fragment = cls._normalize_key_fragment(text, max_len=64) + return fragment or "generic" + + def _build_queue_dedupe_key( + self, + *, + level: str, + intent: Optional[str], + message: str, + source_chat_id: str, + matched_project_id: Optional[int], + source_object: Optional[str] = None, + ) -> str: + project_key = str(int(matched_project_id)) if matched_project_id is not None else "global" + source_key = self._normalize_key_fragment(source_chat_id or "direct", max_len=48) or "direct" + intent_key = self._normalize_key_fragment(str(intent or "").strip().lower(), max_len=48) or self._build_intent_fragment(message) + object_key = self._normalize_key_fragment(source_object or message, max_len=64) or "generic" + return f"{level}:{project_key}:{source_key}:{intent_key}:{object_key}"[:255] + + async def _find_active_dedupe_item(self, dedupe_key: Optional[str]) -> Optional[FeishuItem]: + normalized_key = (dedupe_key or "").strip() + if not normalized_key: + return None + return ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.dedupe_key == normalized_key, + FeishuItem.route_status.in_(tuple(ACTIVE_ITEM_ROUTE_STATUSES)), + ) + ) + .order_by(FeishuItem.updated_at.desc(), FeishuItem.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + async def _build_existing_item_result(self, item: FeishuItem) -> dict[str, Any]: + level = str(item.level or "").lower() + result: dict[str, Any] = { + "item_id": int(item.id), + "level": level, + "project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + "signal_id": None, + "route_status": self._normalize_route_status(item.route_status), + "clarification_message": item.blocking_reason or None, + } + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion and suggestion.created_project_id: + result["project_id"] = int(suggestion.created_project_id) + return result + + if level == "command": + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + project_id = _safe_int(payload.get("project_id") or item.matched_project_id) + if project_id is not None: + result["project_id"] = int(project_id) + return result + + signal = await self._get_signal_by_item(item.id) + if signal: + result["signal_id"] = int(signal.id) + if signal.matched_project_id is not None: + result["project_id"] = int(signal.matched_project_id) + return result + + async def _resolve_intent_gate( + self, + *, + message: str, + inferred_project_id: Optional[int], + source_chat_id: str = "", + source_sender_open_id: Optional[str] = None, + sender: str = "", + ) -> dict[str, Any]: + text = str(message or "").strip() + if not text: + return { + "intent": INTENT_SMALLTALK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": None, + } + + correction_match: Optional[dict[str, Any]] = None + if self.db is not None and len(text) >= 4: + try: + recall_rows = await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).retrieve_intent_correction_samples( + utterance=text, + top_k=1, + project_id=inferred_project_id, + ) + if recall_rows: + correction_match = recall_rows[0] + except Exception as exc: + logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") + + if self._is_healthcheck_message(text): + return { + "intent": INTENT_HEALTHCHECK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + if self._is_short_confirmation_message(text): + return { + "intent": INTENT_SMALLTALK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + + if correction_match and float(correction_match.get("score") or 0.0) >= 0.84: + gate = { + "intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(), + "canonical_action": str(correction_match.get("canonical_action") or "").strip() or None, + "clarification_template": str(correction_match.get("clarification_template") or "").strip(), + "risk_level": str(correction_match.get("risk_level") or "medium").strip().lower() or "medium", + "fields": {}, + "correction_match": correction_match, + } + if gate["intent"] not in { + INTENT_QUERY, + INTENT_SMALLTALK, + INTENT_HEALTHCHECK, + INTENT_CREATE_PROJECT, + INTENT_CREATE_TASK, + INTENT_SIGNAL_INTAKE, + INTENT_EXTERNAL_CONNECT, + INTENT_CLARIFICATION_REQUIRED, + }: + gate["intent"] = INTENT_SIGNAL_INTAKE + return gate + + if self._looks_like_project_create_request(text): + fields = self._extract_project_intent_fields(text) + fields = await self._maybe_prefill_project_fields_from_embedded_content( + message=text, + fields=fields, + ) + fields = await self._maybe_prefill_project_fields_from_context( + message=text, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + fields=fields, + ) + if fields["ready"]: + return { + "intent": INTENT_CREATE_PROJECT, + "canonical_action": "project.create", + "clarification_template": "", + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + return { + "intent": INTENT_CLARIFICATION_REQUIRED, + "canonical_action": "project.create", + "clarification_template": self._build_project_clarification_message(fields["missing_fields"]), + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + + if self._looks_like_task_create_request(text): + fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id) + if fields["ready"]: + return { + "intent": INTENT_CREATE_TASK, + "canonical_action": "task.create", + "clarification_template": "", + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + return { + "intent": INTENT_CLARIFICATION_REQUIRED, + "canonical_action": "task.create", + "clarification_template": self._build_task_clarification_message(fields["missing_fields"]), + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + + if self._looks_like_external_connect_request(text): + return { + "intent": INTENT_EXTERNAL_CONNECT, + "canonical_action": "source.connect", + "clarification_template": "外部系统接入默认进入待决策队列。请补充接入目标、授权边界、回流项目和幂等要求。", + "risk_level": "high", + "fields": {"project_id": inferred_project_id}, + "correction_match": correction_match, + } + + if self._is_obvious_question(text): + return { + "intent": INTENT_QUERY, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + + return { + "intent": INTENT_SIGNAL_INTAKE, + "canonical_action": "signal.enter", + "clarification_template": "", + "risk_level": "medium", + "fields": {"project_id": inferred_project_id}, + "correction_match": correction_match, + } + + @staticmethod + def _extract_project_scope(text: str, *, description: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + for pattern in ( + r"(?:范围|scope|面向|目标|用于|负责|服务于)[::\s]*(.+)", + r"(?:描述|背景|场景|做什么)[::\s]*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + if candidate: + return candidate[:400] + if len(description) >= 12: + return description[:400] + return "" + + def _extract_project_intent_fields(self, message: str) -> dict[str, Any]: + title = OpenClawCommanderService._extract_project_create_title(message) + if not title: + cleaned = re.sub(r"\s+", " ", str(message or "")).strip() + for pattern in ( + r"(?:名称|名字|项目名)(?:叫|是|为)?[::\s]*(.+)", + r"项目(?:叫|名为|名称叫|名称是|名称为)[::\s]*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" ,,::\"'“”") + if candidate: + title = candidate[:24] + break + description = OpenClawCommanderService._extract_project_create_description( + instruction=message, + title=title, + ) + project_scope = self._extract_project_scope(message, description=description) + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _looks_like_contextual_project_request(message: str) -> bool: + normalized = re.sub(r"\s+", "", str(message or "")).lower() + if not normalized: + return False + return any( + token in normalized + for token in ( + "根据这个", + "根据上面", + "基于这个", + "基于上面", + "按这个", + "按上面", + "汇总以上", + "汇总上面", + "以上内容", + "上面内容", + "这个汇总", + "这个总结", + "刚才内容", + ) + ) + + @staticmethod + def _strip_feishu_mention_prefix(text: str) -> str: + cleaned = re.sub(r"^]*>[^<]*\s*", "", str(text or ""), flags=re.IGNORECASE).strip() + return re.sub(r"\s+", " ", cleaned).strip() + + @staticmethod + def _looks_like_placeholder_project_text(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return True + if len(normalized) > 48: + return False + return any( + token in normalized + for token in ( + "创建个项目", + "创建一个项目", + "新建一个项目", + "新增一个项目", + "给我创建个项目", + "项目在项目列表", + "项目在pmflow", + "放进pmflow", + "放到pmflow", + "放进项目列表", + "放到项目列表", + "汇总以上内容", + "根据这个", + "根据上面", + ) + ) + + @staticmethod + def _looks_like_project_request_artifact(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + return any( + token in normalized + for token in ( + "给我创建个项目", + "给我创建一个项目", + "创建个项目", + "创建一个项目", + "新建一个项目", + "新增一个项目", + "项目在项目列表", + "项目列表@pmflow", + "进项目列表", + "放进项目列表", + "放到项目列表", + "放进pmflow", + "放到pmflow", + "根据这个汇总", + "根据上面汇总", + "汇总以上内容", + ) + ) + + @staticmethod + def _looks_like_unusable_project_context(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return True + return any( + token in normalized + for token in ( + "系统处理中出现异常", + "本次未执行", + "请稍后重试", + "requestwasaborted", + "connectionaborted", + "429", + "模型服务异常", + "无法生成可靠回答", + "无法直接给结论", + "没有输出,可能后端有问题", + "让我看看main.py", + ) + ) + + @staticmethod + def _guess_project_title_from_text(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + normalized = re.sub(r"\s+", "", cleaned).lower() + if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")): + return "十五五医药方向研究" + if "医药" in cleaned and any( + token in normalized + for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海") + ): + return "医药方向研究" + if "医疗器械" in cleaned and "国产替代" in cleaned: + return "医疗器械国产替代研究" + return "" + + @staticmethod + def _extract_embedded_project_context(message: str) -> str: + cleaned = re.sub(r"\s+", " ", str(message or "")).strip() + if len(cleaned) < 80: + return "" + markers = [] + for pattern in ( + r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目", + r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if match: + markers.append(match.start()) + if not markers: + return "" + context = cleaned[: min(markers)].strip(" ,,::") + if len(context) < 80: + return "" + return context[:4000] + + @staticmethod + def _derive_project_title_from_context(*, question: str, answer: str) -> str: + guessed = FeishuSignalPipelineService._guess_project_title_from_text(f"{question}\n{answer}".strip()) + if guessed: + return guessed + + def _normalize(raw: str) -> str: + candidate = re.sub(r"\s+", " ", str(raw or "")).strip(" ,,。.!!??::;;") + candidate = re.sub(r"^(?:请问|麻烦|帮我|我想知道|我想了解|想了解一下|目前|现在|关于|对于|针对)", "", candidate) + candidate = re.sub( + r"(?:还有哪些|有哪些|值得关注什么|值得关注哪些|值得关注|应该投什么|该投什么|该怎么做|怎么做|是什么|是啥|可以吗|吗|呢)$", + "", + candidate, + ) + candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;") + candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;") + candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;") + normalized = OpenClawCommanderService._normalize_project_title_candidate(candidate) + if normalized.endswith(("方向", "规划", "创新")): + normalized = OpenClawCommanderService._normalize_project_title_candidate(f"{normalized}研究") + return normalized + + title = _normalize(question) + if title: + return title + + cleaned_answer = re.sub(r"\s+", " ", str(answer or "")).strip() + for pattern in ( + r"((?:十五五|医药|医疗|器械|公共卫生|老龄化)[^。;;!!??]{0,16}(?:方向|规划|创新|体系))", + r"((?:[A-Za-z0-9\u4e00-\u9fff]{2,18})(?:方向|规划|创新|中台|系统|平台|治理|流程|方案|体系))", + ): + match = re.search(pattern, cleaned_answer) + if not match: + continue + title = _normalize(match.group(1)) + if title: + return title + return "" + + async def _load_recent_chat_turns( + self, + *, + source_chat_id: str, + source_sender_open_id: Optional[str], + sender: str, + limit: int = 3, + ) -> list[dict[str, str]]: + if self.db is None or not source_chat_id: + return [] + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) + if external_session_id is None: + return [] + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + messages = await rag_service.context_cache.get_session_messages( + tenant_id=self.tenant_id, + session_id=int(external_session_id), + ) + if not messages: + return [] + turns = rag_service.context_cache.messages_to_turns(messages) + return [row for row in turns if isinstance(row, dict)][-limit:] + + async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str: + if self.db is None or not source_chat_id: + return "" + rows = ( + await self.db.execute( + select(FeishuReplyOutbox) + .where( + and_( + FeishuReplyOutbox.chat_id == (source_chat_id or "")[:128], + FeishuReplyOutbox.status == "sent", + ) + ) + .order_by(FeishuReplyOutbox.id.desc()) + .limit(limit) + ) + ).scalars().all() + for row in rows: + text = self._strip_feishu_mention_prefix(str(row.message_text or "")) + if not text: + continue + if self._looks_like_unusable_project_context(text): + continue + return text[:4000] + return "" + + async def _maybe_prefill_project_fields_from_embedded_content( + self, + *, + message: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + if fields.get("ready"): + return fields + + embedded_context = self._extract_embedded_project_context(message) + if not embedded_context or self._looks_like_unusable_project_context(embedded_context): + return fields + + current_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_description) + or self._looks_like_project_request_artifact(current_description) + ): + current_description = "" + current_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_scope) + or self._looks_like_project_request_artifact(current_scope) + ): + current_scope = "" + + title = str(fields.get("title") or "").strip() or self._guess_project_title_from_text(embedded_context) + description = current_description or embedded_context[:4000] + project_scope = current_scope or self._extract_project_scope(embedded_context, description=description) + if title and description and project_scope: + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": [], + "ready": True, + } + + project_info = await self._extract_project_prefill( + f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。" + ) + suggested_name = OpenClawCommanderService._normalize_project_title_candidate( + str(project_info.get("suggested_name") or "").strip() + ) + if self._looks_like_placeholder_project_text(suggested_name): + suggested_name = "" + title = title or suggested_name + description = description or str(project_info.get("suggested_description") or "").strip()[:4000] + if self._looks_like_unusable_project_context(description): + description = "" + project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + async def _maybe_prefill_project_fields_from_context( + self, + *, + message: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + sender: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + if fields.get("ready"): + return fields + if not source_chat_id or not self._looks_like_contextual_project_request(message): + return fields + + turns = await self._load_recent_chat_turns( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + limit=3, + ) + context_blocks: list[str] = [] + question_hint = "" + answer_hint = "" + if turns: + anchor = turns[-1] + recent_question = str(anchor.get("question") or "").strip() + recent_answer = str(anchor.get("answer") or "").strip() + if recent_question: + context_blocks.append(f"最近用户问题:{recent_question}") + question_hint = recent_question + if recent_answer and not self._looks_like_unusable_project_context(recent_answer): + context_blocks.append(f"最近系统回答:{recent_answer}") + answer_hint = recent_answer + + if not context_blocks: + reply_fallback = await self._load_recent_chat_reply_fallback_text(source_chat_id=source_chat_id) + if reply_fallback: + context_blocks.append(f"最近系统回答:{reply_fallback}") + answer_hint = reply_fallback + + if not context_blocks: + return fields + + base_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(base_description) + or self._looks_like_project_request_artifact(base_description) + ): + base_description = "" + if not answer_hint and not base_description: + return fields + + heuristic_title = self._derive_project_title_from_context(question=question_hint, answer=answer_hint) + heuristic_description = base_description or answer_hint[:4000] + heuristic_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(heuristic_scope) + or self._looks_like_project_request_artifact(heuristic_scope) + ): + heuristic_scope = "" + if not heuristic_scope and heuristic_description: + heuristic_scope = self._extract_project_scope( + f"{question_hint}\n\n{answer_hint}".strip(), + description=heuristic_description, + ) + if heuristic_title and heuristic_description and heuristic_scope: + return { + "title": str(fields.get("title") or "").strip() or heuristic_title, + "description": heuristic_description, + "project_scope": heuristic_scope, + "missing_fields": [], + "ready": True, + } + + prefill_source = ( + f"当前建项目请求:{message}\n\n" + + "\n\n".join(context_blocks) + + "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。" + ) + project_info = await self._extract_project_prefill(prefill_source) + current_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_description) + or self._looks_like_project_request_artifact(current_description) + ): + current_description = "" + current_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_scope) + or self._looks_like_project_request_artifact(current_scope) + ): + current_scope = "" + title = ( + str(fields.get("title") or "").strip() + or heuristic_title + or OpenClawCommanderService._normalize_project_title_candidate(str(project_info.get("suggested_name") or "")) + ) + description = current_description or str(project_info.get("suggested_description") or "").strip() + if not description and answer_hint: + description = answer_hint[:4000] + if self._looks_like_unusable_project_context(description): + description = "" + project_scope = current_scope + if not project_scope: + scope_source = f"{description}\n\n{answer_hint}".strip() + project_scope = self._extract_project_scope(scope_source, description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + def _resolve_project_candidate_fields( + self, + *, + item: FeishuItem, + suggestion: Optional[ProjectSuggestion] = None, + ) -> dict[str, Any]: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} + title = str( + (suggestion.suggested_name if suggestion is not None else None) + or operation_payload.get("title") + or "" + ).strip() + description = str( + (suggestion.suggested_description if suggestion is not None else None) + or operation_payload.get("description") + or "" + ).strip() + project_scope = str(payload.get("project_scope") or "").strip() + if not title or not description: + extracted = self._extract_project_intent_fields(item.raw_message or "") + if not title: + title = str(extracted.get("title") or "").strip() + if not description: + description = str(extracted.get("description") or "").strip() + if not project_scope: + project_scope = str(extracted.get("project_scope") or "").strip() + if not project_scope: + project_scope = self._extract_project_scope(item.raw_message or "", description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _build_project_clarification_message(missing_fields: list[str]) -> str: + labels = { + "title": "项目名称", + "description": "项目描述", + "project_scope": "项目范围", + } + ordered = [labels[field] for field in missing_fields if field in labels] + suffix = "、".join(ordered) if ordered else "项目名称、项目描述、项目范围" + return f"未执行:当前建项目请求还缺少 {suffix}。请直接补充这三项后我再创建。" + + @classmethod + def _build_project_preview_title( + cls, + *, + title: str, + description: str, + project_scope: str, + ) -> str: + normalized_title = OpenClawCommanderService._normalize_project_title_candidate(title) + if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): + return normalized_title + + for candidate in (project_scope, description): + cleaned = str(candidate or "").strip() + if not cleaned or cls._looks_like_placeholder_project_text(cleaned): + continue + normalized = OpenClawCommanderService._normalize_project_title_candidate(cleaned[:48]) + if normalized: + return normalized + return "项目创建请求" + + @classmethod + def _build_project_preview_summary( + cls, + *, + description: str, + project_scope: str, + blocking_reason: Optional[str], + fallback_text: str, + ) -> str: + normalized_description = str(description or "").strip() + if cls._looks_like_placeholder_project_text(normalized_description): + normalized_description = "" + normalized_scope = str(project_scope or "").strip() + if cls._looks_like_placeholder_project_text(normalized_scope): + normalized_scope = "" + + fragments: list[str] = [] + if normalized_description: + fragments.append(_compact_text(normalized_description, max_len=140)) + if normalized_scope and normalized_scope not in normalized_description: + fragments.append(f"范围:{_compact_text(normalized_scope, max_len=90)}") + if fragments: + return ";".join(fragments[:2]) + if str(blocking_reason or "").strip(): + return str(blocking_reason).strip() + return _compact_text(fallback_text, max_len=140) + + def _extract_task_intent_fields(self, *, text: str, inferred_project_id: Optional[int]) -> dict[str, Any]: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + title = "" + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + title = quoted_match.group(1).strip() + if not title: + for pattern in ( + r"(?:新增|新建|创建|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)", + r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::") + if candidate: + title = candidate[:120] + break + + description = "" + if title: + description = cleaned.replace(title, "", 1).strip(" ,,::") + description = re.sub(r"^(?:新增|新建|创建|加一个|加个).{0,8}任务", "", description, flags=re.IGNORECASE).strip(" ,,::") + + missing_fields: list[str] = [] + if inferred_project_id is None: + missing_fields.append("project_id") + if not title: + missing_fields.append("title") + return { + "project_id": int(inferred_project_id) if inferred_project_id is not None else None, + "title": title, + "description": description[:5000] or None, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _build_task_clarification_message(missing_fields: list[str]) -> str: + labels = { + "project_id": "归属项目", + "title": "任务标题", + } + ordered = [labels[field] for field in missing_fields if field in labels] + suffix = "、".join(ordered) if ordered else "归属项目和任务标题" + return f"未执行:当前建任务请求还缺少 {suffix}。请补充后我再创建。" + + @staticmethod + def _build_pending_operation_summary(*, operation_code: str, payload: dict[str, Any]) -> str: + if operation_code == "project.create": + return f"待创建项目:{str(payload.get('title') or '未命名项目').strip()}" + if operation_code == "task.create": + return f"待创建任务:{str(payload.get('title') or '未命名任务').strip()}" + return f"待执行动作:{operation_code}" + + @staticmethod + def _build_pending_operation_next_step(*, operation_code: str) -> str: + if operation_code == "project.create": + return "补齐信息或确认后,系统会创建项目并绑定真实项目 ID。" + if operation_code == "task.create": + return "确认后,系统会在目标项目中创建任务。" + return "补齐上下文后,系统会继续执行该动作。" + + @staticmethod + def _build_gate_source_object(*, intent: str, fields: dict[str, Any], message: str) -> str: + if intent == INTENT_CREATE_PROJECT: + return str(fields.get("title") or fields.get("project_scope") or message) + if intent == INTENT_CREATE_TASK: + return str(fields.get("title") or fields.get("project_id") or message) + return str(fields.get("project_id") or intent or message) + + async def _queue_project_intent_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + intent_gate: dict[str, Any], + blocking_reason: str, + decision_reason: str, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + source_object = self._build_gate_source_object( + intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), + fields=fields, + message=message, + ) + dedupe_key = self._build_queue_dedupe_key( + level="project", + intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), + message=message, + source_chat_id=source_chat_id, + matched_project_id=None, + source_object=source_object, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.blocking_reason = blocking_reason[:2000] + existing_item.decision_reason = decision_reason[:1000] + existing_item.raw_message = message[:4000] + await self.db.flush() + result = await self._build_existing_item_result(existing_item) + result["clarification_message"] = blocking_reason + return result + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0, + signal_confidence=0.0, + raw_message=message[:4000], + split_from_item_id=None, + level="project", + level_confidence=1.0, + level_reasoning="intent_gate:create_project", + card_type="new_project_candidate", + route_status=ROUTE_STATUS_QUEUED, + matched_project_id=None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], + decision_reason=decision_reason[:1000] or None, + blocking_reason=blocking_reason[:2000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text=self._build_pending_operation_next_step(operation_code="project.create"), + queue_visible=True, + result_payload={ + "intent": INTENT_CREATE_PROJECT, + "executor": "operation_executor", + "operation_code": "project.create", + "operation_payload": { + "title": str(fields.get("title") or "").strip(), + "description": str(fields.get("description") or "").strip(), + }, + "project_scope": str(fields.get("project_scope") or "").strip() or None, + "result_summary": self._build_pending_operation_summary( + operation_code="project.create", + payload={ + "title": str(fields.get("title") or "").strip(), + }, + ), + }, + ) + self.db.add(item) + await self.db.flush() + + suggestion = ProjectSuggestion( + tenant_id=self.tenant_id, + feishu_item_id=int(item.id), + suggested_name=(str(fields.get("title") or "").strip() or message[:20] or "新项目建议")[:255], + suggested_description=(str(fields.get("description") or "").strip() or message[:400])[:4000], + suggested_owner=(sender or "待指定")[:120], + suggested_timeline="待确认", + confidence=1.0, + reasoning=json.dumps(["命中 intent gate:create_project,等待确认或补齐字段。"], ensure_ascii=False), + status="pending", + ) + self.db.add(suggestion) + await self.db.flush() + return { + "item_id": int(item.id), + "level": "project", + "project_id": None, + "signal_id": None, + "route_status": ROUTE_STATUS_QUEUED, + "clarification_message": blocking_reason, + } + + async def _queue_command_intent_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + intent_gate: dict[str, Any], + operation_code: str, + operation_payload: dict[str, Any], + blocking_reason: str, + decision_reason: str, + matched_project_id: Optional[int], + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + source_object = self._build_gate_source_object( + intent=str(intent_gate.get("intent") or ""), + fields=fields, + message=message, + ) + dedupe_key = self._build_queue_dedupe_key( + level="command", + intent=str(intent_gate.get("intent") or ""), + message=message, + source_chat_id=source_chat_id, + matched_project_id=matched_project_id, + source_object=source_object, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.blocking_reason = blocking_reason[:2000] + existing_item.decision_reason = decision_reason[:1000] + existing_item.raw_message = message[:4000] + existing_item.result_payload = { + **(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}), + "intent": str(intent_gate.get("intent") or ""), + "executor": "operation_executor", + "operation_code": operation_code, + "operation_payload": operation_payload, + "result_summary": self._build_pending_operation_summary( + operation_code=operation_code, + payload=operation_payload, + ), + } + await self.db.flush() + result = await self._build_existing_item_result(existing_item) + result["clarification_message"] = blocking_reason + return result + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning=f"intent_gate:{str(intent_gate.get('intent') or '')}", + card_type=operation_code.replace(".", "_")[:30], + route_status=ROUTE_STATUS_QUEUED, + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], + decision_reason=decision_reason[:1000] or None, + blocking_reason=blocking_reason[:2000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text=self._build_pending_operation_next_step(operation_code=operation_code), + queue_visible=True, + result_payload={ + "intent": str(intent_gate.get("intent") or ""), + "executor": "operation_executor", + "operation_code": operation_code, + "operation_payload": operation_payload, + "result_summary": self._build_pending_operation_summary( + operation_code=operation_code, + payload=operation_payload, + ), + "project_id": int(matched_project_id) if matched_project_id is not None else None, + }, + ) + self.db.add(item) + await self.db.flush() + return { + "item_id": int(item.id), + "level": "command", + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_QUEUED, + "clarification_message": blocking_reason, + } + + async def _record_committed_operation_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent: str, + operation_code: str, + execution_result: dict[str, Any], + matched_project_id: Optional[int], + ) -> Optional[int]: + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is not None: + occurred_at = occurred_at.astimezone(timezone.utc).replace(tzinfo=None) + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning=f"intent_gate:{intent}", + card_type=operation_code.replace(".", "_")[:30], + route_status=ROUTE_STATUS_AUTO_DONE, + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at, + ai_output=json.dumps({"execution_result": execution_result}, ensure_ascii=False)[:8000], + decision_reason="deterministic_operation_committed", + blocking_reason=None, + safe_to_auto_execute=True, + auto_execute_state="done", + message_class="auto_done", + occurrence_count=1, + next_step_text="真实提交已完成,结果已绑定 DB commit。", + queue_visible=False, + processed_at=_utc_now().replace(tzinfo=None), + result_payload={ + "intent": intent, + "executor": "operation_executor", + "operation_code": operation_code, + "result_summary": str(execution_result.get("message") or "").strip() or operation_code, + "operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {}, + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, + }, + ) + self.db.add(item) + await self.db.flush() + return int(item.id) + + async def _execute_controlled_operation( + self, + *, + operator_user: User, + operation_code: str, + payload: dict[str, Any], + trace_id: str, + ) -> dict[str, Any]: + executor = OperationExecutorService(self.db) + project_context = None + project_id = _safe_int(payload.get("project_id")) + if operation_code != "project.create" and project_id is not None: + project_context = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project_context is None: + raise ValueError("project_context_missing") + + logger.info( + "operation_started", + tenant_id=self.tenant_id, + trace_id=trace_id, + operation_code=operation_code, + payload=payload, + executor="operation_executor", + ) + execution = await executor.execute_operations( + tenant_id=self.tenant_id, + operator_user=operator_user, + project=project_context, + operations=[ + { + "operation_code": operation_code, + "payload": payload, + "reason": "intent_gate", + } + ], + dry_run=False, + trace_id=trace_id, + ) + first = execution["results"][0] if execution.get("results") else {} + event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed" + logger.info( + event_name, + tenant_id=self.tenant_id, + trace_id=trace_id, + operation_code=operation_code, + result=first, + executor="operation_executor", + ) + return execution + + @staticmethod + def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str: + if item is None: + return "unknown" + level = str(item.level or "").strip().lower() + if level == "project": + return "project_candidate" + if level == "signal": + return "signal_intake" + if level == "command": + return str(item.card_type or "command") + return level or "unknown" + + async def _maybe_write_intent_correction_sample( + self, + *, + message: str, + source_chat_id: str, + intent_gate: dict[str, Any], + ) -> None: + if self.db is None or not self._is_correction_message(message): + return + recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id, limit=3) if source_chat_id else [] + wrong_interpretation = self._derive_wrong_interpretation_from_recent_item(recent_items[0] if recent_items else None) + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + clarification_template = str(intent_gate.get("clarification_template") or "").strip() + if not clarification_template: + if str(intent_gate.get("intent") or "") == INTENT_CREATE_PROJECT: + clarification_template = self._build_project_clarification_message( + list(fields.get("missing_fields") or []), + ) + elif str(intent_gate.get("intent") or "") == INTENT_CREATE_TASK: + clarification_template = self._build_task_clarification_message( + list(fields.get("missing_fields") or []), + ) + try: + await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).upsert_intent_correction_sample( + user_utterance=message, + wrong_interpretation=wrong_interpretation, + correct_intent=str(intent_gate.get("intent") or INTENT_CLARIFICATION_REQUIRED), + canonical_action=str(intent_gate.get("canonical_action") or "").strip() or "clarify", + clarification_template=clarification_template, + risk_level=str(intent_gate.get("risk_level") or "medium"), + project_id=_safe_int(fields.get("project_id")), + ) + except Exception as exc: + logger.warning(f"intent_correction_write_failed: tenant_id={self.tenant_id}, error={exc}") + + async def _build_healthcheck_capture( + self, + *, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + ) -> dict[str, Any]: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent": INTENT_HEALTHCHECK}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="healthcheck", + routing_decision=ROUTING_INTENT_GATE_HEALTHCHECK, + routing_reason="命中 healthcheck,直接返回执行链可用状态,不进入 Inbox。", + reasoning_bullets=[ + "intent gate 已识别为 healthcheck。", + "不涉及写库动作。", + "回执固定由后端生成。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + normalized_text=message, + route=ROUTING_INTENT_GATE_HEALTHCHECK, + intent=INTENT_HEALTHCHECK, + executor="router", + reply_source="deterministic_reply", + ) + return { + "status": "answered", + "classification": "healthcheck", + "project_scope": "healthcheck", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-healthcheck-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_HEALTHCHECK, + "direct_reply_required": True, + "direct_reply_text": "healthcheck=ok;执行链在线;仅真实 commit 后才会回“已执行”。", + } + + async def _handle_create_project_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_project_clarification_message( + list(fields.get("missing_fields") or []), + ) + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + + operator_user = await self._resolve_command_operator_user(sender=sender) + if not fields.get("ready"): + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建项目缺少必填字段,已转待澄清,禁止猜测执行。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "当前缺少 project.create 必填字段。", + "仅保留结构化待澄清项,不触发执行器。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED, + executor="operation_executor", + operation_code="project.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-clarify-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_CLARIFICATION_REQUIRED, + "clarification_message": clarification_message, + "direct_reply_required": True, + "direct_reply_text": clarification_message, + } + + if not self._can_execute_high_value_action(operator_user): + queued = await self._queue_project_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + blocking_reason=clarification_message if not fields.get("ready") else "建项目属于高风险动作,当前需要你确认后再执行。", + decision_reason="high_value_action_guard", + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建项目命中高风险保护,已转待决策/待澄清。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "当前未满足自动执行条件。", + "仅保留结构化待决策项,不做猜测执行。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, + executor="operation_executor", + operation_code="project.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-pending-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), + } + + payload_description = str(fields.get("description") or "").strip() + project_scope = str(fields.get("project_scope") or "").strip() + if project_scope and project_scope not in payload_description: + payload_description = f"{payload_description}\n\n项目范围:{project_scope}".strip() + payload = { + "title": str(fields.get("title") or "").strip(), + "description": payload_description, + } + trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}" + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="project.create", + payload=payload, + trace_id=trace_id, + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "project.create failed")) + + project_result = first.get("result") if isinstance(first.get("result"), dict) else {} + project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) + item_id = await self._record_committed_operation_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent=INTENT_CREATE_PROJECT, + operation_code="project.create", + execution_result=first, + matched_project_id=project_id, + ) + reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_OPERATION_COMMITTED, + routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "执行器仅走 project.create。", + "回复绑定真实 commit 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_OPERATION_COMMITTED, + intent=INTENT_CREATE_PROJECT, + executor="operation_executor", + operation_code="project.create", + operation_result=first if isinstance(first, dict) else None, + reply_source="db_commit", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [int(project_id)] if project_id is not None else [], + "signal_id": None, + "created_project_id": int(project_id) if project_id is not None else None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": int(project_id) if project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_AUTO_DONE, + } + ], + "intent": INTENT_CREATE_PROJECT, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": {"execution": execution}, + } + + async def _handle_create_task_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + operation_payload = { + "project_id": _safe_int(fields.get("project_id")), + "title": str(fields.get("title") or "").strip(), + "description": str(fields.get("description") or "").strip() or None, + } + clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_task_clarification_message( + list(fields.get("missing_fields") or []), + ) + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + + operator_user = await self._resolve_command_operator_user(sender=sender) + if not fields.get("ready"): + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建任务缺少必填字段,已转待澄清,禁止猜测执行。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "当前缺少 task.create 必填字段。", + "仅保留结构化待澄清项,不触发执行器。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED, + executor="operation_executor", + operation_code="task.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-task-clarify-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_CLARIFICATION_REQUIRED, + "clarification_message": clarification_message, + "direct_reply_required": True, + "direct_reply_text": clarification_message, + } + + if not self._can_execute_high_value_action(operator_user): + queued = await self._queue_command_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + operation_code="task.create", + operation_payload={k: v for k, v in operation_payload.items() if v is not None}, + blocking_reason=clarification_message if not fields.get("ready") else "建任务属于高风险动作,当前需要你确认后再执行。", + decision_reason="high_value_action_guard", + matched_project_id=_safe_int(fields.get("project_id")), + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建任务命中高风险保护,已转待决策/待澄清。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "当前未满足自动执行条件。", + "仅保留结构化待决策项,不做猜测执行。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, + executor="operation_executor", + operation_code="task.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-task-pending-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), + } + + trace_id = f"intent-task-{self._stable_positive_int(message, sender, feishu_message_id)}" + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="task.create", + payload={k: v for k, v in operation_payload.items() if v is not None}, + trace_id=trace_id, + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "task.create failed")) + project_id = _safe_int(fields.get("project_id")) + item_id = await self._record_committed_operation_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent=INTENT_CREATE_TASK, + operation_code="task.create", + execution_result=first, + matched_project_id=project_id, + ) + reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], + project_score=1.0 if project_id is not None else None, + signal_confidence=0.0 if project_id is not None else None, + level="command", + routing_decision=ROUTING_OPERATION_COMMITTED, + routing_reason="建任务已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "执行器仅走 task.create。", + "回复绑定真实 commit 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_OPERATION_COMMITTED, + intent=INTENT_CREATE_TASK, + executor="operation_executor", + operation_code="task.create", + operation_result=first if isinstance(first, dict) else None, + reply_source="db_commit", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(project_id)] if project_id is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": int(project_id) if project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_AUTO_DONE, + } + ], + "intent": INTENT_CREATE_TASK, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": {"execution": execution}, + } + + async def _handle_external_connect_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。" + queued = await self._queue_command_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + operation_code="source.connect", + operation_payload={"project_id": _safe_int(fields.get("project_id"))}, + blocking_reason=blocking_reason, + decision_reason="external_connect_requires_manual_decision", + matched_project_id=_safe_int(fields.get("project_id")), + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, + routing_reason="外部系统接入被收口到待决策队列,不允许自由执行。", + reasoning_bullets=[ + "intent gate 命中 external_connect。", + "当前仅允许受控工具链执行。", + "系统已转待决策,不做任意 HTTP/shell。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, + intent=INTENT_EXTERNAL_CONNECT, + executor="router", + operation_code="source.connect", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-external-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_EXTERNAL_CONNECT, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or blocking_reason), + } + + async def process_message( + self, + *, + message_content: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str] = None, + feishu_message_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + ) -> dict[str, Any]: + process_started_at = time.perf_counter() + normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id) + message = self._normalize_incoming_text(message_content) + if not message: + return self._decorate_capture_result( + {"status": "ignored", "reason": "empty_message", "results": []}, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.empty_message", + db_mutations=[], + ) + + if self._is_water_talk_message(message): + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_NOISE_FILTERED, + routing_reason="识别为闲聊/问答水话,已过滤且不进入待处理队列。", + reasoning_bullets=[ + "消息缺少可执行交付信息。", + "不满足需求信号的最小结构(目标/对象/动作)。", + "已按噪音消息忽略,不触发入库与排队。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result({ + "status": "ignored", + "reason": ROUTING_NOISE_FILTERED, + "classification": "non_requirement", + "project_scope": "non_requirement", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-noise-{(feishu_message_id or 'noid')[:32]}", + "results": [], + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.noise_filtered", db_mutations=[]) + + if feishu_message_id: + await self._acquire_message_lock(feishu_message_id) + + if feishu_message_id and await self._has_processed_message(feishu_message_id): + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_DUPLICATE_FILTERED, + routing_reason="检测到重复飞书消息 ID,已过滤且不再重复执行 AI 处理。", + reasoning_bullets=[ + "消息唯一标识已存在于处理日志中。", + "本次请求被标记为重复消息。", + "系统已跳过后续分类和入库动作。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result({ + "status": "ignored", + "reason": ROUTING_DUPLICATE_FILTERED, + "classification": "duplicate", + "project_scope": "duplicate", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", + "results": [], + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_filtered", db_mutations=[]) + + projects = await self._load_active_projects() + chat_context_resolution = { + "project_id": None, + "ambiguous": False, + "candidate_ids": [], + "top_ratio": 0.0, + } + if source_chat_id: + try: + chat_context_resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) + except Exception: + chat_context_resolution = { + "project_id": None, + "ambiguous": False, + "candidate_ids": [], + "top_ratio": 0.0, + } + inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) + + followup_capture = await self._maybe_handle_queue_followup( + message=message, + sender=sender, + source_chat_id=source_chat_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + ) + if followup_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + followup_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.followup", + db_mutations=self._summarize_db_mutations(followup_capture), + ) + + lookup_capture = await self._maybe_answer_project_lookup_question( + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + projects=projects, + ) + if lookup_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + lookup_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.project_lookup", + db_mutations=[], + ) + + intent_gate = await self._resolve_intent_gate( + message=message, + inferred_project_id=inferred_project_id, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + logger.info( + "intent_resolved", + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + intent=str(intent_gate.get("intent") or ""), + canonical_action=str(intent_gate.get("canonical_action") or ""), + normalized_text=message[:400], + ) + await self._maybe_write_intent_correction_sample( + message=message, + source_chat_id=source_chat_id, + intent_gate=intent_gate, + ) + + gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE) + if gate_intent == INTENT_SMALLTALK: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="smalltalk", + routing_decision=ROUTING_NOISE_FILTERED, + routing_reason="intent gate 识别为 smalltalk/短确认,不进入 Inbox。", + reasoning_bullets=[ + "消息不包含可执行主数据动作。", + "命中 short confirmation / smalltalk。", + "系统直接忽略,不写通知。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result( + { + "status": "ignored", + "reason": ROUTING_NOISE_FILTERED, + "classification": "smalltalk", + "project_scope": "smalltalk", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-smalltalk-{(feishu_message_id or 'noid')[:32]}", + "results": [], + }, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.smalltalk", + db_mutations=[], + ) + + if gate_intent == INTENT_HEALTHCHECK: + healthcheck_capture = await self._build_healthcheck_capture( + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + healthcheck_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.healthcheck", + db_mutations=[], + ) + + if gate_intent == INTENT_QUERY: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + rag_result = await rag_service.answer_question( + question=message, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + qa_capture = await self._build_direct_qa_capture( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。", + intent_label=INTENT_QUERY, + ) + await self.db.commit() + return self._decorate_capture_result( + qa_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.query", + db_mutations=[], + ) + + if gate_intent == INTENT_CREATE_PROJECT or ( + gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "project.create" + ): + project_capture = await self._handle_create_project_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + project_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.project_create", + db_mutations=self._summarize_db_mutations(project_capture), + ) + + if gate_intent == INTENT_CREATE_TASK or ( + gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "task.create" + ): + task_capture = await self._handle_create_task_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + task_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.task_create", + db_mutations=self._summarize_db_mutations(task_capture), + ) + + if gate_intent == INTENT_EXTERNAL_CONNECT: + external_capture = await self._handle_external_connect_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + external_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.external_connect", + db_mutations=self._summarize_db_mutations(external_capture), + ) + + command_capture = await self._maybe_execute_openclaw_command( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + inferred_project_id=inferred_project_id, + projects=projects, + process_started_at=process_started_at, + ) + if command_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + command_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.command", + db_mutations=self._summarize_db_mutations(command_capture), + ) + + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + + sidecar_rag_result: Optional[dict[str, Any]] = None + intent_for_response = "statement" + force_signal_fastpath = False + rag_question_part = message + + if self._is_obvious_question(message): + rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if self._is_requirement_like_question(message): + sidecar_rag_result = rag_result + intent_for_response = "question_requirement" + message = self._extract_requirement_statement_from_question(message) + if not message: + message = self._normalize_incoming_text(message_content) + force_signal_fastpath = True + else: + qa_capture = await self._build_direct_qa_capture( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", + intent_label="question_obvious", + ) + await self.db.commit() + return self._decorate_capture_result( + qa_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.qa_direct", + db_mutations=[], + ) + else: + intent_result = await rag_service.classify_intent(message) + intent = str(intent_result.get("intent") or "statement").lower() + intent_for_response = intent + rag_question_part = str(intent_result.get("question_part") or "").strip() or message + rag_statement_part = str(intent_result.get("statement_part") or "").strip() + + if intent == "question": + rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if self._is_requirement_like_question(message): + sidecar_rag_result = rag_result + intent_for_response = "question_requirement" + message = self._extract_requirement_statement_from_question(message) + if not message: + message = self._normalize_incoming_text(message_content) + force_signal_fastpath = True + else: + qa_capture = await self._build_direct_qa_capture( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", + intent_label=intent, + ) + await self.db.commit() + return self._decorate_capture_result( + qa_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.qa_direct", + db_mutations=[], + ) + + if intent == "mixed": + mixed_rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if rag_statement_part: + message = rag_statement_part + sidecar_rag_result = mixed_rag_result + + try: + if force_signal_fastpath: + level_result = self._heuristic_level_fallback(message) + else: + level_result = await self._classify_level(message=message, projects=projects) + project_score = _safe_float(level_result.get("project_score"), 0.0) + level = _normalize_level_value( + level_result.get("level"), + default="project" if project_score >= 0.6 else "signal", + ) + level_confidence = _safe_float(level_result.get("level_confidence"), project_score) + level_reasoning = str(level_result.get("level_reasoning") or "").strip() + + split_items = [] + seen_split_items: set[tuple[str, str]] = set() + raw_split_items = level_result.get("split_items") + should_split = bool(level_result.get("should_split")) + if should_split and isinstance(raw_split_items, list): + for item in raw_split_items[:2]: + if not isinstance(item, dict): + continue + item_level = _normalize_level_value(item.get("level"), default=level) + item_content = str(item.get("content") or "").strip() + if item_level in {"project", "signal"} and item_content: + dedupe_key = (item_level, item_content) + if dedupe_key in seen_split_items: + continue + seen_split_items.add(dedupe_key) + split_items.append({"level": item_level, "content": item_content}) + if not split_items: + split_items = [{"level": level if level in {"project", "signal"} else "signal", "content": message}] + + routing_policy = await self._load_routing_policy() + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + + results: list[dict[str, Any]] = [] + parent_item_id: Optional[int] = None + for idx, split in enumerate(split_items): + item_result = await self._process_one_item( + content=split["content"], + level=split["level"], + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + level_result=level_result, + level_confidence=level_confidence, + level_reasoning=level_reasoning, + project_score=project_score, + cold_start_enabled=bool(routing_policy["cold_start_enabled"]), + signal_auto_enter_threshold=float(routing_policy["signal_auto_enter_threshold"]), + project_auto_create_threshold=float(routing_policy["project_auto_create_threshold"]), + project_uncertain_threshold=float(routing_policy["project_uncertain_threshold"]), + chat_context_auto_link_enabled=bool(routing_policy["chat_context_auto_link_enabled"]), + projects=projects, + split_from_item_id=parent_item_id if idx > 0 else None, + chat_type=normalized_chat_type, + chat_context_resolution=chat_context_resolution, + ) + if idx == 0 and item_result.get("item_id"): + parent_item_id = int(item_result["item_id"]) + results.append(item_result) + + await self.db.commit() + + signal_ids = [int(x["signal_id"]) for x in results if x.get("signal_id")] + linked_projects = sorted( + { + int(x["project_id"]) + for x in results + if x.get("project_id") is not None + } + ) + classification = "split" if len(results) > 1 else (results[0].get("level") or "signal") + clarification_message = next( + ( + str(row.get("clarification_message") or "").strip() + for row in results + if isinstance(row, dict) and str(row.get("clarification_message") or "").strip() + ), + "", + ) + direct_reply_text: Optional[str] = None + if sidecar_rag_result: + sidecar_answer = str(sidecar_rag_result.get("answer") or "").strip() + if sidecar_answer: + direct_reply_text = ( + self._build_rag_reply_text( + answer=sidecar_answer, + sources=sidecar_rag_result.get("sources") or [], + ) + + "\n\n我已回答你的问题,并把需求部分记录为信号进入处理队列。" + ) + return self._decorate_capture_result({ + "status": "captured", + "classification": classification, + "project_scope": classification, + "linked_project_ids": linked_projects, + "signal_id": str(signal_ids[0]) if signal_ids else None, + "created_project_id": linked_projects[0] if linked_projects else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-item-{results[0].get('item_id')}" if results else None, + "results": results, + "intent": intent_for_response, + "rag_result": sidecar_rag_result, + "direct_reply_required": bool(direct_reply_text), + "direct_reply_text": direct_reply_text, + "clarification_message": clarification_message or None, + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.{classification}", db_mutations=self._summarize_db_mutations({"results": results, "signal_id": str(signal_ids[0]) if signal_ids else None, "created_project_id": linked_projects[0] if linked_projects else None})) + except IntegrityError as exc: + await self.db.rollback() + if feishu_message_id and self._is_duplicate_integrity_error(exc): + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_DUPLICATE_FILTERED, + routing_reason="唯一约束命中,判定为重复飞书消息,已跳过重复处理。", + reasoning_bullets=[ + "数据库唯一约束阻止了重复入库。", + "当前请求被收敛到 duplicate 模板回执。", + "后续不再触发 AI 分类与入库动作。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result({ + "status": "ignored", + "reason": ROUTING_DUPLICATE_FILTERED, + "classification": "duplicate", + "project_scope": "duplicate", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", + "results": [], + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_integrity", db_mutations=[]) + + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_FAILED, + routing_reason="数据库完整性校验失败,已停止当前消息处理。", + reasoning_bullets=[ + f"异常类型:{exc.__class__.__name__}", + "可能原因:唯一约束/外键约束冲突。", + "请结合 SQL 日志排查数据一致性问题。", + ], + processing_status="failed", + error_message=str(exc)[:2000] or "integrity_error", + duration_ms=duration_ms, + ) + await self.db.commit() + raise + except Exception as exc: + await self.db.rollback() + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_FAILED, + routing_reason="AI 处理链路执行失败,已停止当前消息处理并写入错误日志。", + reasoning_bullets=[ + f"异常类型:{exc.__class__.__name__}", + f"异常信息:{str(exc)[:120] or '未知错误'}", + "请在日志详情中查看完整错误并重试。", + ], + processing_status="failed", + error_message=str(exc)[:2000] or "unknown_error", + duration_ms=duration_ms, + ) + await self.db.commit() + raise + + @staticmethod + def _is_duplicate_integrity_error(exc: IntegrityError) -> bool: + message = str(exc or "").lower() + if "feishu_items" not in message: + return False + duplicate_hints = ( + "uq_feishu_items_tenant_message_root", + "uq_feishu_items_tenant_message_split", + "unique constraint", + "duplicate key value", + "is not unique", + ) + return any(hint in message for hint in duplicate_hints) + + @staticmethod + def _build_rag_reply_text(*, answer: str, sources: list[dict[str, Any]]) -> str: + clean_answer = str(answer or "").strip() or "我未找到足够证据,建议补充更多上下文。" + labels: list[str] = [] + deep_link = "" + for source in sources[:4]: + if not isinstance(source, dict): + continue + label = str(source.get("label") or source.get("source") or "").strip() + if label and label not in labels: + labels.append(label) + if not deep_link: + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + target_url = str(meta.get("target_url") or "").strip() + if not target_url: + project_id = meta.get("project_id") + if project_id is not None: + try: + target_url = f"/projects/{int(project_id)}/board" + except Exception: + target_url = "" + if target_url.startswith("/"): + deep_link = target_url + source_text = " / ".join(labels) if labels else "未命中可引用来源" + if deep_link: + return f"{clean_answer}\n\n来源: {source_text}\nView details → {deep_link}" + return f"{clean_answer}\n\n来源: {source_text}" + + @staticmethod + def _normalize_chat_type(*, chat_type: Optional[str], source_chat_id: str) -> str: + text = str(chat_type or "").strip().lower() + if text in {"p2p", "single", "single_chat", "dm", "direct"}: + return "p2p" + if text in {"group", "chat", "group_chat"}: + return "group" + return "group" if source_chat_id else "p2p" + + @staticmethod + def _normalize_incoming_text(message_content: Optional[str]) -> str: + text = str(message_content or "").replace("\r\n", "\n").replace("\r", "\n") + if not text: + return "" + text = FEISHU_BOT_MENTION_RE.sub(" ", text) + text = FEISHU_QUOTE_LINE_RE.sub(" ", text) + text = FEISHU_QUOTE_PREFIX_RE.sub("", text) + text = FEISHU_NOISE_TOKEN_RE.sub(" ", text) + text = re.sub(r"@所有人|@everyone", " ", text, flags=re.IGNORECASE) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{2,}", "\n", text) + text = re.sub(r"\s+", " ", text).strip() + return text[:2000] + + @classmethod + def _is_obvious_question(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._is_openclaw_business_command(text): + return False + if cls._looks_like_project_lookup_question(text): + return True + return bool(OBVIOUS_QUESTION_RE.search(text)) + + @staticmethod + def _decorate_capture_result( + result: dict[str, Any], + *, + chat_type: str, + normalized_text: str, + route: str, + db_mutations: list[dict[str, Any]], + ) -> dict[str, Any]: + payload = dict(result or {}) + direct_reply_text = str(payload.get("direct_reply_text") or "").strip() + if direct_reply_text: + payload["direct_reply_text"] = FeishuSignalPipelineService._normalize_direct_reply_text(direct_reply_text) + clarification_message = str(payload.get("clarification_message") or "").strip() + if clarification_message: + payload["clarification_message"] = FeishuSignalPipelineService._normalize_direct_reply_text(clarification_message) + followup_reply = str(payload.get("followup_reply") or "").strip() + if followup_reply: + payload["followup_reply"] = FeishuSignalPipelineService._normalize_direct_reply_text(followup_reply) + payload["chat_type"] = chat_type + payload["normalized_text"] = normalized_text + payload["route"] = route + payload["db_mutations"] = db_mutations + payload.setdefault("results", []) + return payload + + @staticmethod + def _normalize_direct_reply_text(text: str) -> str: + cleaned = str(text or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.replace("```", "").strip() + if cleaned.lower().startswith("json"): + cleaned = cleaned[4:].strip() + if not cleaned: + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + lowered = cleaned.lower() + provider_error_signals = ( + "request was aborted", + "request aborted", + "connection aborted", + "operation aborted", + "context canceled", + "context cancelled", + "cancelled", + "canceled", + "rate limit", + "too many requests", + "quota", + "insufficient_quota", + "insufficient quota", + "authentication failed", + "service unavailable", + "upstream error", + "bad gateway", + "gateway timeout", + "internal server error", + "connection reset", + "econnreset", + "socket hang up", + ) + if any(flag in lowered for flag in provider_error_signals): + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + return cleaned[:500] + + @staticmethod + def _summarize_db_mutations(result: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(result, dict): + return [] + mutations: list[dict[str, Any]] = [] + command_result = result.get("command_result") if isinstance(result.get("command_result"), dict) else None + execution = command_result.get("execution") if isinstance(command_result, dict) else None + execution_results = execution.get("results") if isinstance(execution, dict) else None + if isinstance(execution_results, list): + for row in execution_results[:6]: + if not isinstance(row, dict): + continue + mutations.append( + { + "operation_code": str(row.get("operation_code") or ""), + "status": str(row.get("status") or ""), + "entity_type": str(row.get("entity_type") or ""), + "entity_id": str(row.get("entity_id") or ""), + } + ) + if mutations: + return mutations + + rows = result.get("results") + if isinstance(rows, list): + for row in rows[:6]: + if not isinstance(row, dict): + continue + mutations.append( + { + "item_id": row.get("item_id"), + "level": row.get("level"), + "route_status": row.get("route_status"), + "project_id": row.get("project_id"), + "signal_id": row.get("signal_id"), + } + ) + return mutations + + async def _build_direct_qa_capture( + self, + *, + message: str, + rag_result: dict[str, Any], + inferred_project_id: Optional[int], + feishu_message_id: Optional[str], + process_started_at: float, + route_reason: str, + intent_label: str, + ) -> dict[str, Any]: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent": intent_label, "rag": rag_result}, ensure_ascii=False)[:4000], + project_score=None, + signal_confidence=None, + level="question", + routing_decision=ROUTING_DIRECT_QA, + routing_reason=route_reason, + reasoning_bullets=[ + f"intent={intent_label}", + "命中 question 路由,跳过 signal/project 处理。", + "回答已记录 QA 训练数据。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "answered", + "classification": "question", + "project_scope": "question", + "linked_project_ids": [int(inferred_project_id)] if inferred_project_id is not None else [], + "signal_id": None, + "created_project_id": int(inferred_project_id) if inferred_project_id is not None else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "rag_result": rag_result, + "direct_reply_required": True, + "direct_reply_text": self._build_rag_reply_text( + answer=str(rag_result.get("answer") or ""), + sources=rag_result.get("sources") or [], + ), + } + + async def _maybe_handle_queue_followup( + self, + *, + message: str, + sender: str, + source_chat_id: str, + feishu_message_id: Optional[str], + process_started_at: float, + ) -> Optional[dict[str, Any]]: + if not source_chat_id: + return None + wants_continue = self._looks_like_continue_followup(message) + wants_status = bool(FOLLOWUP_STATUS_RE.search(message)) + if not wants_continue and not wants_status: + return None + + recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id) + if not recent_items: + reply_text = "当前群里还没有可追踪的飞书处理项。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "群上下文中不存在可追踪的最近处理项。" + classification = "question" + status = "answered" + mutations: list[dict[str, Any]] = [] + results: list[dict[str, Any]] = [] + elif wants_continue: + pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED] + if len(pending_items) != 1: + reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3]) + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + operator_user = await self._resolve_command_operator_user(sender=sender) + if operator_user is None: + reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:当前租户下没有可用操作者。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + target_item = pending_items[0] + if (target_item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(int(target_item.id)) + if suggestion is None: + reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion) + if not candidate_fields["ready"]: + reply_text = ( + str(target_item.blocking_reason or "").strip() + or self._build_project_clarification_message(candidate_fields["missing_fields"]) + ) + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) + reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) + decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM + reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" + classification = "command" + status = "captured" + mutations = self._summarize_db_mutations( + { + "results": [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": confirm_result.get("project_id"), + "signal_id": confirm_result.get("signal_id"), + } + ] + } + ) + results = [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": _safe_int(confirm_result.get("project_id")), + "signal_id": _safe_int(confirm_result.get("signal_id")), + } + ] + else: + signal = await self._get_signal_by_item(int(target_item.id)) + if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)): + reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理信号缺少项目归属。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) + reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) + decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM + reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" + classification = "command" + status = "captured" + mutations = self._summarize_db_mutations( + { + "results": [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": confirm_result.get("project_id"), + "signal_id": confirm_result.get("signal_id"), + } + ] + } + ) + results = [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": _safe_int(confirm_result.get("project_id")), + "signal_id": _safe_int(confirm_result.get("signal_id")), + } + ] + else: + target_item = self._select_followup_status_target(message=message, recent_items=recent_items) + reply_text = await self._build_followup_status_reply(target_item) + decision = ROUTING_QUEUE_FOLLOWUP_QUERY + reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。" + classification = "question" + status = "answered" + mutations = [] + results = [] + + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps( + { + "followup_reply": reply_text, + "recent_item_ids": [int(item.id) for item in recent_items[:3]], + "wants_continue": wants_continue, + "wants_status": wants_status, + }, + ensure_ascii=False, + )[:4000], + project_score=None, + signal_confidence=None, + level="followup", + routing_decision=decision, + routing_reason=reason, + reasoning_bullets=[ + "follow-up 查询/指令命中最近飞书上下文。", + "回复内容只基于真实 DB 状态,不使用自由生成。", + f"recent_items={len(recent_items)}", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + ) + + latest = recent_items[0] if recent_items else None + linked_project_ids: list[int] = [] + if results and results[0].get("project_id") is not None: + linked_project_ids.append(int(results[0]["project_id"])) + if latest and latest.matched_project_id is not None: + linked_project_ids.append(int(latest.matched_project_id)) + linked_project_ids = sorted(set(linked_project_ids)) + return { + "status": status, + "classification": classification, + "project_scope": classification, + "linked_project_ids": linked_project_ids, + "signal_id": str(results[0].get("signal_id")) if results else None, + "created_project_id": str(results[0].get("project_id")) if results else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}", + "results": results, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "db_mutations": mutations, + } + + async def _load_recent_chat_items(self, *, source_chat_id: str, limit: int = 5) -> list[FeishuItem]: + rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.source_chat_id == (source_chat_id or "")[:128], + ) + ) + .order_by(FeishuItem.id.desc()) + .limit(limit) + ) + ).scalars().all() + return list(rows) + + @staticmethod + def _extract_item_operation_code(item: FeishuItem) -> str: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + return str(payload.get("operation_code") or "").strip().lower() + + @classmethod + def _select_followup_status_target(cls, *, message: str, recent_items: list[FeishuItem]) -> FeishuItem: + if not recent_items: + raise ValueError("recent_items_empty") + normalized = re.sub(r"\s+", "", str(message or "")).lower() + wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目")) + wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source")) + + if wants_source_status: + for item in recent_items: + operation_code = cls._extract_item_operation_code(item) + if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""): + return item + + if wants_project_create: + for item in recent_items: + operation_code = cls._extract_item_operation_code(item) + level = str(item.level or "").strip().lower() + card_type = str(item.card_type or "").strip().lower() + if ( + level == "project" + or operation_code == "project.create" + or card_type in {"project_create", "new_project", "new_project_candidate"} + ): + return item + + return recent_items[0] + + @staticmethod + def _build_followup_pending_clarification(items: list[FeishuItem]) -> str: + if not items: + return "当前群没有待处理项可继续。" + fragments = [] + for item in items[:3]: + level = str(item.level or "").lower() + label = "项目候选" if level == "project" else ("命令" if level == "command" else "信号") + fragments.append(f"{label}#{int(item.id)}") + return f"当前群最近有多个待处理项:{'、'.join(fragments)}。请先明确要继续哪一条。" + + def _build_followup_confirmation_reply(self, *, target_item: FeishuItem, confirm_result: dict[str, Any]) -> str: + level = str(target_item.level or "").lower() + if level == "project": + project_id = confirm_result.get("project_id") + return f"已继续处理:项目候选#{int(target_item.id)} 已确认并创建项目#{project_id}。" + if level == "command": + project_id = confirm_result.get("project_id") + task_id = confirm_result.get("task_id") + if task_id and project_id: + return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。" + if project_id: + return f"已继续处理:命令队列项#{int(target_item.id)} 已落到项目#{project_id}。" + return f"已继续处理:命令队列项#{int(target_item.id)} 已确认。" + signal_id = confirm_result.get("signal_id") + project_id = confirm_result.get("project_id") + if signal_id and project_id: + return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。" + return f"已继续处理:队列项#{int(target_item.id)} 已确认。" + + async def _build_followup_status_reply(self, item: FeishuItem) -> str: + route_status = self._normalize_route_status(item.route_status) + level = str(item.level or "").strip().lower() + if level == "project": + suggestion = await self._get_project_suggestion(int(item.id)) + project_id = suggestion.created_project_id if suggestion and suggestion.created_project_id else item.matched_project_id + if route_status == ROUTE_STATUS_AUTO_DONE and project_id: + return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。" + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条项目候选已撤销,系统没有保留该次自动结果。" + return "最近一条项目候选仍在待确认,系统还没有落正式项目。" + + if level == "command": + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_code = str(payload.get("operation_code") or "").strip() + project_id = _safe_int(payload.get("project_id") or item.matched_project_id) + summary = str(payload.get("result_summary") or "").strip() + if route_status == ROUTE_STATUS_AUTO_DONE: + return summary or ( + f"最近一条命令已执行完成,归属项目#{int(project_id)}。" + if project_id is not None + else "最近一条命令已执行完成。" + ) + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条命令结果已撤销。" + if route_status == ROUTE_STATUS_QUEUED: + return ( + f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。" + if operation_code + else "最近一条命令已入队待确认。" + ) + return "最近一条命令仍在处理中。" + + signal = await self._get_signal_by_item(int(item.id)) + signal_id = int(signal.id) if signal is not None else None + project_id = int(signal.matched_project_id or item.matched_project_id) if (signal and (signal.matched_project_id or item.matched_project_id)) else None + if route_status == ROUTE_STATUS_AUTO_DONE and signal_id and project_id: + return f"最近一条信号#{signal_id} 已确认并关联项目#{project_id}。" + if route_status == ROUTE_STATUS_QUEUED and signal_id and project_id: + return f"最近一条信号#{signal_id} 已入队待确认,候选项目#{project_id}。" + if route_status == ROUTE_STATUS_QUEUED and signal_id: + return f"最近一条信号#{signal_id} 已入队待确认,但项目归属仍不明确。" + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条信号结果已撤销。" + return "最近一条飞书处理项仍在处理中,尚未得到可确认的 DB 状态。" + + @staticmethod + def _looks_like_project_create_command(message: str) -> bool: + return bool(OPENCLAW_PROJECT_CREATE_RE.search(str(message or ""))) + + @classmethod + def _looks_like_project_lookup_question(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._is_openclaw_business_command(text): + return False + normalized = re.sub(r"\s+", "", text.lower()) + if PROJECT_LIST_QUERY_RE.search(text): + return True + if PROJECT_ID_QUERY_RE.search(text): + return True + if "项目" not in normalized: + return False + return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) + + @classmethod + def _is_openclaw_business_command(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._looks_like_project_create_command(text): + return True + return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text)) + + async def _maybe_execute_openclaw_command( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + inferred_project_id: Optional[int], + projects: list[dict[str, Any]], + process_started_at: float, + ) -> Optional[dict[str, Any]]: + if not self._is_openclaw_business_command(message): + return None + + operator_user = await self._resolve_command_operator_user(sender=sender) + if operator_user is None: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="当前租户下没有可用操作者,系统未执行写操作。", + ) + + target_project_id = inferred_project_id or self._guess_matched_project_id_from_text(message, projects) + commander = OpenClawCommanderService(self.db) + if self._looks_like_project_create_command(message): + command_result = await commander.dispatch_global_command( + tenant_id=self.tenant_id, + operator_user=operator_user, + instruction=message, + dry_run=False, + max_operations=4, + response_mode="fast", + persist_run=False, + ) + elif target_project_id is not None: + command_result = await commander.dispatch_command( + tenant_id=self.tenant_id, + operator_user=operator_user, + project_id=int(target_project_id), + instruction=message, + dry_run=False, + max_operations=6, + response_mode="fast", + persist_run=False, + ) + else: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="缺少明确项目上下文。请指明项目名,或在对应项目群内发送该指令。", + ) + + execution = command_result.get("execution") if isinstance(command_result, dict) else {} + requested_count = int((execution or {}).get("requested_count") or 0) + if requested_count <= 0 and int(command_result.get("planned_operations") or 0) <= 0: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="未识别到可安全执行的业务操作,系统未写库。", + ) + + linked_project_ids = self._collect_command_project_ids(command_result) + created_project_id = self._extract_created_project_id(command_result) + primary_project_id = created_project_id or (linked_project_ids[0] if linked_project_ids else None) + route_status = self._resolve_command_route_status(command_result) + item_id = await self._record_command_context_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + matched_project_id=primary_project_id, + route_status=route_status, + command_result=command_result, + ) + reply_text = self._build_openclaw_command_reply(command_result) + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:120000], + project_score=1.0 if primary_project_id is not None else None, + signal_confidence=0.0, + level="command", + routing_decision=ROUTING_OPENCLAW_EXECUTED, + routing_reason="识别为明确业务命令,已直接进入 OpenClaw command 层并按真实执行结果回执。", + reasoning_bullets=[ + "命中 command-first 路由,跳过 RAG 问答与信号分流。", + f"planner_status={str(command_result.get('planner_status') or '') or 'unknown'}", + f"openclaw_status={str(command_result.get('openclaw_status') or '') or 'unknown'}", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": linked_project_ids, + "signal_id": None, + "created_project_id": created_project_id or primary_project_id, + "auto_tasks": [], + "ai_trace_id": str(command_result.get("trace_id") or ""), + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": primary_project_id, + "signal_id": None, + "route_status": route_status, + } + ], + "intent": "command", + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": command_result, + } + + async def _maybe_answer_project_lookup_question( + self, + *, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + projects: list[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if not self._looks_like_project_lookup_question(message): + return None + + linked_project_ids: list[int] = [] + reply_text = "" + id_match = PROJECT_ID_QUERY_RE.search(message) + if id_match: + target_id = int(id_match.group(1)) + matched = next((item for item in projects if _safe_int(item.get("id")) == target_id), None) + if matched is None: + reply_text = f"项目#{target_id} 不存在,或当前租户下不可见。" + else: + linked_project_ids = [target_id] + reply_text = self._format_project_lookup_hit_rows([matched], prefix="查到了") + elif PROJECT_LIST_QUERY_RE.search(message): + if not projects: + reply_text = "当前没有可见项目。" + else: + reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") + linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] + else: + keyword = self._extract_project_lookup_keyword(message) + matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) + if matched_rows: + linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] + reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了") + elif keyword: + reply_text = f"没有查到包含“{keyword}”的项目。" + else: + reply_text = "这条问题缺少可检索的项目关键词。" + + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"deterministic_lookup": True, "linked_project_ids": linked_project_ids}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="question", + routing_decision=ROUTING_DIRECT_QA, + routing_reason="命中项目查询问句,已直接基于真实 DB 返回结果,跳过 LLM 分流。", + reasoning_bullets=[ + "命中 deterministic project lookup 规则。", + "直接查询 projects 表,不走 signal/project 分流。", + "回复内容只基于真实 DB 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "answered", + "classification": "question", + "project_scope": "question", + "linked_project_ids": linked_project_ids, + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-lookup-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": "question", + "direct_reply_required": True, + "direct_reply_text": reply_text, + } + + async def _build_openclaw_blocked_capture( + self, + *, + message: str, + feishu_message_id: Optional[str], + duration_ms: int, + reason: str, + ) -> dict[str, Any]: + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level="command", + routing_decision=ROUTING_OPENCLAW_BLOCKED, + routing_reason=reason, + reasoning_bullets=[ + "命中 command-first 路由,但当前缺少安全执行所需的上下文。", + "系统未执行任何写库动作。", + "回执只基于确定性拦截结果,不使用 LLM 概括。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": None, + "results": [], + "intent": "command", + "direct_reply_required": True, + "direct_reply_text": f"未执行:{reason}", + "command_result": None, + } + + async def _resolve_command_operator_user(self, *, sender: str) -> Optional[User]: + sender_id = await self._resolve_user_id_by_name(sender) + if sender_id is not None: + sender_user = await self._load_active_user_by_id(user_id=int(sender_id)) + if sender_user is not None: + return sender_user + + manager = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + User.status == "active", + User.role.in_(["BOSS", "ADMIN", "PM"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + if manager is not None: + return manager + return await self._load_active_user_by_id(user_id=await self._fallback_user_id()) + + async def _load_active_user_by_id(self, user_id: Optional[int]) -> Optional[User]: + if user_id is None: + return None + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.id == int(user_id), + User.is_active.is_(True), + User.status == "active", + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _record_command_context_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + matched_project_id: Optional[int], + route_status: str, + command_result: dict[str, Any], + ) -> Optional[int]: + if matched_project_id is None and not source_chat_id: + return None + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is not None: + occurred_at = occurred_at.astimezone(timezone.utc).replace(tzinfo=None) + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning="OpenClaw command-first execution", + card_type="command", + route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30], + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at, + ai_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:8000], + message_class="auto_done", + safe_to_auto_execute=True, + auto_execute_state="done", + result_payload={ + "intent": "command", + "executor": "openclaw_commander", + "operation_code": None, + "result_summary": self._build_openclaw_command_reply(command_result), + "command_result": command_result, + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, + }, + queue_visible=False, + processed_at=_utc_now().replace(tzinfo=None), + ) + self.db.add(item) + await self.db.flush() + return int(item.id) + + @staticmethod + def _resolve_command_route_status(command_result: dict[str, Any]) -> str: + status = str(command_result.get("openclaw_status") or "").strip().lower() + if status in {"completed", "partial", "planned"}: + return ROUTE_STATUS_AUTO_DONE + return ROUTE_STATUS_FAILED + + @staticmethod + def _collect_command_project_ids(command_result: dict[str, Any]) -> list[int]: + project_ids: set[int] = set() + project_info = command_result.get("project") if isinstance(command_result, dict) else None + if isinstance(project_info, dict): + project_id = project_info.get("id") + try: + if project_id is not None and int(project_id) > 0: + project_ids.add(int(project_id)) + except (TypeError, ValueError): + pass + execution = command_result.get("execution") if isinstance(command_result, dict) else None + results = execution.get("results") if isinstance(execution, dict) else None + if isinstance(results, list): + for row in results: + if not isinstance(row, dict): + continue + if str(row.get("entity_type") or "").strip() != "project": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + candidate = payload.get("project_id") or row.get("entity_id") + try: + if candidate is not None and int(candidate) > 0: + project_ids.add(int(candidate)) + except (TypeError, ValueError): + continue + return sorted(project_ids) + + @staticmethod + def _extract_created_project_id(command_result: dict[str, Any]) -> Optional[int]: + execution = command_result.get("execution") if isinstance(command_result, dict) else None + results = execution.get("results") if isinstance(execution, dict) else None + if not isinstance(results, list): + return None + for row in results: + if not isinstance(row, dict): + continue + if str(row.get("operation_code") or "").strip() != "project.create": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + candidate = payload.get("project_id") or row.get("entity_id") + try: + if candidate is not None and int(candidate) > 0: + return int(candidate) + except (TypeError, ValueError): + continue + return None + + @classmethod + def _build_openclaw_command_reply(cls, command_result: dict[str, Any]) -> str: + execution = command_result.get("execution") if isinstance(command_result, dict) else {} + results = execution.get("results") if isinstance(execution, dict) else [] + if not isinstance(results, list) or not results: + return "未执行:未识别到可安全落库的业务操作。" + + fragments = [cls._format_openclaw_result_fragment(row) for row in results[:3] if isinstance(row, dict)] + fragments = [item for item in fragments if item] + applied_count = int((execution or {}).get("applied_count") or 0) + skipped_count = int((execution or {}).get("skipped_count") or 0) + failed_count = int((execution or {}).get("failed_count") or 0) + + if applied_count <= 0 and skipped_count <= 0 and failed_count > 0: + first_failed = next((row for row in results if isinstance(row, dict) and str(row.get("status") or "") == "failed"), {}) + message = str(first_failed.get("message") or "执行失败").strip() + return f"未执行:{message}" + if not fragments: + return "已接收指令,但未生成可展示的执行回执。" + + prefix = "未重复执行:" if applied_count <= 0 and skipped_count > 0 else "已执行完成:" + reply = f"{prefix}{';'.join(fragments[:2])}" + if failed_count > 0 and applied_count > 0: + reply = f"{reply};另有 {failed_count} 项失败" + return reply + "。" + + @staticmethod + def _format_openclaw_result_fragment(row: dict[str, Any]) -> str: + operation_code = str(row.get("operation_code") or "").strip() + status = str(row.get("status") or "").strip().lower() + message = str(row.get("message") or "").strip() + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + if status == "failed": + return "" + + if operation_code.startswith("project."): + project_id = payload.get("project_id") or row.get("entity_id") + title = str(payload.get("title") or payload.get("project_title") or "").strip() + status_text = str(payload.get("status") or "").strip() + label = f"项目#{project_id}" + if title: + label = f"{label}「{title}」" + if operation_code == "project.create" and status == "applied": + return f"{label}已创建,状态 {status_text or 'active'}" + if operation_code == "project.create" and status == "skipped": + return f"{label}已存在,状态 {status_text or 'active'}" + if status_text: + return f"{label}状态已更新为 {status_text}" + return f"{label}{message or '已处理'}" + + if operation_code == "task.create": + return f"任务#{row.get('entity_id') or ''}已创建".strip() + if operation_code == "task.assign": + return f"任务#{row.get('entity_id') or ''}已分配".strip() + if operation_code == "task.prioritize": + return f"任务#{row.get('entity_id') or ''}优先级已更新".strip() + if operation_code == "task.status_transition": + return f"任务#{row.get('entity_id') or ''}状态已更新".strip() + if operation_code == "personnel.workload_update": + return f"成员#{row.get('entity_id') or ''}工时已更新".strip() + if operation_code == "notification.create": + return f"通知#{row.get('entity_id') or ''}已创建".strip() + return message or operation_code + + @staticmethod + def _extract_project_lookup_keyword(message: str) -> str: + text = str(message or "").strip() + if not text: + return "" + cleaned = re.sub( + r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)", + " ", + text, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:40] + + @staticmethod + def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: + normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower() + if len(normalized_keyword) < 2: + return [] + matched: list[dict[str, Any]] = [] + for item in projects: + title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower() + if normalized_keyword and normalized_keyword in title: + matched.append(item) + return matched + + @staticmethod + def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: + if not rows: + return "当前没有可见项目。" + fragments: list[str] = [] + for item in rows[:8]: + project_id = item.get("id") + title = str(item.get("title") or item.get("name") or "").strip() or f"项目#{project_id}" + status = str(item.get("status") or "").strip() or "unknown" + fragments.append(f"#{project_id} {title}({status})") + return f"{prefix}:{'、'.join(fragments)}。" + + @classmethod + def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool: + if str(item.decision_reason or "").strip() == "missing_required_fields": + return True + raw_message = str(item.raw_message or "").strip() + if not raw_message: + return False + normalized = re.sub(r"\s+", "", raw_message).lower() + if cls._is_water_talk_message(raw_message): + return True + if cls._is_healthcheck_message(raw_message): + return True + if cls._is_short_confirmation_message(raw_message): + return True + if cls._looks_like_project_lookup_question(raw_message): + return True + if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): + return True + return False + + async def list_queue(self, *, limit: int = 30) -> dict[str, Any]: + routing_policy = await self._load_routing_policy() + pending_rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.queue_visible.is_(True), + FeishuItem.route_status.in_((ROUTE_STATUS_QUEUED, "pending")), + ) + ) + .order_by(FeishuItem.created_at.desc()) + .limit(limit) + ) + ).scalars().all() + pending_rows = self._dedupe_queue_items(pending_rows) + pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)] + + # DB 列是 timestamp without time zone,比较值必须使用 naive UTC,避免 asyncpg aware/naive 报错。 + today_start = _utc_now().astimezone(timezone.utc).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + ) + auto_rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE, "auto_processed", "confirmed")), + FeishuItem.processed_at.is_not(None), + FeishuItem.processed_at >= today_start, + ) + ) + .order_by(FeishuItem.processed_at.desc()) + .limit(limit) + ) + ).scalars().all() + auto_rows = self._dedupe_queue_items(auto_rows) + auto_rows = [row for row in auto_rows if not self._is_water_talk_message(row.raw_message or "")] + + pending_items = [await self._serialize_item(row) for row in pending_rows] + pending_items.sort( + key=lambda x: ( + -int(x.get("priority_rank") or 0), + str(x.get("created_at") or ""), + ) + ) + auto_items = [await self._serialize_item(row) for row in auto_rows] + + signal_auto = len([x for x in auto_items if x.get("level") == "signal"]) + project_auto = len( + [ + x for x in auto_items + if x.get("level") == "project" + or ( + x.get("level") == "command" + and isinstance(x.get("command"), dict) + and str(x["command"].get("operation_code") or "") == "project.create" + ) + ] + ) + return { + "pending_count": len(pending_items), + "pending_items": pending_items, + "auto_processed_items": auto_items, + "auto_summary": { + "signals_auto_entered_today": signal_auto, + "projects_auto_created_today": project_auto, + }, + "cold_start": bool(routing_policy["cold_start_enabled"]), + } + + async def get_item_detail(self, item_id: int) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + return await self._serialize_item(item, include_raw=True, include_history=True) + + async def update_item( + self, + *, + item_id: int, + payload: dict[str, Any], + user_id: int, + ) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + + changed = False + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + if not suggestion: + raise ValueError("project_suggestion_not_found") + for field in ("suggested_name", "suggested_description", "suggested_owner", "suggested_timeline"): + if field in payload: + new_value = str(payload.get(field) or "").strip() or None + if getattr(suggestion, field) != new_value: + setattr(suggestion, field, new_value) + changed = True + if changed: + suggestion.status = "pending" + else: + signal = await self._get_signal_by_item(item.id) + if not signal: + raise ValueError("signal_not_found") + editable_fields = ( + "title", + "signal_type", + "priority", + "summary", + "recommended_action", + "matched_project_id", + ) + for field in editable_fields: + if field not in payload: + continue + old_value = getattr(signal, field) + raw_new = payload.get(field) + if field == "matched_project_id": + new_value = int(raw_new) if raw_new is not None else None + else: + new_value = str(raw_new).strip() if raw_new is not None else None + if new_value == "": + new_value = None + if old_value == new_value: + continue + setattr(signal, field, new_value) + self.db.add( + SignalEdit( + tenant_id=self.tenant_id, + signal_id=int(signal.id), + field=field, + old_value=None if old_value is None else str(old_value), + new_value=None if new_value is None else str(new_value), + edited_by=int(user_id), + edited_at=_utc_now().replace(tzinfo=None), + ) + ) + changed = True + + if changed: + await self.db.commit() + return await self.get_item_detail(item_id) + + async def dismiss_item(self, *, item_id: int, reason: str) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + item.route_status = ROUTE_STATUS_DISMISSED + item.queue_visible = False + item.dismissed_reason = (reason or "").strip()[:300] or "用户手动忽略" + item.processed_at = _utc_now().replace(tzinfo=None) + item.message_class = "ignored" + item.auto_execute_state = "dismissed" + + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion: + suggestion.status = "dismissed" + else: + signal = await self._get_signal_by_item(item.id) + if signal: + signal.processed = True + signal.status = "已关闭" + + await self.db.commit() + return {"id": str(item.id), "status": item.route_status} + + async def confirm_item(self, *, item_id: int, user_id: int, action: Optional[str] = None) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + + level = (item.level or "").lower() + result: dict[str, Any] = {"id": str(item.id), "level": level} + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if not suggestion: + raise ValueError("project_suggestion_not_found") + candidate_fields = self._resolve_project_candidate_fields(item=item, suggestion=suggestion) + if not candidate_fields["ready"]: + raise ValueError( + str(item.blocking_reason or "").strip() + or self._build_project_clarification_message(candidate_fields["missing_fields"]) + ) + operator_user = await self._load_active_user_by_id(user_id=int(user_id)) + if operator_user is None: + raise ValueError("operator_not_found") + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="project.create", + payload={ + "title": candidate_fields["title"], + "description": candidate_fields["description"], + }, + trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}", + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "project_create_failed")) + project_result = first.get("result") if isinstance(first.get("result"), dict) else {} + project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) + suggestion.status = "confirmed" + suggestion.created_project_id = int(project_id) if project_id is not None else None + item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id + item.result_payload = { + "intent": INTENT_CREATE_PROJECT, + "executor": "operation_executor", + "operation_code": "project.create", + "result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目", + "operation_result": project_result, + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + result["project_id"] = str(project_id) if project_id is not None else None + elif level == "command": + operator_user = await self._load_active_user_by_id(user_id=int(user_id)) + if operator_user is None: + raise ValueError("operator_not_found") + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_code = str(payload.get("operation_code") or "").strip() + operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} + if operation_code not in {"task.create"}: + raise ValueError("command_operation_not_supported") + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code=operation_code, + payload=operation_payload, + trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}", + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "command_execution_failed")) + project_id = _safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id) + task_id = _safe_int(first.get("entity_id")) + item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id + item.result_payload = { + **payload, + "executor": "operation_executor", + "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", + "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + result["project_id"] = str(project_id) if project_id is not None else None + result["task_id"] = str(task_id) if task_id is not None else None + else: + signal = await self._get_signal_by_item(item.id) + if not signal: + raise ValueError("signal_not_found") + matched_project_id = signal.matched_project_id or item.matched_project_id + if not matched_project_id: + raise ValueError("signal_missing_project") + await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) + signal.processed = False + signal.status = "待处理" + if action in {"create_charter", "trigger_alert"}: + signal.recommended_action = action + result["signal_id"] = str(signal.id) + result["project_id"] = str(matched_project_id) + + item.route_status = ROUTE_STATUS_AUTO_DONE + item.queue_visible = False + item.processed_at = _utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.safe_to_auto_execute = False + item.auto_execute_state = "done" + item.blocking_reason = None + item.next_step_text = "你已授权继续执行,结果已落地,可进入结果页查看。" + item.undoable_until = _build_undo_deadline(item.processed_at) if level in {"project", "signal"} else None + if level == "project": + created_project_id = _safe_int(result.get("project_id")) + item.result_payload = { + **(item.result_payload if isinstance(item.result_payload, dict) else {}), + "result_count": 1, + "impact_objects": [f"项目#{created_project_id}"] if created_project_id else [], + } + elif level == "signal": + project_id = _safe_int(result.get("project_id")) + signal_id = _safe_int(result.get("signal_id")) + item.result_payload = { + "result_summary": f"已确认信号 #{signal_id} 并关联项目 #{project_id}" if project_id and signal_id else "已确认信号并完成入库", + "result_count": 1, + "impact_objects": [f"信号#{signal_id}"] if signal_id else [], + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + else: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + item.result_payload = { + **payload, + "result_count": 1, + } + await self.db.commit() + return result + + async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + if self._normalize_route_status(item.route_status) != ROUTE_STATUS_AUTO_DONE: + raise ValueError("item_not_auto_processed") + + level = (item.level or "").lower() + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion and suggestion.created_project_id: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(suggestion.created_project_id), + ) + ) + ) + ).scalar_one_or_none() + if project: + project.status = "inactive" + if suggestion: + suggestion.status = "pending" + elif level == "signal": + signal = await self._get_signal_by_item(item.id) + if signal: + await self.db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.signal_id == int(signal.id), + ) + ) + ) + signal.status = "待处理" + signal.processed = False + + item.route_status = ROUTE_STATUS_UNDONE + item.queue_visible = False + item.processed_at = _utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.auto_execute_state = "undone" + item.undoable_until = None + existing_payload = item.result_payload if isinstance(item.result_payload, dict) else {} + item.result_payload = { + **existing_payload, + "result_summary": "该结果已撤销", + "undone": True, + } + await self.db.commit() + return {"id": str(item.id), "status": item.route_status} + + async def _process_one_item( + self, + *, + content: str, + level: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + level_result: dict[str, Any], + level_confidence: float, + level_reasoning: str, + project_score: float, + cold_start_enabled: bool, + signal_auto_enter_threshold: float, + project_auto_create_threshold: float, + project_uncertain_threshold: float, + chat_context_auto_link_enabled: bool, + projects: list[dict[str, Any]], + split_from_item_id: Optional[int], + chat_type: str, + chat_context_resolution: dict[str, Any], + ) -> dict[str, Any]: + item_started_at = time.perf_counter() + level_val = "project" if level == "project" else "signal" + matched_project_hint = _safe_int(chat_context_resolution.get("project_id")) + dedupe_key = self._build_queue_dedupe_key( + level=level_val, + intent=INTENT_SIGNAL_INTAKE if level_val == "signal" else INTENT_CREATE_PROJECT, + message=content, + source_chat_id=source_chat_id, + matched_project_id=matched_project_hint, + source_object=content, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.raw_message = content[:4000] + existing_item.source_sender = (sender or "")[:120] or None + existing_item.source_sender_open_id = (source_sender_open_id or "")[:128] or None + existing_item.source_chat_id = (source_chat_id or "")[:128] or None + existing_item.source_occurred_at = occurred_at.replace(tzinfo=None) + existing_item.level_reasoning = level_reasoning[:1000] + existing_item.ai_output = json.dumps( + { + "chat_type": chat_type, + "normalized_text": content, + "level_result": level_result, + }, + ensure_ascii=False, + )[:8000] + await self.db.flush() + return await self._build_existing_item_result(existing_item) + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=project_score, + signal_confidence=max(0.0, 1 - project_score), + raw_message=content[:4000], + split_from_item_id=split_from_item_id, + level=level_val, + level_confidence=level_confidence, + level_reasoning=level_reasoning[:1000], + card_type="signal" if level_val == "signal" else "new_project", + route_status=ROUTE_STATUS_CLASSIFIED, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps( + { + "chat_type": chat_type, + "normalized_text": content, + "level_result": level_result, + }, + ensure_ascii=False, + )[:8000], + decision_reason=level_reasoning[:1000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text="AI 正在校验上下文,确认后会继续推进后续动作。", + queue_visible=True, + ) + self.db.add(item) + await self.db.flush() + + if level_val == "project": + project_info = await self._extract_project_prefill(content) + suggestion = ProjectSuggestion( + tenant_id=self.tenant_id, + feishu_item_id=int(item.id), + suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], + suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], + suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], + suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], + confidence=_safe_float(project_info.get("confidence"), level_confidence), + reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), + status="pending", + ) + self.db.add(suggestion) + await self.db.flush() + project_id = await self._route_project_item( + item=item, + suggestion=suggestion, + sender=sender, + project_score=project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + ) + routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log( + project_score=project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + project_uncertain_threshold=project_uncertain_threshold, + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=int(item.id), + raw_message=content, + ai_raw_output=json.dumps( + { + "level_classification": level_result, + "project_prefill": project_info, + }, + ensure_ascii=False, + ), + project_score=project_score, + signal_confidence=max(0.0, 1 - project_score), + level="project", + routing_decision=routing_decision, + routing_reason=routing_reason, + reasoning_bullets=reasoning_bullets, + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - item_started_at) * 1000), + ) + return { + "item_id": int(item.id), + "level": "project", + "project_id": project_id, + "signal_id": None, + "route_status": item.route_status, + } + + simple_signal_request = self._is_simple_signal_source_request(content) + signal_info = await self._extract_signal(content, projects=projects) + matched_project_id = signal_info.get("matched_project_id") + if matched_project_id is not None: + try: + matched_project_id = int(matched_project_id) + except (TypeError, ValueError): + matched_project_id = None + + confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score)) + if simple_signal_request: + confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05)) + inferred_project_id: Optional[int] = None + clarification_message: Optional[str] = None + if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id: + inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) + if inferred_project_id is not None: + matched_project_id = int(inferred_project_id) + elif bool(chat_context_resolution.get("ambiguous")): + candidate_ids = [int(x) for x in (chat_context_resolution.get("candidate_ids") or [])[:3] if _safe_int(x)] + if candidate_ids: + clarification_message = ( + f"当前群最近同时涉及项目#{'、#'.join([str(pid) for pid in candidate_ids])}," + "请明确这条消息归属哪个项目。" + ) + + if matched_project_id is None and self._should_promote_signal_to_project_candidate( + content=content, + signal_info=signal_info, + confidence=confidence, + signal_auto_enter_threshold=signal_auto_enter_threshold, + simple_signal_request=simple_signal_request, + ): + promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold)) + item.level = "project" + item.project_score = promoted_project_score + item.signal_confidence = max(0.0, 1 - promoted_project_score) + item.card_type = "new_project_candidate" + item.matched_project_id = None + + project_info = await self._extract_project_prefill(content) + suggestion = ProjectSuggestion( + tenant_id=self.tenant_id, + feishu_item_id=int(item.id), + suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], + suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], + suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], + suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], + confidence=_safe_float(project_info.get("confidence"), confidence), + reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), + status="pending", + ) + self.db.add(suggestion) + await self.db.flush() + + project_id = await self._route_project_item( + item=item, + suggestion=suggestion, + sender=sender, + project_score=promoted_project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + ) + routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log( + project_score=promoted_project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + project_uncertain_threshold=project_uncertain_threshold, + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=int(item.id), + raw_message=content, + ai_raw_output=json.dumps( + { + "level_classification": level_result, + "signal_extraction": signal_info, + "promoted_to_project_candidate": True, + "simple_signal_request": simple_signal_request, + "project_prefill": project_info, + }, + ensure_ascii=False, + ), + project_score=promoted_project_score, + signal_confidence=confidence, + level="project", + routing_decision=routing_decision, + routing_reason=routing_reason, + reasoning_bullets=reasoning_bullets, + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - item_started_at) * 1000), + ) + return { + "item_id": int(item.id), + "level": "project", + "project_id": project_id, + "signal_id": None, + "route_status": item.route_status, + } + + signal = Signal( + tenant_id=self.tenant_id, + source="Feishu", + content=f"[{sender or '未知用户'}]: {content[:800]}", + impact_score=max(0.1, min(1.0, confidence)), + priority=self._to_signal_priority(str(signal_info.get("priority") or "P2")), + processed=False, + strategic_analysis=json.dumps( + { + "source_context": { + "group_id": source_chat_id, + "sender": sender, + "sender_open_id": source_sender_open_id, + }, + "ai_analysis": { + "classification": "signal", + "reason": signal_info.get("summary"), + }, + }, + ensure_ascii=False, + ), + tasks_generated="[]", + feishu_item_id=int(item.id), + matched_project_id=matched_project_id, + title=(signal_info.get("title") or content[:40] or "未命名信号")[:200], + signal_type=self._normalize_signal_type(signal_info.get("type")), + summary=(signal_info.get("summary") or "")[:3000] or None, + reasoning=json.dumps(_normalize_reasoning(signal_info.get("reasoning")), ensure_ascii=False), + confidence=confidence, + recommended_action=self._normalize_recommended_action(signal_info.get("recommended_action")), + status="待处理", + ) + self.db.add(signal) + await self.db.flush() + + project_id = await self._route_signal_item( + item=item, + signal=signal, + matched_project_id=matched_project_id, + confidence=confidence, + cold_start_enabled=cold_start_enabled, + signal_auto_enter_threshold=signal_auto_enter_threshold, + ) + routing_decision, routing_reason, reasoning_bullets = self._build_signal_routing_log( + confidence=confidence, + cold_start_enabled=cold_start_enabled, + matched_project_id=matched_project_id, + signal_auto_enter_threshold=signal_auto_enter_threshold, + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=int(item.id), + raw_message=content, + ai_raw_output=json.dumps( + { + "level_classification": level_result, + "signal_extraction": signal_info, + "simple_signal_request": simple_signal_request, + "chat_context_inferred_project_id": inferred_project_id, + "chat_context_resolution": chat_context_resolution, + "clarification_message": clarification_message, + }, + ensure_ascii=False, + ), + project_score=project_score, + signal_confidence=confidence, + level="signal", + routing_decision=routing_decision, + routing_reason=routing_reason, + reasoning_bullets=reasoning_bullets, + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - item_started_at) * 1000), + ) + return { + "item_id": int(item.id), + "level": "signal", + "project_id": project_id, + "signal_id": int(signal.id), + "route_status": item.route_status, + "clarification_message": clarification_message, + } + + async def _route_project_item( + self, + *, + item: FeishuItem, + suggestion: ProjectSuggestion, + sender: str, + project_score: float, + cold_start_enabled: bool, + project_auto_create_threshold: float, + ) -> Optional[int]: + _ = project_auto_create_threshold + high_score = project_score >= max(project_auto_create_threshold, 0.7) + item.card_type = "new_project" if high_score else "new_project_candidate" + item.route_status = ROUTE_STATUS_QUEUED + item.queue_visible = True + item.message_class = "action_required" + item.safe_to_auto_execute = False + item.auto_execute_state = "blocked" + item.blocking_reason = ( + "当前处于冷启动保护期,建项目默认需要你确认后再执行。" + if cold_start_enabled + else "建项目属于高风险动作,默认需要你授权后再执行。" + ) + item.decision_reason = ( + "intent gate/自由分类均不再直接 auto create,避免误把自然语言写成项目主数据。" + ) + item.next_step_text = "确认后,系统会走受控 project.create,并绑定真实项目 ID。" + return None + + async def _route_signal_item( + self, + *, + item: FeishuItem, + signal: Signal, + matched_project_id: Optional[int], + confidence: float, + cold_start_enabled: bool, + signal_auto_enter_threshold: float, + ) -> Optional[int]: + item.matched_project_id = int(matched_project_id) if matched_project_id else None + if cold_start_enabled or confidence < signal_auto_enter_threshold or not matched_project_id: + item.card_type = "signal" + item.route_status = ROUTE_STATUS_QUEUED + item.queue_visible = True + item.message_class = "action_required" + item.safe_to_auto_execute = False + item.auto_execute_state = "blocked" + if not matched_project_id: + item.blocking_reason = "当前缺少明确项目归属,继续自动执行会产生错误副作用。" + item.next_step_text = "补齐项目归属后,AI 将自动把信号入库并继续后续链路。" + item.decision_reason = "上下文尚不完整,系统暂停在需你决策队列。" + elif cold_start_enabled: + item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。" + item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。" + item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。" + else: + item.blocking_reason = "当前置信度不足以自动入库,需要你拍板后再继续。" + item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" + item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" + return matched_project_id + + await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) + item.card_type = "signal_auto" + item.route_status = ROUTE_STATUS_AUTO_DONE + item.queue_visible = False + item.processed_at = _utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.safe_to_auto_execute = True + item.auto_execute_state = "done" + item.blocking_reason = None + item.decision_reason = "低风险且上下文完整,AI 已自动完成信号入库。" + item.next_step_text = "下一步可基于该信号继续生成 charter 或分发任务。" + item.undoable_until = _build_undo_deadline(item.processed_at) + item.result_payload = { + "result_summary": f"已自动入库信号 {signal.title or '未命名信号'}", + "result_count": 1, + "impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"], + "target_url": f"/projects/{int(matched_project_id)}/board", + "project_id": int(matched_project_id), + } + await self._notify_signal_auto_entered( + project_id=int(matched_project_id), + signal=signal, + ) + return int(matched_project_id) + + async def _extract_project_prefill(self, message: str) -> dict[str, Any]: + prompt = PROMPT_FEISHU_PROJECT_PREFILL.format(message=message[:1500]) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_project_prefill", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("project_prefill_empty_json") + parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) + return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill") + except Exception as exc: + logger.warning(f"feishu_project_prefill_fallback: error={exc}") + fallback = self._heuristic_project_prefill_fallback(message) + return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback") + + async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]: + if self._is_simple_signal_source_request(message): + fallback = self._heuristic_signal_fallback(message=message, projects=projects) + return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath") + + prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format( + message=message[:1500], + projects_json=json.dumps(projects, ensure_ascii=False), + ) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_signal_extraction", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("signal_extraction_empty_json") + parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) + return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction") + except Exception as exc: + logger.warning(f"feishu_signal_extraction_fallback: error={exc}") + fallback = self._heuristic_signal_fallback(message=message, projects=projects) + return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback") + + async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: + if self._is_simple_signal_source_request(message): + fallback = self._heuristic_level_fallback(message) + return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath") + + prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format( + message=message[:1500], + projects_json=json.dumps(projects, ensure_ascii=False), + ) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_level_classification", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("level_classification_empty_json") + + # 兼容不同模型输出别名,避免只返回总分/中文 level 时被误降级到 signal。 + bi_raw = _first_non_empty(parsed, ("build_intent_score", "buildIntentScore", "intent_score", "build_intent")) + sc_raw = _first_non_empty(parsed, ("scope_score", "scopeScore", "scope")) + tl_raw = _first_non_empty(parsed, ("timeline_score", "timelineScore", "timeline")) + ow_raw = _first_non_empty(parsed, ("owner_score", "ownerScore", "owner")) + provided_project_score_raw = _first_non_empty(parsed, ("project_score", "projectScore", "score")) + + bi = _safe_float(bi_raw, 0.0) + sc = _safe_float(sc_raw, 0.0) + tl = _safe_float(tl_raw, 0.0) + ow = _safe_float(ow_raw, 0.0) + weighted = round((bi * 0.4) + (sc * 0.25) + (tl * 0.2) + (ow * 0.15), 4) + + component_count = sum(value is not None for value in (bi_raw, sc_raw, tl_raw, ow_raw)) + has_project_score = provided_project_score_raw is not None + provided_project_score = _safe_float(provided_project_score_raw, 0.0) + + final_project_score = weighted + if component_count < 4 and has_project_score: + final_project_score = provided_project_score + elif component_count > 0 and has_project_score: + # 当模型给出了总分与维度分,优先采用更保守的较高值,降低误判为 signal 的风险。 + final_project_score = max(weighted, provided_project_score) + final_project_score = max(0.0, min(1.0, final_project_score)) + + default_level = "project" if final_project_score >= 0.6 else "signal" + normalized_split_items: list[dict[str, str]] = [] + raw_split_items = parsed.get("split_items") + if isinstance(raw_split_items, list): + for item in raw_split_items[:2]: + if not isinstance(item, dict): + continue + item_level = _normalize_level_value(item.get("level"), default=default_level) + item_content = str(item.get("content") or "").strip() + if not item_content: + continue + normalized_split_items.append({"level": item_level, "content": item_content}) + + level_reasoning = str(parsed.get("level_reasoning") or "").strip() or "模型返回缺少理由,已按结构化兜底。" + payload: dict[str, Any] = { + "build_intent_score": max(0.0, min(1.0, bi)), + "scope_score": max(0.0, min(1.0, sc)), + "timeline_score": max(0.0, min(1.0, tl)), + "owner_score": max(0.0, min(1.0, ow)), + "project_score": final_project_score, + "level": _normalize_level_value(parsed.get("level"), default=default_level), + "level_confidence": max( + 0.0, + min(1.0, _safe_float(parsed.get("level_confidence"), max(final_project_score, 1 - final_project_score))), + ), + "level_reasoning": level_reasoning[:600], + "should_split": _safe_bool(parsed.get("should_split"), False), + "split_items": normalized_split_items, + } + if self._is_simple_signal_source_request(message): + payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35) + payload["level"] = "signal" + payload["should_split"] = False + payload["split_items"] = [] + payload["level_reasoning"] = ( + f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。" + )[:600] + return _validate_schema(_LevelClassificationSchema, payload, context="level_classification") + except Exception as exc: + logger.warning(f"feishu_level_classification_fallback: error={exc}") + fallback = self._heuristic_level_fallback(message) + return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback") + + def _heuristic_level_fallback(self, message: str) -> dict[str, Any]: + text = message or "" + build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2 + scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35 + timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1 + owner_score = 0.7 if OWNER_RE.search(text) else 0.15 + project_score = round( + (build_intent_score * 0.4) + + (scope_score * 0.25) + + (timeline_score * 0.2) + + (owner_score * 0.15), + 4, + ) + has_project_intent = bool(PROJECT_INTENT_RE.search(text)) + has_signal_fact = bool(SIGNAL_FACT_RE.search(text)) + should_split = bool(has_project_intent and has_signal_fact) + simple_signal_request = self._is_simple_signal_source_request(text) + if simple_signal_request: + project_score = min(project_score, 0.35) + should_split = False + level = "signal" + level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。" + else: + level = "project" if project_score >= 0.6 else "signal" + level_reasoning = "基于关键词与上下文规则的兜底判定。" + return { + "build_intent_score": build_intent_score, + "scope_score": scope_score, + "timeline_score": timeline_score, + "owner_score": owner_score, + "project_score": project_score, + "level": level, + "level_confidence": max(project_score, 1 - project_score), + "level_reasoning": level_reasoning, + "should_split": should_split, + "split_items": ( + [ + {"level": "project", "content": text}, + {"level": "signal", "content": text}, + ] + if should_split + else [] + ), + } + + @staticmethod + def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]: + normalized_message = str(message or "").strip().lower() + if not normalized_message: + return None + for row in projects: + if not isinstance(row, dict): + continue + pid_raw = row.get("id") + title = str(row.get("name") or row.get("title") or "").strip() + if not title or len(title) < 2: + continue + try: + pid = int(pid_raw) if pid_raw is not None else None + except Exception: + pid = None + if pid is None: + continue + if title.lower() in normalized_message: + return pid + return None + + def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: + text = str(message or "").strip() + normalized = text.lower() + cleaned = re.sub(r"\s+", " ", text).strip() + summary = cleaned[:280] if cleaned else "飞书消息(空内容)" + + signal_type = "新需求" + priority = "P2" + recommended_action = "enter_only" + if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")): + signal_type = "项目风险" + priority = "P1" + recommended_action = "trigger_alert" + elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")): + signal_type = "用户反馈" + priority = "P2" + elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")): + signal_type = "竞品动态" + priority = "P2" + + title_seed = cleaned or "飞书需求信号" + title = title_seed[:48] + if not title: + title = "飞书需求信号" + confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72 + + return { + "title": title, + "type": signal_type, + "priority": priority, + "matched_project_id": self._guess_matched_project_id_from_text(text, projects), + "summary": summary, + "reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"], + "confidence": confidence, + "recommended_action": recommended_action, + } + + @staticmethod + def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]: + text = str(message or "").strip() + cleaned = re.sub(r"\s+", " ", text).strip() + suggested_name = cleaned[:30] if cleaned else "飞书新需求项目" + if len(suggested_name) < 4: + suggested_name = "飞书新需求项目" + suggested_description = ( + f"由飞书需求自动生成的项目草案:{cleaned[:200]}" + if cleaned + else "由飞书需求自动生成的项目草案,待补充目标、范围与里程碑。" + ) + return { + "suggested_name": suggested_name, + "suggested_description": suggested_description, + "suggested_owner": "待指定", + "suggested_timeline": "待评估", + "confidence": 0.62, + "reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"], + } + + async def _load_routing_policy(self) -> dict[str, float | bool]: + integration = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + config: dict[str, Any] = {} + if integration is not None: + if isinstance(integration.config, dict): + config = integration.config + elif isinstance(integration.config, str): + config = _safe_json_object(integration.config) + + cold_start_enabled = bool(config.get("cold_start_enabled")) + chat_context_auto_link_enabled = bool(config.get("chat_context_auto_link_enabled", False)) + + signal_auto_enter_threshold = min( + 1.0, + max( + 0.0, + _safe_float( + config.get("signal_auto_enter_threshold"), + DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, + ), + ), + ) + project_auto_create_threshold = min( + 1.0, + max( + 0.0, + _safe_float( + config.get("project_auto_create_threshold"), + DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, + ), + ), + ) + project_uncertain_threshold = min( + project_auto_create_threshold, + max( + 0.0, + _safe_float( + config.get("project_uncertain_threshold"), + DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, + ), + ), + ) + return { + "cold_start_enabled": cold_start_enabled, + "signal_auto_enter_threshold": signal_auto_enter_threshold, + "project_auto_create_threshold": project_auto_create_threshold, + "project_uncertain_threshold": project_uncertain_threshold, + "chat_context_auto_link_enabled": chat_context_auto_link_enabled, + } + + async def _load_active_projects(self) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.status != "inactive", + ) + ) + ) + ).scalars().all() + return [ + { + "id": int(p.id), + "name": p.name, + "title": p.title or p.name, + "status": p.status, + "description": p.description or "", + } + for p in rows + ] + + async def _create_project_from_suggestion( + self, + suggestion: ProjectSuggestion, + *, + actor_user_id: Optional[int], + ) -> Project: + owner_id = await self._resolve_user_id_by_name(suggestion.suggested_owner) or actor_user_id + project_name = (suggestion.suggested_name or "新项目").strip()[:255] or "新项目" + project_desc = (suggestion.suggested_description or "").strip()[:4000] or None + await ensure_project_id_sequence(self.db) + project = Project( + tenant_id=self.tenant_id, + name=project_name, + title=project_name, + description=project_desc, + owner_id=owner_id, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + return project + + async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None: + existed = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if existed is not None: + return + row = ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal_id), + link_type="feishu_signal", + relevance_score=0.8, + ) + self.db.add(row) + + async def _notify_project_created(self, *, project: Project) -> None: + if not project.owner_id: + return + project_name = project.title or project.name or f"项目#{project.id}" + await NotificationService(self.db).create_notification( + tenant_id=self.tenant_id, + user_id=int(project.owner_id), + category="requirement", + title=f"创建项目:{project_name}", + content=f"AI 已根据飞书消息自动创建项目 [{project_name}],可直接进入 SSTC 看板。", + source="feishu", + entity_type="project", + entity_id=int(project.id), + target_url=f"/projects/{project.id}/board", + kind="auto_done", + priority="high", + status="auto_done", + recommended_action="view_result", + dedupe_key=f"feishu-project-auto:{int(project.id)}", + payload={ + "message_class": "auto_done", + "summary": f"AI 已自动创建项目 {project_name}", + "ai_plan": "下一步可进入项目看板补齐 charter 与执行任务。", + "project_id": int(project.id), + "project_name": project_name, + "result_count": 1, + "impact_objects": [project_name], + "can_undo": True, + "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), + }, + commit=False, + ) + + async def _notify_signal_auto_entered(self, *, project_id: int, signal: Signal) -> None: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + if not project or not project.owner_id: + return + project_name = project.title or project.name or f"项目#{project.id}" + await NotificationService(self.db).create_notification( + tenant_id=self.tenant_id, + user_id=int(project.owner_id), + category="requirement", + title=f"信号已自动入库:{signal.title or '未命名信号'}", + content="高置信度信号已自动进入项目信号源,可直接继续生成宪章。", + source="feishu", + entity_type="signal", + entity_id=int(signal.id), + target_url=f"/projects/{project_id}/board", + kind="auto_done", + priority=str(signal.priority or "medium"), + status="auto_done", + recommended_action="view_result", + dedupe_key=f"feishu-signal-auto:{int(signal.id)}:{int(project_id)}", + payload={ + "message_class": "auto_done", + "summary": f"AI 已把信号 {signal.title or f'#{signal.id}'} 自动入库到 {project_name}", + "ai_plan": "下一步可继续生成 charter、任务或通知负责人。", + "project_id": int(project_id), + "project_name": project_name, + "result_count": 1, + "impact_objects": [signal.title or f"信号#{signal.id}", project_name], + "can_undo": True, + "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), + }, + commit=False, + ) + + async def _load_project_name(self, project_id: Optional[int]) -> Optional[str]: + if project_id is None: + return None + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + return None + return str(project.title or project.name or "").strip() or f"项目#{int(project_id)}" + + @staticmethod + def _resolve_message_class(item: FeishuItem) -> str: + value = str(item.message_class or "").strip().lower() + if value in {"action_required", "auto_running", "auto_done", "info", "chat", "query", "ignored"}: + return value + route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) + if route_status == ROUTE_STATUS_QUEUED: + return "action_required" + if route_status == ROUTE_STATUS_AUTO_RUNNING: + return "auto_running" + if route_status in {ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE}: + return "auto_done" + return "info" + + @staticmethod + def _resolve_auto_execute_state(item: FeishuItem) -> str: + value = str(item.auto_execute_state or "").strip().lower() + if value: + return value + route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) + if route_status == ROUTE_STATUS_AUTO_DONE: + return "done" + if route_status == ROUTE_STATUS_QUEUED: + return "blocked" + if route_status == ROUTE_STATUS_UNDONE: + return "undone" + if route_status == ROUTE_STATUS_FAILED: + return "failed" + return "idle" + + async def _serialize_item( + self, + item: FeishuItem, + *, + include_raw: bool = False, + include_history: bool = False, + ) -> dict[str, Any]: + result_payload = item.result_payload if isinstance(item.result_payload, dict) else {} + ai_output = _safe_dict(item.ai_output) + project_name = await self._load_project_name(_safe_int(item.matched_project_id)) + row: dict[str, Any] = { + "id": str(item.id), + "feishu_message_id": item.feishu_message_id, + "level": item.level, + "card_type": item.card_type, + "route_status": self._normalize_route_status(item.route_status), + "project_score": item.project_score, + "signal_confidence": item.signal_confidence, + "level_confidence": item.level_confidence, + "level_reasoning": item.level_reasoning, + "matched_project_id": item.matched_project_id, + "project_name": project_name, + "sender": item.source_sender, + "created_at": item.created_at.isoformat() if item.created_at else None, + "processed_at": item.processed_at.isoformat() if item.processed_at else None, + "updated_at": item.updated_at.isoformat() if item.updated_at else None, + "decision_reason": item.decision_reason, + "blocking_reason": item.blocking_reason, + "safe_to_auto_execute": bool(item.safe_to_auto_execute), + "auto_execute_state": self._resolve_auto_execute_state(item), + "dedupe_key": item.dedupe_key, + "message_class": self._resolve_message_class(item), + "occurrence_count": int(item.occurrence_count or 1), + "next_step_text": item.next_step_text, + "undoable_until": item.undoable_until.isoformat() if item.undoable_until else None, + "result_payload": result_payload or None, + "display_kind": self._resolve_message_class(item), + "target_url": result_payload.get("target_url") if isinstance(result_payload, dict) else None, + "priority_rank": 0, + } + if not row["target_url"] and item.matched_project_id: + row["target_url"] = f"/projects/{int(item.matched_project_id)}/board" + + if include_raw: + row["raw_message"] = item.raw_message + + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + operation_payload = result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else {} + project_fields = { + "title": str( + (suggestion.suggested_name if suggestion is not None else None) + or operation_payload.get("title") + or "" + ).strip(), + "description": str( + (suggestion.suggested_description if suggestion is not None else None) + or operation_payload.get("description") + or "" + ).strip(), + "project_scope": str(result_payload.get("project_scope") or "").strip(), + "missing_fields": [], + "ready": False, + } + if ( + self._looks_like_placeholder_project_text(project_fields["title"]) + or self._looks_like_placeholder_project_text(project_fields["description"]) + or self._looks_like_placeholder_project_text(project_fields["project_scope"]) + ): + project_fields = await self._maybe_prefill_project_fields_from_context( + message=str(item.raw_message or ""), + source_chat_id=str(item.source_chat_id or ""), + source_sender_open_id=str(item.source_sender_open_id or "") or None, + sender=str(item.source_sender or ""), + fields=project_fields, + ) + if suggestion: + created_project_id = _safe_int(suggestion.created_project_id) + created_project_name = await self._load_project_name(created_project_id) + row["project_suggestion"] = { + "id": str(suggestion.id), + "suggested_name": suggestion.suggested_name, + "suggested_description": suggestion.suggested_description, + "suggested_owner": suggestion.suggested_owner, + "suggested_timeline": suggestion.suggested_timeline, + "confidence": suggestion.confidence, + "reasoning": _normalize_reasoning(suggestion.reasoning), + "status": suggestion.status, + "created_project_id": str(created_project_id) if created_project_id else None, + } + row["project_name"] = created_project_name or row["project_name"] + row["matched_project_id"] = created_project_id or row["matched_project_id"] + row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" + row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 + if created_project_id and not row["target_url"]: + row["target_url"] = f"/projects/{created_project_id}/board" + row["title"] = self._build_project_preview_title( + title=str(project_fields.get("title") or ""), + description=str(project_fields.get("description") or ""), + project_scope=str(project_fields.get("project_scope") or ""), + ) + row["summary"] = self._build_project_preview_summary( + description=str(project_fields.get("description") or ""), + project_scope=str(project_fields.get("project_scope") or ""), + blocking_reason=item.blocking_reason, + fallback_text=str(ai_output.get("normalized_text") or item.raw_message or ""), + ) + row["detail_summary"] = row["summary"] + if not row.get("priority"): + row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" + row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 + return row + + if (item.level or "").lower() == "command": + operation_code = str(result_payload.get("operation_code") or "").strip() + row["title"] = ( + str(result_payload.get("result_summary") or "").strip() + or operation_code + or "受控动作" + ) + row["summary"] = str(item.blocking_reason or row["title"] or "").strip() + row["priority"] = "high" if row["display_kind"] == "action_required" else "medium" + row["priority_rank"] = 4 if row["display_kind"] == "action_required" else 2 + if not row.get("next_step_text"): + row["next_step_text"] = self._build_pending_operation_next_step(operation_code=operation_code) + if result_payload: + row["command"] = { + "operation_code": operation_code or None, + "executor": str(result_payload.get("executor") or "").strip() or None, + "operation_payload": result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else None, + "operation_result": result_payload.get("operation_result") if isinstance(result_payload.get("operation_result"), dict) else None, + } + return row + + signal = await self._get_signal_by_item(item.id) + if signal: + matched_project_id = _safe_int(signal.matched_project_id) or _safe_int(item.matched_project_id) + matched_project_name = await self._load_project_name(matched_project_id) + row["signal"] = { + "id": str(signal.id), + "title": signal.title, + "signal_type": signal.signal_type, + "priority": signal.priority, + "summary": signal.summary, + "reasoning": _normalize_reasoning(signal.reasoning), + "confidence": signal.confidence, + "recommended_action": signal.recommended_action, + "matched_project_id": signal.matched_project_id, + "status": signal.status, + } + row["title"] = signal.title or "未命名信号" + row["summary"] = signal.summary + row["priority"] = signal.priority + row["priority_rank"] = PROJECT_PRIORITY_ORDER.get(str(signal.priority or "").upper(), 1) + row["matched_project_id"] = matched_project_id + row["project_name"] = matched_project_name or row["project_name"] + if matched_project_id and not row["target_url"]: + row["target_url"] = f"/projects/{matched_project_id}/board" + if not row.get("next_step_text"): + if row["display_kind"] == "auto_done": + row["next_step_text"] = "下一步可继续生成 charter、任务或通知负责人。" + elif not matched_project_id: + row["next_step_text"] = "补齐项目归属后,AI 将自动继续后续链路。" + if not row.get("summary"): + row["summary"] = _compact_text(signal.content or item.raw_message, max_len=140) + if include_history: + history_rows = ( + await self.db.execute( + select(SignalEdit) + .where( + and_( + SignalEdit.tenant_id == self.tenant_id, + SignalEdit.signal_id == int(signal.id), + ) + ) + .order_by(SignalEdit.edited_at.desc()) + .limit(20) + ) + ).scalars().all() + row["edit_history"] = [ + { + "field": h.field, + "old_value": h.old_value, + "new_value": h.new_value, + "edited_by": str(h.edited_by) if h.edited_by else None, + "edited_at": h.edited_at.isoformat() if h.edited_at else None, + } + for h in history_rows + ] + return row + + async def _resolve_user_id_by_name(self, name: Optional[str]) -> Optional[int]: + key = (name or "").strip() + if not key: + return None + user = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + func.lower(User.name) == key.lower(), + ) + ) + ) + ).scalar_one_or_none() + if user: + return int(user.id) + fuzzy = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + User.name.ilike(f"%{key}%"), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return int(fuzzy.id) if fuzzy else None + + async def _fallback_user_id(self) -> Optional[int]: + user = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return int(user) if user else None + + async def _resolve_chat_context_project(self, *, source_chat_id: str) -> dict[str, Any]: + chat_id = (source_chat_id or "").strip() + if not chat_id: + return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} + + recent_rows = ( + await self.db.execute( + select(FeishuItem.matched_project_id) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.source_chat_id == chat_id, + FeishuItem.matched_project_id.is_not(None), + FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, "auto_processed", "confirmed")), + ) + ) + .order_by(FeishuItem.id.desc()) + .limit(20) + ) + ).all() + project_ids = [int(row[0]) for row in recent_rows if row and row[0] is not None] + if not project_ids: + return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} + + unique_ids = set(project_ids) + candidate_ids = sorted(unique_ids, key=lambda pid: (-project_ids.count(pid), pid)) + if len(unique_ids) == 1: + return {"project_id": int(project_ids[0]), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": 1.0} + + top_pid = max(unique_ids, key=lambda pid: project_ids.count(pid)) + top_count = project_ids.count(top_pid) + ratio = top_count / len(project_ids) + if top_count >= 2 and ratio >= 0.6: + return {"project_id": int(top_pid), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": ratio} + return {"project_id": None, "ambiguous": True, "candidate_ids": candidate_ids, "top_ratio": ratio} + + async def _infer_project_id_from_chat_context(self, *, source_chat_id: str) -> Optional[int]: + resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) + return _safe_int(resolution.get("project_id")) + + async def _get_item(self, item_id: int) -> Optional[FeishuItem]: + return ( + await self.db.execute( + select(FeishuItem).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _get_project_suggestion(self, item_id: int) -> Optional[ProjectSuggestion]: + return ( + await self.db.execute( + select(ProjectSuggestion).where( + and_( + ProjectSuggestion.tenant_id == self.tenant_id, + ProjectSuggestion.feishu_item_id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _get_signal_by_item(self, item_id: int) -> Optional[Signal]: + return ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.feishu_item_id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + def _dedupe_queue_items(self, rows: list[FeishuItem]) -> list[FeishuItem]: + deduped: list[FeishuItem] = [] + seen_signal_keys: set[str] = set() + aggregated: dict[str, FeishuItem] = {} + for row in rows: + explicit_key = str(row.dedupe_key or "").strip() + if explicit_key: + existing = aggregated.get(explicit_key) + if existing is None: + aggregated[explicit_key] = row + deduped.append(row) + else: + existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max( + 1, int(row.occurrence_count or 1) + ) + continue + level = str(row.level or "").lower() + if level != "signal": + deduped.append(row) + continue + message_id = str(row.feishu_message_id or "").strip() + key = message_id or f"{str(row.raw_message or '')[:120]}::{str(row.source_sender or '')}" + if key in seen_signal_keys: + continue + seen_signal_keys.add(key) + deduped.append(row) + return deduped + + @staticmethod + def _is_water_talk_message(message: str) -> bool: + content = str(message or "").strip().lower() + if not content: + return True + normalized = re.sub(r"\s+", "", content) + question_hints = ( + "?", + "?", + "吗", + "怎么", + "如何", + "为什么", + "谁", + "多少", + "是否", + "有没有", + "几号", + "几点", + "周几", + ) + water_keywords = ( + "你是谁", + "你是誰", + "什么身份", + "身份确认", + "今天什么日期", + "今天几号", + "当前日期", + "日期疑问", + "询问日期", + "现在几点", + "几点了", + "今天周几", + "今天星期几", + "说话", + "在吗", + "在不在", + ) + requirement_hints = ( + "需求", + "优化", + "调整", + "修复", + "bug", + "故障", + "报错", + "项目", + "页面", + "接口", + "功能", + "排版", + "样式", + "任务", + "立项", + "上线", + "配置", + ) + if any(k in normalized for k in water_keywords) and not any(h in normalized for h in requirement_hints): + return True + if any(k in normalized for k in question_hints): + return False + return False + + @staticmethod + def _build_rag_conversation_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], + ) -> str: + chat = str(source_chat_id or "").strip() + if not chat: + return "" + actor = str(source_sender_open_id or "").strip() + if not actor: + actor = str(sender or "").strip().lower() + actor = re.sub(r"\s+", "", actor) + actor = actor[:96] if actor else "unknown" + return f"feishu:{chat}:{actor}" + + @staticmethod + def _is_requirement_like_question(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + normalized = re.sub(r"\s+", "", text.lower()) + has_question = ( + "?" in normalized + or "?" in normalized + or any( + cue in normalized + for cue in ("吗", "是否", "是不是", "要不要", "能不能", "可不可以", "需不需要", "有没有必要") + ) + ) + if not has_question: + return False + if INDEPENDENT_REQUIREMENT_RE.search(normalized): + return True + if SIMPLE_SIGNAL_REQUEST_RE.search(normalized): + return True + has_verb = bool(QUESTION_REQUIREMENT_VERB_RE.search(normalized)) + has_object = bool(QUESTION_REQUIREMENT_OBJECT_RE.search(normalized)) + return has_verb and has_object + + @staticmethod + def _extract_requirement_statement_from_question(message: str) -> str: + text = str(message or "").strip() + if not text: + return "" + candidate = re.sub(r"^(请问|问下|想问下|我想问下|麻烦问下|请教下)\s*", "", text, flags=re.IGNORECASE).strip() + candidate = re.sub(r"\s*[??!!。\.]+\s*$", "", candidate).strip() + candidate = re.sub(r"(吗|呢|呀|吧)\s*$", "", candidate).strip() + replacements = ( + (r"能不能", "需要"), + (r"可不可以", "需要"), + (r"要不要", "需要"), + (r"需不需要", "需要"), + (r"有没有必要", "需要"), + (r"是不是", ""), + (r"是否", ""), + ) + for pattern, repl in replacements: + candidate = re.sub(pattern, repl, candidate, flags=re.IGNORECASE) + candidate = re.sub(r"\s+", " ", candidate).strip(" ,,::") + return candidate or text + + async def _has_processed_message(self, feishu_message_id: str) -> bool: + key = (feishu_message_id or "").strip() + if not key: + return False + existed_in_items = ( + await self.db.execute( + select(FeishuItem.id).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.feishu_message_id == key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existed_in_items is not None: + return True + existed = ( + await self.db.execute( + select(AIProcessingLog.id).where( + and_( + AIProcessingLog.tenant_id == self.tenant_id, + AIProcessingLog.feishu_message_id == key, + or_( + AIProcessingLog.level.is_(None), + AIProcessingLog.level != "ingress", + ), + or_( + AIProcessingLog.routing_decision.is_(None), + AIProcessingLog.routing_decision != "event_received", + ), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return existed is not None + + async def _acquire_message_lock(self, feishu_message_id: str) -> None: + key = (feishu_message_id or "").strip() + if not key: + return + try: + bind = self.db.get_bind() + dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower() + except Exception: + dialect_name = "" + if dialect_name not in {"postgresql", "postgres"}: + return + try: + await self.db.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:key))"), + {"key": key}, + ) + except Exception as exc: + logger.warning(f"feishu_pipeline_message_lock_failed: key={key[:32]}, error={exc}") + + async def _write_processing_log( + self, + *, + feishu_message_id: Optional[str], + feishu_item_id: Optional[int], + raw_message: Optional[str], + ai_raw_output: Optional[str], + project_score: Optional[float], + signal_confidence: Optional[float], + level: Optional[str], + routing_decision: str, + routing_reason: Optional[str], + reasoning_bullets: Optional[list[str]], + processing_status: str, + error_message: Optional[str], + duration_ms: Optional[int], + normalized_text: Optional[str] = None, + route: Optional[str] = None, + intent: Optional[str] = None, + executor: Optional[str] = None, + operation_code: Optional[str] = None, + operation_result: Optional[dict[str, Any]] = None, + reply_source: Optional[str] = None, + ) -> None: + normalized_route = (route or routing_decision or "")[:80] or None + normalized_intent = (intent or level or "")[:40] or None + normalized_executor = (executor or ("operation_executor" if operation_code else "router"))[:40] or None + normalized_reply_source = ( + reply_source + or ("db_commit" if operation_code else "deterministic_reply") + ) + self.db.add( + AIProcessingLog( + tenant_id=self.tenant_id, + feishu_message_id=(feishu_message_id or "")[:128] or None, + feishu_item_id=int(feishu_item_id) if feishu_item_id is not None else None, + raw_message=(raw_message or "")[:8000] or None, + ai_raw_output=(ai_raw_output or "")[:120000] or None, + project_score=project_score, + signal_confidence=signal_confidence, + level=(level or "")[:20] or None, + normalized_text=(normalized_text or raw_message or "")[:8000] or None, + route=normalized_route, + intent=normalized_intent, + executor=normalized_executor, + operation_code=(operation_code or "")[:80] or None, + operation_result=operation_result if isinstance(operation_result, dict) else None, + reply_source=(normalized_reply_source or "")[:40] or None, + routing_decision=(routing_decision or ROUTING_FAILED)[:64], + routing_reason=(routing_reason or "")[:1000] or None, + reasoning_bullets=[str(x).strip()[:120] for x in (reasoning_bullets or [])[:3]], + processing_status=(processing_status or "success")[:20], + error_message=(error_message or "")[:4000] or None, + duration_ms=max(0, int(duration_ms)) if duration_ms is not None else None, + ) + ) + + def _build_project_routing_log( + self, + *, + project_score: float, + cold_start_enabled: bool, + project_auto_create_threshold: float, + project_uncertain_threshold: float, + ) -> tuple[str, str, list[str]]: + _ = project_auto_create_threshold + if cold_start_enabled: + return ( + ROUTING_PENDING_COLD_START, + "当前处于冷启动期,项目候选会统一进入待处理队列。", + [ + "冷启动期开启,自动创建策略暂缓。", + f"project_score={project_score:.2f},仅用于排序参考。", + "等待人工确认后再执行创建动作。", + ], + ) + if project_score >= project_uncertain_threshold: + return ( + ROUTING_PENDING_UNCERTAIN_PROJECT, + "项目分数较高,但建项已收口到待决策链,不再自动创建。", + [ + f"project_score={project_score:.2f},高于 {project_uncertain_threshold:.2f}。", + "即使意图强,也必须等待受控 project.create。", + "已进入待处理队列等待确认。", + ], + ) + return ( + ROUTING_PENDING_LOW_CONFIDENCE, + "项目分数偏低,暂不自动创建,进入待处理队列。", + [ + f"project_score={project_score:.2f},低于 {project_uncertain_threshold:.2f}。", + "消息更接近执行信号而非完整立项需求。", + "需要人工补充信息后再决策。", + ], + ) + + def _build_signal_routing_log( + self, + *, + confidence: float, + cold_start_enabled: bool, + matched_project_id: Optional[int], + signal_auto_enter_threshold: float, + ) -> tuple[str, str, list[str]]: + if cold_start_enabled: + return ( + ROUTING_PENDING_COLD_START, + "当前处于冷启动期,信号统一进入待处理队列。", + [ + "冷启动期开启,自动入库策略暂缓。", + f"signal_confidence={confidence:.2f},仅用于排序参考。", + "等待人工审核后再入库。", + ], + ) + if confidence < signal_auto_enter_threshold: + return ( + ROUTING_PENDING_LOW_CONFIDENCE, + "信号置信度低于自动入库阈值,已进入待处理队列。", + [ + f"signal_confidence={confidence:.2f},低于 {signal_auto_enter_threshold:.2f} 阈值。", + "当前信息不足以支持自动决策。", + "需要人工确认后再执行后续动作。", + ], + ) + if not matched_project_id: + return ( + ROUTING_PENDING_UNCERTAIN_PROJECT, + "置信度达标但项目归属不确定,转人工确认。", + [ + f"signal_confidence={confidence:.2f},达到自动判定线。", + "未识别到明确项目归属。", + "已进入待处理队列等待项目确认。", + ], + ) + return ( + ROUTING_AUTO_ENTER_SIGNAL, + "信号置信度达标且项目归属明确,系统已自动入库。", + [ + f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。", + f"已匹配项目 #{int(matched_project_id)}。", + "已自动建立信号关联并发送通知。", + ], + ) + + @staticmethod + def _normalize_recommended_action(value: Any) -> str: + action = str(value or "enter_only").strip().lower() + if action not in {"enter_only", "create_charter", "trigger_alert"}: + return "enter_only" + return action + + @staticmethod + def _normalize_signal_type(value: Any) -> str: + signal_type = str(value or "新需求").strip() + if signal_type in SIGNAL_TYPE_OPTIONS: + return signal_type + return "新需求" + + @staticmethod + def _to_signal_priority(value: str) -> str: + key = str(value or "").strip().upper() + if key in {"P0", "HIGH"}: + return "high" + if key in {"P1", "MEDIUM"}: + return "medium" + if key in {"P2", "LOW"}: + return "low" + if key == "P3": + return "low" + return "medium" + + def _should_promote_signal_to_project_candidate( + self, + *, + content: str, + signal_info: dict[str, Any], + confidence: float, + signal_auto_enter_threshold: float, + simple_signal_request: bool, + ) -> bool: + _ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request) + # 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库; + # 不再把 signal 自由升级为 project candidate。 + return False + + @staticmethod + def _is_simple_signal_source_request(message: str) -> bool: + text = re.sub(r"\s+", "", str(message or "")) + if len(text) < 6: + return False + if PROJECT_COMPLEXITY_RE.search(text): + return False + if SIMPLE_SIGNAL_REQUEST_RE.search(text): + return True + action_tokens = ("新增", "增加", "实现", "支持", "修复", "优化", "接入", "改造", "调整") + object_tokens = ("功能", "能力", "页面", "接口", "流程", "按钮", "提醒", "识别", "字段", "规则", "逻辑") + return ( + len(text) <= 180 + and any(token in text for token in action_tokens) + and any(token in text for token in object_tokens) + ) diff --git a/backend/app/services/fetcher.py b/backend/app/services/fetcher.py new file mode 100644 index 00000000..3dbf97cc --- /dev/null +++ b/backend/app/services/fetcher.py @@ -0,0 +1,9 @@ +"""外部信号抓取兼容实现。""" + +from __future__ import annotations + + +async def fetch_hn_stories(limit: int = 30) -> list[dict]: + """返回空列表,保留接口契约。""" + _ = limit + return [] diff --git a/backend/app/services/integration.py b/backend/app/services/integration.py new file mode 100644 index 00000000..12c2b7e9 --- /dev/null +++ b/backend/app/services/integration.py @@ -0,0 +1,540 @@ +""" +Neural Sync 2.0 - Integration Service + +集成服务 - 管理 Plane 和飞书集成 +- 配置管理 +- 状态监控 +- 健康检查 +- 故障降级 +""" + +import asyncio +import logging +from typing import Optional, Dict, List, Any +from pydantic import BaseModel, Field +from datetime import datetime, timedelta +from enum import Enum +import httpx + +logger = logging.getLogger(__name__) + + +class IntegrationType(str, Enum): + """集成类型""" + PLANE = "plane" + LARK = "lark" + GITHUB = "github" + GITLAB = "gitlab" + + +class IntegrationStatus(str, Enum): + """集成状态""" + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + DEGRADED = "degraded" + PENDING = "pending" + + +class PlaneConfig(BaseModel): + """Plane 配置""" + api_url: str = "https://api.plane.so/api/v1" + api_key: str = "" + workspace_slug: str = "" + project_id: str = "" + enabled: bool = True + + +class LarkConfig(BaseModel): + """飞书配置""" + app_id: str = "" + app_secret: str = "" + verification_token: str = "" + encrypt_key: str = "" + default_chat_id: str = "" + enabled: bool = True + + +class IntegrationHealth(BaseModel): + """集成健康状态""" + type: IntegrationType + status: IntegrationStatus + last_check: datetime + last_success: Optional[datetime] = None + last_error: Optional[str] = None + error_count: int = 0 + consecutive_failures: int = 0 + latency_ms: Optional[float] = None + message: str = "" + + +class IntegrationConfig(BaseModel): + """集成配置""" + plane: PlaneConfig = Field(default_factory=PlaneConfig) + lark: LarkConfig = Field(default_factory=LarkConfig) + + +class TaskSyncResult(BaseModel): + """任务同步结果""" + success: bool + integration_type: IntegrationType + tasks_created: int = 0 + tasks_updated: int = 0 + errors: List[str] = Field(default_factory=list) + details: Dict[str, Any] = Field(default_factory=dict) + + +class NotificationResult(BaseModel): + """通知发送结果""" + success: bool + integration_type: IntegrationType + recipients: int = 0 + error: Optional[str] = None + + +class IntegrationService: + """ + 集成服务 - 管理外部集成 + + 特性: + - 配置管理(动态加载/保存) + - 健康检查和监控 + - 自动故障降级 + - 重试机制 + - 事件通知 + """ + + # 健康检查配置 + HEALTH_CHECK_INTERVAL = 60 # 秒 + MAX_CONSECUTIVE_FAILURES = 3 + DEGRADE_THRESHOLD = 5 # 错误次数后降级 + + def __init__(self, config: Optional[IntegrationConfig] = None, event_bus=None): + self.config = config or IntegrationConfig() + self.event_bus = event_bus + + # 健康状态 + self._health_status: Dict[IntegrationType, IntegrationHealth] = { + IntegrationType.PLANE: IntegrationHealth( + type=IntegrationType.PLANE, + status=IntegrationStatus.PENDING, + last_check=datetime.utcnow(), + ), + IntegrationType.LARK: IntegrationHealth( + type=IntegrationType.LARK, + status=IntegrationStatus.PENDING, + last_check=datetime.utcnow(), + ), + } + + # 统计信息 + self._stats = { + IntegrationType.PLANE: {"synced": 0, "failed": 0}, + IntegrationType.LARK: {"sent": 0, "failed": 0}, + } + + # 服务实例(延迟加载) + self._plane_service = None + self._lark_service = None + + # 健康检查任务 + self._health_check_task: Optional[asyncio.Task] = None + + async def start(self): + """启动集成服务""" + self._health_check_task = asyncio.create_task(self._health_check_loop()) + logger.info("[Integration] Service started") + + async def stop(self): + """停止集成服务""" + if self._health_check_task: + self._health_check_task.cancel() + try: + await self._health_check_task + except asyncio.CancelledError: + pass + logger.info("[Integration] Service stopped") + + async def sync_tasks_to_plane( + self, + tasks: List[Dict[str, Any]], + is_urgent: bool = False + ) -> TaskSyncResult: + """ + 同步任务到 Plane + + 增强功能: + - 批量创建 + - 标签管理 + - 优先级映射 + - 错误重试 + """ + integration_type = IntegrationType.PLANE + health = self._health_status[integration_type] + + # 检查服务是否可用 + if not self._is_integration_available(integration_type): + return TaskSyncResult( + success=False, + integration_type=integration_type, + errors=[f"Plane integration is {health.status.value}"], + ) + + start_time = datetime.utcnow() + + try: + # 获取 Plane 服务 + plane = await self._get_plane_service() + + # 批量同步 + result = await plane.sync_tasks(tasks, is_urgent) + + # 更新统计 + if result.get("status") == "success": + self._stats[integration_type]["synced"] += result.get("created_count", 0) + await self._record_success(integration_type) + else: + self._stats[integration_type]["failed"] += len(result.get("errors", [])) + await self._record_failure(integration_type, ", ".join(result.get("errors", []))) + + # 计算延迟 + latency = (datetime.utcnow() - start_time).total_seconds() * 1000 + health.latency_ms = latency + + return TaskSyncResult( + success=result.get("status") == "success", + integration_type=integration_type, + tasks_created=result.get("created_count", 0), + errors=result.get("errors", []), + details=result, + ) + + except Exception as e: + logger.error(f"[Integration] Plane sync failed: {e}") + await self._record_failure(integration_type, str(e)) + return TaskSyncResult( + success=False, + integration_type=integration_type, + errors=[str(e)], + ) + + async def send_strategic_alert( + self, + chat_id: str, + signal_content: str, + decision: Dict[str, Any], + tasks: List[Dict[str, Any]] + ) -> NotificationResult: + """ + 发送战略预警到飞书 + + 增强功能: + - 富文本卡片 + - 可操作按钮 + - @提及支持 + - 消息去重 + """ + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + + # 检查服务是否可用 + if not self._is_integration_available(integration_type): + return NotificationResult( + success=False, + integration_type=integration_type, + error=f"Lark integration is {health.status.value}", + ) + + start_time = datetime.utcnow() + + try: + # 获取飞书服务 + lark = await self._get_lark_service() + + # 发送卡片消息 + await lark.send_strategic_alert(chat_id, signal_content, decision, tasks) + + # 更新统计 + self._stats[integration_type]["sent"] += 1 + await self._record_success(integration_type) + + # 计算延迟 + latency = (datetime.utcnow() - start_time).total_seconds() * 1000 + health.latency_ms = latency + + # 发送事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.LARK_NOTIFY_SUCCESS, + data={ + "type": "strategic_alert", + "chat_id": chat_id, + }, + ) + ) + + return NotificationResult( + success=True, + integration_type=integration_type, + recipients=1, + ) + + except Exception as e: + logger.error(f"[Integration] Lark notification failed: {e}") + await self._record_failure(integration_type, str(e)) + + # 发送失败事件 + if self.event_bus: + from ..events import EventType, Event + await self.event_bus.publish( + Event( + type=EventType.LARK_NOTIFY_FAILED, + data={ + "type": "strategic_alert", + "error": str(e), + }, + ) + ) + + return NotificationResult( + success=False, + integration_type=integration_type, + error=str(e), + ) + + async def send_card_message( + self, + chat_id: str, + content: str, + tasks: List[Dict[str, Any]] + ) -> NotificationResult: + """ + 发送卡片消息到飞书 + """ + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + + if not self._is_integration_available(integration_type): + return NotificationResult( + success=False, + integration_type=integration_type, + error=f"Lark integration is {health.status.value}", + ) + + try: + lark = await self._get_lark_service() + await lark.send_card_message(chat_id, content, tasks) + + self._stats[integration_type]["sent"] += 1 + await self._record_success(integration_type) + + return NotificationResult( + success=True, + integration_type=integration_type, + recipients=1, + ) + + except Exception as e: + logger.error(f"[Integration] Lark card message failed: {e}") + await self._record_failure(integration_type, str(e)) + return NotificationResult( + success=False, + integration_type=integration_type, + error=str(e), + ) + + async def update_plane_issue_status( + self, + issue_id: str, + status: str = "Done" + ) -> bool: + """更新 Plane 工单状态""" + integration_type = IntegrationType.PLANE + + if not self._is_integration_available(integration_type): + return False + + try: + plane = await self._get_plane_service() + result = await plane.update_issue_status(issue_id, status) + + if "error" not in result: + await self._record_success(integration_type) + return True + + await self._record_failure(integration_type, result.get("error", "Unknown error")) + return False + + except Exception as e: + logger.error(f"[Integration] Plane status update failed: {e}") + await self._record_failure(integration_type, str(e)) + return False + + def get_health_status(self) -> Dict[str, IntegrationHealth]: + """获取所有集成健康状态""" + return {k.value: v for k, v in self._health_status.items()} + + def get_stats(self) -> Dict[str, Dict[str, int]]: + """获取集成统计信息""" + return {k.value: v for k, v in self._stats.items()} + + async def check_plane_health(self) -> IntegrationHealth: + """检查 Plane 集成健康""" + integration_type = IntegrationType.PLANE + health = self._health_status[integration_type] + start_time = datetime.utcnow() + + if not self.config.plane.enabled or not self.config.plane.api_key: + health.status = IntegrationStatus.INACTIVE + health.message = "Plane integration is disabled" + health.last_check = datetime.utcnow() + return health + + try: + # 简单的健康检查:尝试获取项目标签 + plane = await self._get_plane_service() + label_id = await plane.get_label_id("__health_check__") + + health.status = IntegrationStatus.ACTIVE + health.message = "Connected" + health.last_check = datetime.utcnow() + health.latency_ms = (datetime.utcnow() - start_time).total_seconds() * 1000 + + except Exception as e: + logger.warning(f"[Integration] Plane health check failed: {e}") + health.status = IntegrationStatus.ERROR + health.message = f"Health check failed: {str(e)}" + health.last_check = datetime.utcnow() + + return health + + async def check_lark_health(self) -> IntegrationHealth: + """检查飞书集成健康""" + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + start_time = datetime.utcnow() + + if not self.config.lark.enabled or not self.config.lark.app_id: + health.status = IntegrationStatus.INACTIVE + health.message = "Lark integration is disabled" + health.last_check = datetime.utcnow() + return health + + try: + # 简单的健康检查:尝试获取访问令牌 + lark = await self._get_lark_service() + token = await lark.get_tenant_access_token() + + if token and token != "mock_token": + health.status = IntegrationStatus.ACTIVE + health.message = "Connected" + else: + health.status = IntegrationStatus.DEGRADED + health.message = "Running in simulation mode" + + health.last_check = datetime.utcnow() + health.latency_ms = (datetime.utcnow() - start_time).total_seconds() * 1000 + + except Exception as e: + logger.warning(f"[Integration] Lark health check failed: {e}") + health.status = IntegrationStatus.ERROR + health.message = f"Health check failed: {str(e)}" + health.last_check = datetime.utcnow() + + return health + + async def update_config(self, config: IntegrationConfig) -> None: + """更新集成配置""" + self.config = config + + # 重新评估健康状态 + await self.check_plane_health() + await self.check_lark_health() + + logger.info("[Integration] Configuration updated") + + async def _get_plane_service(self): + """获取 Plane 服务实例""" + if self._plane_service is None: + from .plane import PlaneService + self._plane_service = PlaneService() + # 应用配置 + if self.config.plane.api_url: + self._plane_service.api_url = self.config.plane.api_url + if self.config.plane.api_key: + self._plane_service.api_key = self.config.plane.api_key + if self.config.plane.workspace_slug: + self._plane_service.workspace_slug = self.config.plane.workspace_slug + if self.config.plane.project_id: + self._plane_service.project_id = self.config.plane.project_id + self._plane_service.is_simulation = not self.config.plane.api_key + return self._plane_service + + async def _get_lark_service(self): + """获取飞书服务实例""" + if self._lark_service is None: + from .lark import LarkService + self._lark_service = LarkService() + # 应用配置 + if self.config.lark.app_id: + self._lark_service.app_id = self.config.lark.app_id + if self.config.lark.app_secret: + self._lark_service.app_secret = self.config.lark.app_secret + if self.config.lark.verification_token: + self._lark_service.verification_token = self.config.lark.verification_token + return self._lark_service + + def _is_integration_available(self, integration_type: IntegrationType) -> bool: + """检查集成是否可用""" + health = self._health_status[integration_type] + return health.status in [IntegrationStatus.ACTIVE, IntegrationStatus.DEGRADED] + + async def _record_success(self, integration_type: IntegrationType) -> None: + """记录成功操作""" + health = self._health_status[integration_type] + health.last_success = datetime.utcnow() + health.consecutive_failures = 0 + if health.status == IntegrationStatus.ERROR: + health.status = IntegrationStatus.ACTIVE + + async def _record_failure(self, integration_type: IntegrationType, error: str) -> None: + """记录失败操作""" + health = self._health_status[integration_type] + health.last_error = error + health.error_count += 1 + health.consecutive_failures += 1 + health.last_check = datetime.utcnow() + + # 达到降级阈值 + if health.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES: + health.status = IntegrationStatus.ERROR + health.message = f"Service degraded after {health.consecutive_failures} consecutive failures" + logger.warning(f"[Integration] {integration_type.value} degraded after {health.consecutive_failures} failures") + + async def _health_check_loop(self) -> None: + """健康检查循环""" + while True: + try: + await asyncio.sleep(self.HEALTH_CHECK_INTERVAL) + + await self.check_plane_health() + await self.check_lark_health() + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[Integration] Health check loop error: {e}") + + +# 全局服务实例 +_integration_service = None + + +def get_integration_service(config: Optional[IntegrationConfig] = None, event_bus=None) -> IntegrationService: + """获取集成服务实例""" + global _integration_service + if _integration_service is None: + _integration_service = IntegrationService(config, event_bus) + return _integration_service diff --git a/backend/app/services/lark.py b/backend/app/services/lark.py new file mode 100644 index 00000000..ac93a697 --- /dev/null +++ b/backend/app/services/lark.py @@ -0,0 +1,36 @@ +"""飞书服务兼容层。""" + +from __future__ import annotations + + +class LarkService: + def __init__(self) -> None: + self.app_id: str | None = None + self.app_secret: str | None = None + self.verification_token: str | None = None + + async def send_strategic_alert( + self, + chat_id: str, + signal_content: str, + decision_proposal: dict, + tasks: list[dict], + ) -> dict: + return { + "chat_id": chat_id, + "status": "sent", + "tasks": len(tasks or []), + "summary": (signal_content or "")[:80], + "decision": decision_proposal, + } + + async def send_card_message(self, chat_id: str, content: str, tasks: list[dict]) -> dict: + return { + "chat_id": chat_id, + "status": "sent", + "tasks": len(tasks or []), + "content_preview": (content or "")[:80], + } + + +lark_service = LarkService() diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py new file mode 100644 index 00000000..d6cef769 --- /dev/null +++ b/backend/app/services/llm.py @@ -0,0 +1,2560 @@ +"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。 + +能力: +1. 多引擎配置持久化(Integration.type=llm_engine) +2. 动态选择激活引擎 +3. OpenAI-Compatible chat/completions 调用 +4. 基础 AI 调用日志写入(ai_call_logs) +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import hashlib +import json +import os +import re +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +import httpx +from sqlalchemy import and_, select +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import build_default_role_prompt_bundle +from ..database import AsyncSessionLocal +from ..models import AICallLog, Integration + +try: + import redis.asyncio as redis_async + + REDIS_AVAILABLE = True +except Exception: # pragma: no cover + REDIS_AVAILABLE = False + redis_async = None + +SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} +OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1" +OPENCLAW_DEFAULT_LOW_MODEL = "zai/glm-4-flash" +OPENCLAW_DEFAULT_HIGH_MODEL = "minimax/MiniMax-M2.5" +OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek/deepseek-chat" +PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { + "deepseek": { + "name": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "api_env": "DEEPSEEK_API_KEY", + }, + "glm": { + "name": "GLM", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "model": "glm-4-flash", + "api_env": "GLM_API_KEY", + }, + "openai": { + "name": "OpenAI", + "base_url": OPENCLAW_PRIMARY_GATEWAY_URL, + "model": OPENCLAW_DEFAULT_LOW_MODEL, + "api_env": "OPENAI_API_KEY", + }, + "ollama": { + "name": "Ollama", + "base_url": "http://127.0.0.1:11434", + "model": "llama3:latest", + "api_env": "", + }, +} +PROVIDER_ALIASES = { + "zhipu": "glm", + "chatglm": "glm", + "deep-seek": "deepseek", + "local": "ollama", +} +PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0 +BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} +OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0 +OPENCLAW_BACKEND_RULES_PATH = Path(__file__).resolve().parents[2] / ".openclaw" / "pmflow-routing-rules.json" +OPENCLAW_PROJECT_RULES_PATH = Path(__file__).resolve().parents[3] / ".openclaw" / "pmflow-routing-rules.json" +OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 +OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 +DEFAULT_TIER_MODEL_OVERRIDES = { + "low": OPENCLAW_DEFAULT_LOW_MODEL, + "high": OPENCLAW_DEFAULT_HIGH_MODEL, + "safety": OPENCLAW_DEFAULT_SAFETY_MODEL, +} +DEFAULT_HIGH_TIER_HINTS = ( + "project_from_text", + "feishu_project_prefill", + "feishu_task_orchestration", + "feishu_project_signal_classification", + "adaptive_rag_answer_deep", + "rag_db_project_semantic_retrieval", + "analyze_food_calories", + "sstc_ai_breakdown", + "generate_task_breakdown", + "recommend_assignee", + "alignment", + "charter", + "cognitive_conflict", + "blocker", + "model_requirement", +) +DEFAULT_LOW_TIER_HINTS = ( + "feishu_level_classification", + "feishu_signal_extraction", + "feishu_humanized_reply", + "signal_source_onboarding_plan", + "openclaw_command_dispatch", + "signal_source_ai_worker_execution", + "extract_context_insights", + "adaptive_rag_answer_normal", + "rag_intent_classification", + "classify_intent", +) +STRUCTURED_OUTPUT_FUNCTION_HINTS = ( + "feishu_level_classification", + "feishu_signal_extraction", + "signal_source_onboarding_plan", + "signal_source_ai_worker_execution", + "rag_intent_classification", + "classify_intent", + "feishu_project_prefill", + "feishu_task_orchestration", + "openclaw_command_dispatch", + "generate_task_breakdown", + "generate_charter_from_signal", + "decompose_tasks_from_charter", + "recommend_assignees_for_task", + "evaluate_model_requirement", + "calculate_alignment_score", + "detect_cognitive_conflicts", + "rag_db_project_semantic_retrieval", + "workload_optimize_prompt", + "analyze_cost_roi", + "analyze_food_calories", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", +) +LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( + "不知道", + "不清楚", + "无法回答", + "无法确定", + "无法判断", + "信息不足", + "缺少信息", + "没有足够信息", + "请提供更多信息", + "抱歉", + "不能提供", + "无法访问", + "as an ai", + "i'm sorry", + "cannot answer", + "don't know", +) +FAST_LOCAL_FALLBACK_FUNCTION_HINTS = ( + "signal_source_onboarding_plan", + "openclaw_command_dispatch", + "signal_source_ai_worker_execution", +) +FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( + "signal_source_onboarding_plan", + "openclaw_command_dispatch", +) +FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) +FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0 +FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0 +STRUCTURED_CACHE_TTL_SECONDS = 300 +FAIL_FAST_ERROR_MARKERS = ( + "primary_engine_empty_response", + "high_tier_empty_response", + "llm 返回空内容", + "返回空内容", + "empty_response", + "provider_circuit_open", +) +CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2 +CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0 +STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { + "signal_source_onboarding_plan": { + "required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"), + "type_checks": { + "should_onboard": "bool", + "summary": "str", + "worker_roles": "list", + "openclaw_instruction": "str", + }, + "optional_type_checks": { + "source": ("dict_or_none",), + }, + }, + "openclaw_command_dispatch": { + "required_keys": ("summary", "confidence", "operations"), + "type_checks": { + "summary": "str", + "confidence": "number", + "operations": "list", + }, + }, + "signal_source_ai_worker_execution": { + "required_keys": ("summary", "deliverables", "risks", "next_actions"), + "type_checks": { + "summary": "str", + "deliverables": "list", + "risks": "list", + "next_actions": "list", + }, + }, +} + + +def _normalize_provider(raw: Optional[str]) -> str: + provider = str(raw or "").strip().lower() + provider = PROVIDER_ALIASES.get(provider, provider) + if provider not in SUPPORTED_PROVIDERS: + raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") + return provider + + +def _mask_secret(secret: str) -> str: + value = str(secret or "").strip() + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + + +def _safe_json(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + # 返回深拷贝,避免原地修改导致 SQLAlchemy 不感知 JSON 字段变更 + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in BOOL_TRUE_VALUES + + +def _parse_csv_items(raw: str) -> tuple[str, ...]: + rows = [item.strip().lower() for item in str(raw or "").split(",") if item.strip()] + return tuple(rows) + + +def _cn_now_text() -> str: + cn_tz = timezone(timedelta(hours=8)) + return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + + +def _runtime_log(scope: str, message: str) -> None: + print(f"[{_cn_now_text()}][{scope}] {message}") + + +@dataclass +class ChatCompletionResult: + text: str + raw_response: Any = None + + +@dataclass +class StructuredExtractionResult: + payload: Optional[dict[str, Any]] + source_field: str + provider: str + model: str + error_code: Optional[str] = None + raw_text: str = "" + validation: str = "invalid" + from_cache: bool = False + + +class LLMService: + _provider_circuit_state: dict[str, dict[str, Any]] = {} + + def __init__(self) -> None: + self.request_timeout = float(os.getenv("LLM_REQUEST_TIMEOUT", "45")) + self.redis_url = os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {} + self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis_async.from_url(self.redis_url, decode_responses=True) + return self._redis + + @staticmethod + def _normalize_prompt_for_hash(prompt: str) -> str: + return re.sub(r"\s+", " ", str(prompt or "")).strip() + + @classmethod + def _structured_cache_key(cls, *, function_name: str, model: str, prompt: str) -> str: + normalized_prompt = cls._normalize_prompt_for_hash(prompt) + digest = hashlib.sha256(normalized_prompt.encode("utf-8")).hexdigest() + return f"pmflow:structured:{function_name}:{model}:{digest}" + + async def _structured_cache_get(self, *, key: str) -> Optional[dict[str, Any]]: + redis = await self._get_redis() + if redis is None: + return None + try: + raw = await redis.get(key) + except Exception: + return None + if not raw: + return None + try: + parsed = json.loads(raw) + except Exception: + return None + return parsed if isinstance(parsed, dict) else None + + async def _structured_cache_set(self, *, key: str, value: dict[str, Any], ttl: int = STRUCTURED_CACHE_TTL_SECONDS) -> None: + redis = await self._get_redis() + if redis is None: + return + try: + await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) + except Exception: + return + + @staticmethod + def _provider_circuit_key(*, provider: str, base_url: str, model: str) -> str: + return f"{str(provider or '').strip().lower()}|{str(base_url or '').strip().rstrip('/')}|{str(model or '').strip()}" + + @staticmethod + def _http_status_from_exception(exc: Exception) -> Optional[int]: + if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None: + try: + return int(exc.response.status_code) + except Exception: + return None + return None + + @classmethod + def _is_fail_fast_exception(cls, exc: Exception) -> bool: + status_code = cls._http_status_from_exception(exc) + if status_code in {401, 403}: + return True + text = str(exc or "").strip().lower() + return any(marker in text for marker in FAIL_FAST_ERROR_MARKERS) + + @classmethod + def _is_circuit_breaker_worthy_exception(cls, exc: Exception) -> bool: + if cls._is_fail_fast_exception(exc): + return True + if isinstance(exc, httpx.RequestError): + return True + status_code = cls._http_status_from_exception(exc) + return status_code in {408, 409, 429, 500, 502, 503, 504} + + @classmethod + def _is_provider_circuit_open(cls, circuit_key: str) -> bool: + state = cls._provider_circuit_state.get(str(circuit_key)) + if not state: + return False + try: + open_until = float(state.get("open_until") or 0.0) + except Exception: + open_until = 0.0 + now = time.monotonic() + if open_until <= now: + if open_until: + cls._provider_circuit_state.pop(str(circuit_key), None) + return False + return True + + @classmethod + def _record_provider_success(cls, circuit_key: str) -> None: + cls._provider_circuit_state.pop(str(circuit_key), None) + + @classmethod + def _record_provider_failure(cls, circuit_key: str, exc: Exception) -> None: + if not cls._is_circuit_breaker_worthy_exception(exc): + return + now = time.monotonic() + state = dict(cls._provider_circuit_state.get(str(circuit_key)) or {}) + consecutive = int(state.get("consecutive_failures") or 0) + 1 + open_until = float(state.get("open_until") or 0.0) + if consecutive >= CIRCUIT_BREAKER_FAILURE_THRESHOLD: + open_until = now + CIRCUIT_BREAKER_COOLDOWN_SECONDS + cls._provider_circuit_state[str(circuit_key)] = { + "consecutive_failures": consecutive, + "open_until": open_until, + "last_error": str(exc)[:240], + "updated_at_monotonic": now, + } + + # --------------------------------------------------------------------- + # Engine config management + # --------------------------------------------------------------------- + async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + return { + "active_engine_id": bundle.get("active_engine_id"), + "engines": [self._sanitize_engine_for_response(item) for item in engines], + } + + async def create_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + name: str, + provider: str, + api_key: Optional[str], + model: Optional[str] = None, + base_url: Optional[str] = None, + set_active: bool = True, + ) -> dict[str, Any]: + provider = _normalize_provider(provider) + api_key = str(api_key or "").strip() + if provider != "ollama" and not api_key: + raise ValueError("api_key 不能为空") + + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + now = datetime.now(timezone.utc).isoformat() + engine_id = str(uuid4()) + defaults = PROVIDER_DEFAULTS[provider] + engine = { + "id": engine_id, + "name": (name or defaults["name"]).strip()[:80] or defaults["name"], + "provider": provider, + "api_key": api_key, + "model": (model or defaults["model"]).strip() or defaults["model"], + "base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"], + "created_at": now, + "updated_at": now, + } + engines = bundle.get("engines", []) + engines.append(engine) + bundle["engines"] = engines + if set_active or not bundle.get("active_engine_id"): + bundle["active_engine_id"] = engine_id + integration.config = json.loads(json.dumps(bundle)) + integration.is_active = True + await db.commit() + return self._sanitize_engine_for_response(engine) + + async def update_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + name: Optional[str] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + target: Optional[dict[str, Any]] = None + for item in engines: + if str(item.get("id")) == str(engine_id): + target = item + break + if not target: + raise ValueError("engine 不存在") + + if provider is not None: + target["provider"] = _normalize_provider(provider) + active_provider = _normalize_provider(target.get("provider")) + defaults = PROVIDER_DEFAULTS[active_provider] + + if name is not None: + target["name"] = (name or "").strip()[:80] or target.get("name") or defaults["name"] + if api_key is not None: + api_key_val = str(api_key).strip() + if api_key_val: + target["api_key"] = api_key_val + if model is not None: + target["model"] = (model or "").strip() or defaults["model"] + if base_url is not None: + target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"] + + target["updated_at"] = datetime.now(timezone.utc).isoformat() + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return self._sanitize_engine_for_response(target) + + async def delete_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + kept = [item for item in engines if str(item.get("id")) != str(engine_id)] + if len(kept) == len(engines): + raise ValueError("engine 不存在") + bundle["engines"] = kept + active_engine_id = str(bundle.get("active_engine_id") or "") + if active_engine_id == str(engine_id): + bundle["active_engine_id"] = kept[0]["id"] if kept else None + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return { + "deleted_engine_id": str(engine_id), + "active_engine_id": bundle.get("active_engine_id"), + "remaining_count": len(kept), + } + + async def activate_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + exists = any(str(item.get("id")) == str(engine_id) for item in engines) + if not exists: + raise ValueError("engine 不存在") + bundle["active_engine_id"] = str(engine_id) + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return {"active_engine_id": str(engine_id)} + + async def test_engine_connection( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: Optional[str] = None, + prompt: Optional[str] = None, + max_tokens: int = 64, + ) -> dict[str, Any]: + test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip() + if not test_prompt: + raise ValueError("测试 prompt 不能为空") + + engine = await self._resolve_engine_for_test( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + response_text = "" + status = "success" + started = time.perf_counter() + try: + response_text = await self._chat_completion( + provider=str(engine.get("provider") or ""), + prompt=test_prompt, + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=str(engine.get("model") or ""), + temperature=0.0, + max_tokens=max_tokens, + ) + if not response_text.strip(): + raise ValueError("测试响应为空") + latency_ms = int((time.perf_counter() - started) * 1000) + return { + "ok": True, + "engine_id": str(engine.get("id") or ""), + "name": str(engine.get("name") or ""), + "provider": str(engine.get("provider") or ""), + "model": str(engine.get("model") or ""), + "base_url": str(engine.get("base_url") or ""), + "api_key_masked": _mask_secret(str(engine.get("api_key") or "")), + "latency_ms": latency_ms, + "response_preview": response_text[:300], + } + except Exception as exc: + status = "failed" + response_text = f"[error] {exc}" + raise ValueError(f"引擎连通测试失败: {exc}") from exc + finally: + try: + await self._log_ai_call( + tenant_id=tenant_id, + function_name="test_engine_connection", + prompt=test_prompt, + response=response_text, + status=status, + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + ) + except Exception as log_exc: + _runtime_log("LLM", f"测试调用日志写入失败:{log_exc}") + + # --------------------------------------------------------------------- + # LLM calls + # --------------------------------------------------------------------- + async def generate(self, prompt: str, **kwargs: Any) -> str: + call_kwargs = dict(kwargs) + db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) + if db is None: + async with AsyncSessionLocal() as session: + return await self._generate_with_db(session, prompt, **call_kwargs) + return await self._generate_with_db(db, prompt, **call_kwargs) + + async def generate_structured_object(self, prompt: str, **kwargs: Any) -> StructuredExtractionResult: + call_kwargs = dict(kwargs) + db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) + if db is None: + async with AsyncSessionLocal() as session: + return await self._generate_structured_with_db(session, prompt, **call_kwargs) + return await self._generate_structured_with_db(db, prompt, **call_kwargs) + + async def _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str: + tenant_id = int(kwargs.get("tenant_id") or 1) + function_name = str(kwargs.get("function_name") or "llm_generate") + temperature = float(kwargs.get("temperature") or 0.2) + max_tokens = int(kwargs.get("max_tokens") or 1024) + use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) + response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + response_text = "" + status = "success" + routing_decision = "primary_engine" + engine_meta: dict[str, Any] = {} + prompt_profile = {"role_name": "", "system_prompt": ""} + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + safety_attempted = False + try: + if use_prompt_profile: + prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + engine = await self._resolve_engine_for_call( + db, + tenant_id=tenant_id, + function_name=function_name, + ) + engine_meta = engine + provider = str(engine.get("provider") or "") + model = str(engine.get("model") or "") + response_text = await self._chat_completion( + provider=provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if not response_text.strip(): + raise ValueError("primary_engine_empty_response") + + invalid_structured_output = self._is_invalid_structured_output( + function_name=function_name, + response_text=response_text, + ) + should_upgrade_to_high = False + if not self._prefer_fast_local_fallback(function_name): + should_upgrade_to_high = self._should_escalate_low_to_high( + function_name=function_name, + response_text=response_text, + ) or (invalid_structured_output and self._is_low_tier_function(function_name)) + + if should_upgrade_to_high: + high_engine = await self._resolve_high_tier_engine_for_escalation( + db, + tenant_id=tenant_id, + primary_engine=engine, + ) + if high_engine is None: + raise ValueError("high_tier_engine_unavailable") + high_provider = str(high_engine.get("provider") or "") + high_model = str(high_engine.get("model") or "") + _runtime_log( + "LLM升级", + ( + "低阶引擎触发升级:" + f"function={function_name}, from={provider}/{model}, to={high_provider}/{high_model}" + ), + ) + try: + escalated_text = await self._chat_completion( + provider=high_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(high_engine.get("api_key") or ""), + base_url=str(high_engine.get("base_url") or ""), + model=high_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if not escalated_text.strip(): + raise ValueError("high_tier_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): + raise ValueError("high_tier_invalid_structured_output") + response_text = escalated_text + engine_meta = high_engine + except Exception as escalation_exc: + safety_attempted = True + safety_text, safety_engine = await self._try_safety_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + temperature=temperature, + max_tokens=max_tokens, + primary_engine=high_engine, + reason=f"high_tier_retry_failed:{escalation_exc}", + ) + if safety_engine is not None and safety_text.strip(): + routing_decision = "fallback_safety_engine" + status = "fallback_safety_engine" + engine_meta = safety_engine + response_text = safety_text + return response_text + raise ValueError(f"high_tier_retry_failed:{escalation_exc}") from escalation_exc + + if self._is_invalid_structured_output(function_name=function_name, response_text=response_text): + raise ValueError("invalid_structured_output") + return response_text + except Exception as exc: + upgraded_exc: Exception = exc + if self._is_fail_fast_exception(exc): + status = "failed_fast" + response_text = self._stub_response(prompt) + _runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}") + return response_text + if self._is_low_tier_function(function_name): + if self._prefer_fast_local_fallback(function_name): + raise upgraded_exc + high_engine = await self._resolve_high_tier_engine_for_escalation( + db, + tenant_id=tenant_id, + primary_engine=engine_meta, + ) + if high_engine is not None: + high_provider = str(high_engine.get("provider") or "") + high_model = str(high_engine.get("model") or "") + _runtime_log( + "LLM升级", + ( + "低阶引擎异常,转高阶兜底:" + f"function={function_name}, from={engine_meta.get('provider')}/{engine_meta.get('model')}, " + f"to={high_provider}/{high_model}, error={exc}" + ), + ) + try: + escalated_text = await self._chat_completion( + provider=high_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(high_engine.get("api_key") or ""), + base_url=str(high_engine.get("base_url") or ""), + model=high_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if not escalated_text.strip(): + raise ValueError("high_tier_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): + raise ValueError("high_tier_invalid_structured_output") + routing_decision = "fallback_high_tier_engine" + status = "fallback_high_tier_engine" + engine_meta = high_engine + response_text = escalated_text + return response_text + except Exception as high_exc: + upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}") + engine_meta = high_engine + + if not safety_attempted: + safety_attempted = True + safety_text, safety_engine = await self._try_safety_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + temperature=temperature, + max_tokens=max_tokens, + primary_engine=engine_meta, + reason=str(upgraded_exc), + ) + if safety_engine is not None and safety_text.strip(): + routing_decision = "fallback_safety_engine" + status = "fallback_safety_engine" + engine_meta = safety_engine + response_text = safety_text + return response_text + + retry_text = "" + if self._direct_emergency_enabled(): + fallback_engines = self._build_fallback_engines(engine_meta) + for fallback_engine in fallback_engines: + try: + fb_provider = str(fallback_engine.get("provider") or "") + fb_model = str(fallback_engine.get("model") or "") + _runtime_log( + "LLM", + f"应急直连引擎重试:provider={fb_provider}, model={fb_model}, error={exc}", + ) + retry_text = await self._chat_completion( + provider=fb_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(fallback_engine.get("api_key") or ""), + base_url=str(fallback_engine.get("base_url") or ""), + model=fb_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if retry_text.strip(): + routing_decision = "fallback_emergency_direct_engine" + status = "fallback_emergency_direct_engine" + engine_meta = fallback_engine + response_text = retry_text + return response_text + except Exception as retry_exc: + _runtime_log( + "LLM", + ( + "应急直连重试失败:" + f"provider={fallback_engine.get('provider')}, " + f"model={fallback_engine.get('model')}, error={retry_exc}" + ), + ) + + status = "failed" + response_text = self._stub_response(prompt) + _runtime_log("LLM", f"生成失败,已返回兜底 stub:{exc}") + return response_text + finally: + try: + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + await self._log_ai_call( + tenant_id=tenant_id, + function_name=function_name, + prompt=merged_prompt, + response=response_text, + status=status, + provider=str(engine_meta.get("provider") or ""), + model=str(engine_meta.get("model") or ""), + ) + if routing_decision != "primary_engine": + _runtime_log( + "LLM路由", + ( + f"routing_decision={routing_decision}, " + f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}" + ), + ) + except Exception as log_exc: + _runtime_log("LLM", f"调用日志写入失败:{log_exc}") + + async def _generate_structured_with_db( + self, + db: AsyncSession, + prompt: str, + **kwargs: Any, + ) -> StructuredExtractionResult: + tenant_id = int(kwargs.get("tenant_id") or 1) + function_name = str(kwargs.get("function_name") or "llm_generate").strip().lower() or "llm_generate" + temperature = float(kwargs.get("temperature") or 0.0) + max_tokens = int(kwargs.get("max_tokens") or 1024) + use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) + response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + prompt_profile = {"role_name": "", "system_prompt": ""} + merged_prompt = prompt + provider = "" + model = "" + status = "success" + response_text = "" + try: + if use_prompt_profile: + prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + engine = await self._resolve_engine_for_call( + db, + tenant_id=tenant_id, + function_name=function_name, + ) + provider = str(engine.get("provider") or "") + model = str(engine.get("model") or "") + cache_key = "" + if self._structured_cache_enabled(function_name): + cache_key = self._structured_cache_key( + function_name=function_name, + model=model or "unknown", + prompt=merged_prompt, + ) + cached = await self._structured_cache_get(key=cache_key) + if cached is not None: + status = "success_cache_hit" + result = StructuredExtractionResult( + payload=cached, + source_field="cache", + provider=provider, + model=model, + raw_text=json.dumps(cached, ensure_ascii=False), + validation="ok", + from_cache=True, + ) + _runtime_log( + "LLM结构化", + f"function={function_name}, structured_source=cache, validation=ok, fallback=false", + ) + return result + + completion = await self._chat_completion_raw( + provider=provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + response_text = completion.text + extraction = self._extract_structured_object_from_response( + function_name=function_name, + response=completion.raw_response, + fallback_text=completion.text, + provider=provider, + model=model, + ) + is_fallback = extraction.error_code is not None + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={extraction.source_field or 'none'}, " + f"validation={extraction.validation}, fallback={'true' if is_fallback else 'false'}" + ), + ) + if extraction.payload is not None and cache_key: + await self._structured_cache_set(key=cache_key, value=extraction.payload) + status = extraction.error_code or "success" + return extraction + except Exception as exc: + error_code = self._classify_structured_exception(exc) + status = error_code + result = StructuredExtractionResult( + payload=None, + source_field="", + provider=provider, + model=model, + error_code=error_code, + raw_text=str(exc)[:1000], + validation="invalid", + ) + _runtime_log( + "LLM结构化", + f"function={function_name}, structured_source=none, validation=invalid, fallback=true", + ) + return result + finally: + try: + log_payload = response_text or status + await self._log_ai_call( + tenant_id=tenant_id, + function_name=function_name, + prompt=merged_prompt, + response=log_payload, + status=status, + provider=provider, + model=model, + ) + except Exception as log_exc: + _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") + + async def _chat_completion( + self, + *, + provider: str, + prompt: str, + system_prompt: str, + api_key: str, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + function_name: str = "", + response_mode: str = "complete", + ) -> str: + result = await self._chat_completion_raw( + provider=provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=api_key, + base_url=base_url, + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + return result.text + + async def _chat_completion_raw( + self, + *, + provider: str, + prompt: str, + system_prompt: str, + api_key: str, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + function_name: str = "", + response_mode: str = "complete", + ) -> ChatCompletionResult: + provider_name = _normalize_provider(provider) + structured_output = self._structured_output_required(function_name) + request_timeout = self._request_timeout_for_function(function_name, response_mode=response_mode) + is_openclaw_gateway_request = self._is_openclaw_gateway_request( + provider_name=provider_name, + base_url=base_url, + ) + openclaw_agents_timeout_seconds = self._openclaw_agents_timeout_seconds() + if is_openclaw_gateway_request: + # 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。 + request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0) + use_provider_circuit_breaker = self._use_provider_circuit_breaker(function_name) + circuit_key = self._provider_circuit_key( + provider=provider_name, + base_url=base_url, + model=model, + ) + if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key): + raise ValueError(f"provider_circuit_open:{provider_name}:{model}") + key = api_key.strip() + messages: list[dict[str, str]] = [] + cleaned_system_prompt = str(system_prompt or "").strip() + if cleaned_system_prompt: + messages.append({"role": "system", "content": cleaned_system_prompt}) + messages.append({"role": "user", "content": prompt}) + + try: + if provider_name == "ollama": + endpoint = f"{base_url.rstrip('/')}/api/chat" + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, json=payload) + resp.raise_for_status() + data = resp.json() + message = data.get("message") if isinstance(data, dict) else {} + content = message.get("content") if isinstance(message, dict) else None + text = self._sanitize_visible_answer(self._extract_text_from_value(content)) + if not text: + text = self._sanitize_visible_answer(self._extract_text_from_value(data.get("response"))) + if not text and not isinstance(data, dict): + raise ValueError("Ollama 返回空内容") + if use_provider_circuit_breaker: + self._record_provider_success(circuit_key) + return ChatCompletionResult(text=text, raw_response=data) + + if not key: + raise ValueError("未配置 LLM API Key") + endpoint = f"{base_url.rstrip('/')}/chat/completions" + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + if is_openclaw_gateway_request: + payload["agents"] = { + "defaults": { + "timeoutSeconds": int(openclaw_agents_timeout_seconds), + } + } + headers = { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + choices = data.get("choices") or [] + if not choices: + raise ValueError("LLM 响应无 choices") + message = choices[0].get("message") if isinstance(choices[0], dict) else {} + text = self._extract_text_from_chat_response( + message=message, + choice=choices[0] if isinstance(choices[0], dict) else {}, + response=data, + structured_output=structured_output, + ) + if not text and not structured_output: + msg_keys = list(message.keys()) if isinstance(message, dict) else [] + choice_keys = list(choices[0].keys()) if isinstance(choices[0], dict) else [] + choice_preview = "" + try: + choice_preview = json.dumps(choices[0], ensure_ascii=False)[:300] + except Exception: + choice_preview = str(choices[0])[:300] + raise ValueError( + "LLM 返回空内容: " + f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" + ) + if use_provider_circuit_breaker: + self._record_provider_success(circuit_key) + return ChatCompletionResult(text=text, raw_response=data) + except Exception as exc: + if use_provider_circuit_breaker: + self._record_provider_failure(circuit_key, exc) + raise + + async def generate_task_breakdown_async(self, description: str, **kwargs: Any) -> list[dict]: + if not description.strip(): + return [] + prompt = ( + "请将以下需求拆解为 5-8 条执行任务,返回 JSON 数组," + "每项格式: {\"task_name\":\"\",\"type\":\"backend|frontend|database|ai|devops\"," + "\"estimated_hours\":4,\"priority\":\"high|medium|low\"}。\n" + f"需求:{description[:1200]}" + ) + text = await self.generate(prompt, function_name="generate_task_breakdown", **kwargs) + rows = self._parse_json_array(text) + if rows: + return rows + return [ + { + "task_name": "需求分析", + "type": "ai", + "priority": "medium", + "estimated_hours": 4, + "description": description[:200], + } + ] + + async def get_embedding_async(self, text: str, **kwargs: Any) -> list[float]: + db = kwargs.get("db") or kwargs.get("db_session") + tenant_id = int(kwargs.get("tenant_id") or 1) + if db is None: + async with AsyncSessionLocal() as session: + return await self.get_embedding_async(text, db=session, tenant_id=tenant_id) + try: + engine = await self._resolve_active_engine(db, tenant_id=tenant_id) + provider = str(engine.get("provider") or "") + if provider == "openai": + endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/embeddings" + headers = { + "Authorization": f"Bearer {str(engine.get('api_key') or '').strip()}", + "Content-Type": "application/json", + } + payload = { + "model": str(engine.get("model") or "text-embedding-3-small"), + "input": text, + } + async with httpx.AsyncClient(timeout=self.request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + return list(data.get("data", [{}])[0].get("embedding") or []) + except Exception: + pass + # DeepSeek/GLM 及失败场景:回退伪向量,保持功能可用。 + seed = hashlib.md5(text.encode("utf-8")).digest() + return [((seed[i % 16] / 255.0) * 2 - 1) for i in range(64)] + + async def extract_context_insights_async(self, text: str, **kwargs: Any) -> dict: + prompt = ( + "请提炼以下技术文档上下文的关键结论,返回 JSON:" + "{\"summary\":\"\",\"keywords\":[\"\",\"\"],\"risks\":[\"\",\"\"]}\n" + f"上下文:{text[:2000]}" + ) + raw = await self.generate(prompt, function_name="extract_context_insights", **kwargs) + parsed = self._parse_json_object(raw) + if parsed: + return parsed + return {"summary": text[:120], "keywords": []} + + async def find_relevant_context(self, **_: Any) -> list[dict]: + return [] + + async def evaluate_with_voting_async(self, signal_content: str, impact_score: float, **_: Any) -> dict: + is_strategic = impact_score >= 0.8 + return { + "is_strategic": is_strategic, + "confidence": min(1.0, max(0.2, impact_score)), + "decision_proposal": { + "summary": signal_content[:120], + "action": "follow_up" if is_strategic else "observe", + }, + "tasks": [], + "_voting": { + "enabled": False, + "models_used": [], + "strategic_votes": 0, + "total_votes": 0, + "unanimous": True, + }, + } + + async def audit_code_change_async( + self, + task_name: str, + commit_message: str, + code_diff: str, + **_: Any, + ) -> dict: + passed = bool(task_name and commit_message and code_diff is not None) + return { + "passed": passed, + "reason": "basic_stub_check_passed" if passed else "invalid_input", + } + + # --------------------------------------------------------------------- + # internals + # --------------------------------------------------------------------- + def _stub_response(self, prompt: str) -> str: + _ = prompt # 保留签名,避免调用方改动 + return ( + "我暂时无法生成可靠回答(模型服务异常)。" + "为避免误导,本次不直接给结论;请稍后重试," + "或补充更具体的目标对象后继续提问。" + ) + + def _sanitize_engine_for_response(self, item: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(item.get("id") or ""), + "name": str(item.get("name") or ""), + "provider": str(item.get("provider") or ""), + "model": str(item.get("model") or ""), + "base_url": str(item.get("base_url") or ""), + "api_key_masked": _mask_secret(str(item.get("api_key") or "")), + "updated_at": item.get("updated_at"), + "created_at": item.get("created_at"), + } + + async def _get_or_init_bundle( + self, + db: AsyncSession, + *, + tenant_id: int, + ) -> tuple[Integration, dict[str, Any]]: + result = await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "llm_engine", + ) + ) + ) + integration = result.scalar_one_or_none() + if not integration: + integration = Integration( + type="llm_engine", + config={"active_engine_id": None, "engines": []}, + is_active=True, + tenant_id=tenant_id, + ) + db.add(integration) + await db.flush() + bundle = _safe_json(integration.config) + engines = bundle.get("engines") + if not isinstance(engines, list): + engines = [] + bundle["engines"] = engines + if "active_engine_id" not in bundle: + bundle["active_engine_id"] = None + return integration, bundle + + async def _resolve_active_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + ) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + return self._resolve_active_engine_from_bundle(bundle) + + async def _resolve_engine_for_call( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + ) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + active_engine = self._resolve_active_engine_from_bundle(bundle) + active_engine = self._ensure_openclaw_control_plane_engine(active_engine) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + + openclaw_routed = self._resolve_openclaw_engine( + function_name=function_name, + engines=engines, + active_engine=active_engine, + ) + if openclaw_routed is not None: + openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed) + provider = str(openclaw_routed.get("provider") or "") + api_key = str(openclaw_routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + _runtime_log( + "OpenClaw路由", + ( + "命中 OpenClaw 路由但目标引擎缺少 API Key,已回退 active:" + f"function={function_name}, provider={provider}, model={openclaw_routed.get('model')}" + ), + ) + else: + return openclaw_routed + + if not self._mixed_routing_enabled(): + return active_engine + + tier = self._resolve_function_tier(function_name) + if tier not in {"high", "low"}: + return active_engine + + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + if not routed: + return active_engine + + provider = str(routed.get("provider") or "") + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + _runtime_log( + "LLM路由", + ( + "命中分层路由但目标引擎缺少 API Key,回退可用引擎:" + f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + ), + ) + fallback_engine = self._pick_tier_fallback_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + if fallback_engine: + return fallback_engine + return active_engine + _runtime_log( + "LLM路由", + ( + "函数路由生效:" + f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + ), + ) + return self._ensure_openclaw_control_plane_engine(routed) + + def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]: + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + active_engine_id = str(bundle.get("active_engine_id") or "") + if active_engine_id: + for engine in engines: + if str(engine.get("id") or "") == active_engine_id: + return self._normalize_engine_runtime(engine) + if engines: + return self._normalize_engine_runtime(engines[0]) + return self._env_engine_runtime() + + def _mixed_routing_enabled(self) -> bool: + return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) + + def _openclaw_routing_enabled(self) -> bool: + return _env_bool("OPENCLAW_ROUTING_ENABLED", True) + + def _openclaw_control_plane_only(self) -> bool: + return self._openclaw_routing_enabled() and not self._direct_emergency_enabled() + + def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: + runtime = dict(engine or {}) + if not self._openclaw_control_plane_only(): + return runtime + + openai_default = self._env_provider_runtime("openai") + runtime["provider"] = "openai" + runtime["base_url"] = ( + str(os.getenv("OPENAI_BASE_URL", openai_default.get("base_url") or OPENCLAW_PRIMARY_GATEWAY_URL) or "") + .strip() + .rstrip("/") + or OPENCLAW_PRIMARY_GATEWAY_URL + ) + runtime["api_key"] = ( + str(os.getenv("OPENAI_API_KEY", "") or "").strip() + or str(runtime.get("api_key") or "").strip() + ) + runtime["model"] = ( + str(runtime.get("model") or "").strip() + or str(openai_default.get("model") or OPENCLAW_DEFAULT_LOW_MODEL).strip() + ) + runtime["name"] = str(runtime.get("name") or "OpenClaw Gateway").strip() or "OpenClaw Gateway" + runtime["id"] = str(runtime.get("id") or "openclaw-gateway") + return runtime + + def _openclaw_rules_path(self) -> str: + custom = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() + if custom: + return custom + if OPENCLAW_BACKEND_RULES_PATH.exists(): + return str(OPENCLAW_BACKEND_RULES_PATH) + if OPENCLAW_PROJECT_RULES_PATH.exists(): + return str(OPENCLAW_PROJECT_RULES_PATH) + return str(OPENCLAW_BACKEND_RULES_PATH) + + def _load_openclaw_routing_bundle(self) -> dict[str, Any]: + now = time.time() + if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now: + return dict(self._openclaw_routing_cache[1]) + + path = Path(self._openclaw_rules_path()) + bundle: dict[str, Any] = {} + if path.exists(): + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + if isinstance(parsed, dict): + bundle = parsed + except Exception as exc: + _runtime_log("OpenClaw路由", f"规则文件解析失败:path={path} error={exc}") + bundle = {} + self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) + return dict(bundle) + + @staticmethod + def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: + if bool(rule.get("match_all", False)): + return True + fn = str(function_name or "").strip().lower() + if not fn: + return False + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix == "*" or prefix == "all": + return True + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + def _match_openclaw_rule( + self, + function_name: str, + *, + tier_filter: Optional[set[str]] = None, + ) -> Optional[dict[str, Any]]: + if not self._openclaw_routing_enabled(): + return None + bundle = self._load_openclaw_routing_bundle() + rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else [] + if not rules: + return None + + indexed: list[tuple[int, int, dict[str, Any]]] = [] + for idx, row in enumerate(rules): + if not isinstance(row, dict): + continue + if not bool(row.get("enabled", True)): + continue + if tier_filter: + tier_name = str(row.get("tier") or "").strip().lower() + if tier_name not in tier_filter: + continue + if not self._rule_matches_function(row, function_name): + continue + priority = int(row.get("priority") or 0) + indexed.append((priority, -idx, row)) + + if not indexed: + return None + indexed.sort(reverse=True) + return dict(indexed[0][2]) + + def _resolve_openclaw_engine( + self, + *, + function_name: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + rule = self._match_openclaw_rule(function_name, tier_filter={"low", "high"}) + if not rule: + return None + + routed = self._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + if routed is None: + return None + + _runtime_log( + "OpenClaw路由", + ( + "规则命中:" + f"function={function_name}, provider={routed.get('provider')}, " + f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}" + ), + ) + return routed + + def _resolve_engine_from_rule( + self, + *, + rule: dict[str, Any], + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + tier = str(rule.get("tier") or "").strip().lower() + provider_raw = str(rule.get("provider") or "").strip().lower() + engine_id = str(rule.get("engine_id") or "").strip() + model_override = str(rule.get("model") or "").strip() + base_url_override = str(rule.get("base_url") or "").strip().rstrip("/") + + routed: Optional[dict[str, Any]] = None + if engine_id: + for item in engines: + if str(item.get("id") or "").strip() == engine_id: + routed = self._normalize_engine_runtime(item) + break + + if routed is None and provider_raw: + try: + provider = _normalize_provider(provider_raw) + except Exception: + provider = "" + if provider: + for item in engines: + if str(item.get("provider") or "").strip().lower() == provider: + routed = self._normalize_engine_runtime(item) + break + if routed is None: + try: + routed = self._env_provider_runtime(provider) + except Exception: + routed = None + + if routed is None and tier in {"high", "low", "safety"}: + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + + if routed is None: + return None + + if model_override: + routed["model"] = model_override + if base_url_override: + routed["base_url"] = base_url_override + return routed + + def _resolve_function_tier(self, function_name: str) -> str: + fn = str(function_name or "").strip().lower() + if not fn: + return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() + + high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", "")) + low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", "")) + high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS + low_pool = low_hints or DEFAULT_LOW_TIER_HINTS + + if any(hint and hint in fn for hint in high_pool): + return "high" + if any(hint and hint in fn for hint in low_pool): + return "low" + default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() + if default_tier != "active": + return default_tier + # mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。 + return "high" if self._mixed_routing_enabled() else "active" + + def _low_to_high_escalation_enabled(self) -> bool: + return _env_bool("LLM_LOW_TO_HIGH_ESCALATION_ENABLED", True) + + def _is_low_tier_function(self, function_name: str) -> bool: + rule = self._match_openclaw_rule(function_name) + if rule is not None: + tier = str(rule.get("tier") or "").strip().lower() + if tier: + return tier == "low" + return self._resolve_function_tier(function_name) == "low" + + @staticmethod + def _looks_like_unanswerable_response(text: str) -> bool: + cleaned = str(text or "").strip() + lowered = cleaned.lower() + if not lowered: + return True + if len(cleaned) <= 8: + return True + return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS) + + def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool: + if not self._low_to_high_escalation_enabled(): + return False + if not self._is_low_tier_function(function_name): + return False + return self._looks_like_unanswerable_response(response_text) + + @staticmethod + def _merge_prompt_with_profile(*, prompt: str, prompt_profile: dict[str, str]) -> str: + system_prompt = str(prompt_profile.get("system_prompt") or "").strip() + if not system_prompt: + return prompt + role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown" + return f"[system_role:{role_name}]\n{system_prompt}\n\n{prompt}" + + def _direct_emergency_enabled(self) -> bool: + return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False) + + @staticmethod + def _prefer_fast_local_fallback(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS) + + @staticmethod + def _is_fast_local_planner_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS) + + @staticmethod + def _is_fast_local_worker_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS) + + @staticmethod + def _structured_cache_enabled(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return fn in { + "signal_source_onboarding_plan", + "openclaw_command_dispatch", + } + + def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float: + if not self._prefer_fast_local_fallback(function_name): + return self.request_timeout + env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE" + default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS + if self._is_fast_local_planner_function(function_name) and str(response_mode or "").strip().lower() == "fast": + env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST" + default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS + try: + configured = float(os.getenv(env_key, str(default_timeout))) + except Exception: + configured = default_timeout + if configured <= 0: + return self.request_timeout + return min(self.request_timeout, configured) + + @staticmethod + def _is_openclaw_gateway_request(*, provider_name: str, base_url: str) -> bool: + if str(provider_name or "").strip().lower() != "openai": + return False + normalized_base_url = str(base_url or "").strip().lower() + if not normalized_base_url: + return False + return "openclaw" in normalized_base_url + + @staticmethod + def _openclaw_agents_timeout_seconds() -> int: + raw = os.getenv( + "OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS", + str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS), + ) + try: + configured = int(str(raw).strip()) + except Exception: + configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + if configured <= 0: + configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) + + def _use_provider_circuit_breaker(self, function_name: str) -> bool: + return not self._prefer_fast_local_fallback(function_name) + + def _structured_output_required(self, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", "")) + hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS + return any(hint and hint in fn for hint in hint_pool) + + @staticmethod + def _extract_json_candidate(text: str) -> str: + raw = str(text or "").strip() + if not raw: + return "" + if raw.startswith("```"): + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + if raw.startswith("{") or raw.startswith("["): + return raw + + object_candidate = LLMService._extract_balanced_json_segment(raw, "{", "}") + array_candidate = LLMService._extract_balanced_json_segment(raw, "[", "]") + if object_candidate and array_candidate: + return object_candidate if raw.find("{") <= raw.find("[") else array_candidate + if object_candidate: + return object_candidate + if array_candidate: + return array_candidate + return raw + + @staticmethod + def _looks_like_json_container(text: str) -> bool: + raw = str(text or "").strip() + if not raw: + return False + if raw.startswith("{") or raw.startswith("["): + return True + return "{" in raw or "[" in raw + + @staticmethod + def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str: + raw = str(text or "") + start = raw.find(open_char) + if start < 0: + return "" + depth = 0 + in_str = False + escaped = False + for idx in range(start, len(raw)): + ch = raw[idx] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + if depth == 0: + return raw[start : idx + 1].strip() + return "" + + def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool: + if not self._structured_output_required(function_name): + return False + candidate = self._extract_json_candidate(response_text) + if not candidate: + return True + try: + parsed = json.loads(candidate) + except Exception: + return True + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is not None: + return True + return not isinstance(parsed, (dict, list)) + + @staticmethod + def _strip_code_fence(text: str) -> str: + raw = str(text or "").strip() + if not raw.startswith("```"): + return raw + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.lower().startswith("json"): + raw = raw[4:].strip() + return raw.strip() + + def _extract_text_from_chat_response( + self, + *, + message: Any, + choice: Any, + response: Any, + structured_output: bool, + ) -> str: + if structured_output: + for _, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) + normalized = self._strip_code_fence(text) + if not self._looks_like_json_container(normalized): + continue + candidate = self._extract_json_candidate(normalized) + if candidate: + return candidate + return "" + if isinstance(message, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(message.get("content"))) + if text: + return text + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + fn = first.get("function") if isinstance(first, dict) else {} + if isinstance(fn, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(fn.get("arguments"))) + if text: + return text + if isinstance(choice, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(choice.get("text"))) + if text: + return text + if isinstance(response, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(response.get("output_text"))) + if text: + return text + return "" + + def _iter_structured_candidates( + self, + *, + message: Any, + choice: Any, + response: Any, + ) -> list[tuple[str, Any]]: + candidates: list[tuple[str, Any]] = [] + if isinstance(message, dict): + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + fn = first.get("function") if isinstance(first, dict) else {} + candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None)) + candidates.append(("message.content", message.get("content"))) + if isinstance(choice, dict): + candidates.append(("choice.text", choice.get("text"))) + if isinstance(response, dict): + candidates.append(("response.output_text", response.get("output_text"))) + if isinstance(message, dict): + candidates.append(("message.reasoning_content", message.get("reasoning_content"))) + candidates.append(("message.reasoning_content", message.get("reasoning"))) + if isinstance(choice, dict): + candidates.append(("choice.reasoning_content", choice.get("reasoning_content"))) + candidates.append(("choice.reasoning_content", choice.get("reasoning"))) + if isinstance(response, dict): + candidates.append(("response.reasoning_content", response.get("reasoning_content"))) + candidates.append(("response.reasoning_content", response.get("reasoning"))) + return candidates + + @classmethod + def _unwrap_structured_payload_once(cls, payload: Any) -> Any: + if not isinstance(payload, dict): + return payload + for key in ("json", "data", "result", "arguments"): + if key not in payload: + continue + inner = payload.get(key) + if isinstance(inner, str): + candidate = cls._extract_json_candidate(cls._strip_code_fence(inner)) + if not candidate: + continue + try: + return json.loads(candidate) + except Exception: + return inner + if isinstance(inner, (dict, list)): + return inner + return payload + + @staticmethod + def _matches_expected_type(value: Any, expected: str) -> bool: + if expected == "bool": + return isinstance(value, bool) + if expected == "str": + return isinstance(value, str) + if expected == "list": + return isinstance(value, list) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "dict_or_none": + return value is None or isinstance(value, dict) + return True + + def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]: + fn = str(function_name or "").strip().lower() + schema = STRUCTURED_OBJECT_SCHEMAS.get(fn) + if schema is None: + return None if isinstance(payload, (dict, list)) else "structured_json_invalid" + if not isinstance(payload, dict): + return "structured_schema_invalid" + required_keys = schema.get("required_keys") or () + for key in required_keys: + if key not in payload: + return "structured_schema_invalid" + type_checks = schema.get("type_checks") or {} + for key, expected in type_checks.items(): + if not self._matches_expected_type(payload.get(key), str(expected)): + return "structured_schema_invalid" + optional_type_checks = schema.get("optional_type_checks") or {} + for key, expected_values in optional_type_checks.items(): + if key not in payload: + continue + if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values): + return "structured_schema_invalid" + return None + + def _extract_structured_object_from_response( + self, + *, + function_name: str, + response: Any, + fallback_text: str, + provider: str, + model: str, + ) -> StructuredExtractionResult: + message = {} + choice: dict[str, Any] = {} + if isinstance(response, dict): + choices = response.get("choices") if isinstance(response.get("choices"), list) else [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + message = choice.get("message") if isinstance(choice.get("message"), dict) else {} + + last_nonempty_field = "" + last_nonempty_text = "" + for field, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) + normalized = self._strip_code_fence(text) + if normalized: + last_nonempty_field = field + last_nonempty_text = normalized[:4000] + if not self._looks_like_json_container(normalized): + continue + candidate = self._extract_json_candidate(normalized) + if not candidate: + continue + try: + parsed = json.loads(candidate) + except Exception: + return StructuredExtractionResult( + payload=None, + source_field=field, + provider=provider, + model=model, + error_code="structured_json_invalid", + raw_text=candidate[:4000], + validation="invalid", + ) + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is not None: + return StructuredExtractionResult( + payload=None, + source_field=field, + provider=provider, + model=model, + error_code=validation_error, + raw_text=candidate[:4000], + validation="invalid", + ) + return StructuredExtractionResult( + payload=parsed if isinstance(parsed, dict) else None, + source_field=field, + provider=provider, + model=model, + raw_text=candidate[:4000], + validation="ok", + ) + + fallback_normalized = self._strip_code_fence(fallback_text) + fallback_candidate = ( + self._extract_json_candidate(fallback_normalized) + if self._looks_like_json_container(fallback_normalized) + else "" + ) + if fallback_candidate: + try: + parsed = json.loads(fallback_candidate) + except Exception: + return StructuredExtractionResult( + payload=None, + source_field="generated_text", + provider=provider, + model=model, + error_code="structured_json_invalid", + raw_text=fallback_candidate[:4000], + validation="invalid", + ) + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is None and isinstance(parsed, dict): + return StructuredExtractionResult( + payload=parsed, + source_field="generated_text", + provider=provider, + model=model, + raw_text=fallback_candidate[:4000], + validation="ok", + ) + + if not last_nonempty_field and str(fallback_text or "").strip(): + last_nonempty_field = "generated_text" + last_nonempty_text = str(fallback_text or "").strip()[:4000] + error_code = "structured_json_not_found" if last_nonempty_field else "structured_empty" + return StructuredExtractionResult( + payload=None, + source_field=last_nonempty_field, + provider=provider, + model=model, + error_code=error_code, + raw_text=last_nonempty_text, + validation="invalid", + ) + + @staticmethod + def _classify_structured_exception(exc: Exception) -> str: + if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)): + return "structured_timeout" + if isinstance(exc, (httpx.HTTPError, httpx.RequestError)): + return "structured_http_error" + lowered = str(exc or "").strip().lower() + if "timeout" in lowered: + return "structured_timeout" + if "status" in lowered or "http" in lowered: + return "structured_http_error" + return "structured_empty" + + @staticmethod + def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool: + return ( + str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() + and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() + and str(a.get("base_url") or "").strip().rstrip("/").lower() + == str(b.get("base_url") or "").strip().rstrip("/").lower() + ) + + async def _resolve_high_tier_engine_for_escalation( + self, + db: AsyncSession, + *, + tenant_id: int, + primary_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + active_engine = self._resolve_active_engine_from_bundle(bundle) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + routed = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) + if not routed: + return None + if self._is_same_runtime_engine(routed, primary_engine): + return None + + provider = str(routed.get("provider") or "").strip().lower() + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + return None + return routed + + async def _resolve_safety_tier_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + primary_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + active_engine = self._resolve_active_engine_from_bundle(bundle) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + + safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"}) + routed: Optional[dict[str, Any]] = None + if safety_rule is not None: + routed = self._resolve_engine_from_rule( + rule=safety_rule, + engines=engines, + active_engine=active_engine, + ) + if routed is None: + routed = self._resolve_tier_engine( + tier="safety", + engines=engines, + active_engine=active_engine, + ) + if not routed: + return None + routed = self._ensure_openclaw_control_plane_engine(routed) + if self._is_same_runtime_engine(routed, primary_engine): + return None + + provider = str(routed.get("provider") or "").strip().lower() + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + return None + return routed + + async def _try_safety_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + prompt: str, + system_prompt: str, + temperature: float, + max_tokens: int, + primary_engine: dict[str, Any], + reason: str, + ) -> tuple[str, Optional[dict[str, Any]]]: + safety_engine = await self._resolve_safety_tier_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + primary_engine=primary_engine, + ) + if safety_engine is None: + _runtime_log( + "LLM路由", + f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}", + ) + return "", None + safety_provider = str(safety_engine.get("provider") or "") + safety_model = str(safety_engine.get("model") or "") + _runtime_log( + "LLM路由", + ( + "触发 Safety 兜底:" + f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}" + ), + ) + try: + text = await self._chat_completion( + provider=safety_provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=str(safety_engine.get("api_key") or ""), + base_url=str(safety_engine.get("base_url") or ""), + model=safety_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + ) + if not text.strip(): + raise ValueError("safety_engine_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=text): + raise ValueError("safety_engine_invalid_structured_output") + return text, safety_engine + except Exception as exc: + _runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}") + return "", None + + def _pick_tier_fallback_engine( + self, + *, + tier: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + for item in engines: + candidate = self._normalize_engine_runtime(item) + provider = str(candidate.get("provider") or "") + api_key = str(candidate.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + continue + if tier == "high" and provider == "ollama": + continue + if str(candidate.get("id") or "") == str(active_engine.get("id") or ""): + continue + _runtime_log( + "LLM路由", + ( + "分层路由回退引擎生效:" + f"tier={tier}, provider={provider}, model={candidate.get('model')}" + ), + ) + return candidate + return None + + def _resolve_tier_engine( + self, + *, + tier: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + tier = str(tier or "").strip().lower() + upper = tier.upper() + default_provider_by_tier = { + "low": "openai", + "high": "openai", + "safety": "openai", + } + provider_env = str( + os.getenv( + f"LLM_ROUTING_{upper}_PROVIDER", + default_provider_by_tier.get( + tier, + os.getenv("LLM_PROVIDER", "openai"), + ), + ) + or "" + ).strip() + engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip() + model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip() + base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/") + + routed: Optional[dict[str, Any]] = None + if engine_id: + for item in engines: + if str(item.get("id") or "") == engine_id: + routed = self._normalize_engine_runtime(item) + break + + if routed is None and provider_env: + try: + provider = _normalize_provider(provider_env) + except Exception: + provider = "" + if provider: + for item in engines: + if str(item.get("provider") or "").strip().lower() == provider: + routed = self._normalize_engine_runtime(item) + break + if routed is None: + try: + routed = self._env_provider_runtime(provider) + except Exception: + routed = None + + if routed is None: + return None + + provider = str(routed.get("provider") or "").strip().lower() + if model_override: + routed["model"] = model_override + elif self._openclaw_routing_enabled() and provider == "openai": + default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier) + if default_model: + routed["model"] = default_model + if base_url_override: + routed["base_url"] = base_url_override + elif self._openclaw_routing_enabled() and provider == "openai": + routed["base_url"] = OPENCLAW_PRIMARY_GATEWAY_URL + + if str(routed.get("id") or "") != str(active_engine.get("id") or ""): + _runtime_log( + "LLM路由", + ( + "混合路由已命中:" + f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}" + ), + ) + return self._ensure_openclaw_control_plane_engine(routed) + + async def _resolve_engine_for_test( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: Optional[str], + ) -> dict[str, Any]: + if not engine_id: + engine = await self._resolve_active_engine(db, tenant_id=tenant_id) + if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): + raise ValueError("当前激活引擎未配置 API Key") + return engine + + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + for item in engines: + if str(item.get("id") or "") == str(engine_id): + engine = self._normalize_engine_runtime(item) + if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): + raise ValueError("目标引擎未配置 API Key") + return engine + raise ValueError("engine 不存在") + + async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: + cached = self._prompt_profile_cache.get(int(tenant_id)) + now = time.time() + if cached and cached[0] > now: + return dict(cached[1]) + + role_name = "" + system_prompt = "" + role_key = "" + try: + row = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_id), + Integration.type == "ai_prompt_profiles", + ) + ) + ) + ).scalar_one_or_none() + bundle = _safe_json(row.config if row else {}) + active_key = str(bundle.get("active_role_key") or "").strip() + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + selected = None + for item in roles: + if not isinstance(item, dict): + continue + item_key = str(item.get("role_key") or "").strip() + if item_key == active_key: + selected = item + break + if selected is None: + for item in roles: + if isinstance(item, dict) and bool(item.get("enabled", True)): + selected = item + break + if isinstance(selected, dict): + role_name = str(selected.get("role_name") or "").strip() + system_prompt = str(selected.get("system_prompt") or "").strip() + role_key = str(selected.get("role_key") or "").strip() + except Exception: + role_name = "" + system_prompt = "" + role_key = "" + + if len(system_prompt) < 20: + fallback = build_default_role_prompt_bundle() + fallback_key = str(fallback.get("active_role_key") or "").strip() + roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] + role = None + for item in roles: + if not isinstance(item, dict): + continue + if str(item.get("role_key") or "").strip() == fallback_key: + role = item + break + if role is None and roles: + role = roles[0] if isinstance(roles[0], dict) else None + if isinstance(role, dict): + role_name = str(role.get("role_name") or role_name or "默认角色").strip() + system_prompt = str(role.get("system_prompt") or "").strip() + role_key = str(role.get("role_key") or role_key or fallback_key).strip() + + result = { + "role_key": role_key, + "role_name": role_name, + "system_prompt": system_prompt, + } + self._prompt_profile_cache[int(tenant_id)] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) + return dict(result) + + def _build_fallback_engines(self, primary_engine: dict[str, Any]) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + provider = _normalize_provider(primary_engine.get("provider")) + current_model = str(primary_engine.get("model") or "").strip() + + provider_runtime = self._env_provider_runtime(provider) + default_model = str(provider_runtime.get("model") or PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip() + if default_model and default_model != current_model: + same_provider = dict(primary_engine) + same_provider["model"] = default_model + candidates.append(self._normalize_engine_runtime(same_provider)) + + fallback_provider_raw = str(os.getenv("LLM_FALLBACK_PROVIDER", "ollama")).strip() + if fallback_provider_raw: + fallback_provider = _normalize_provider(fallback_provider_raw) + if fallback_provider != provider: + candidates.append(self._env_provider_runtime(fallback_provider)) + + return candidates + + def _env_provider_runtime(self, provider: str) -> dict[str, Any]: + provider_name = _normalize_provider(provider) + defaults = PROVIDER_DEFAULTS[provider_name] + api_env = str(defaults.get("api_env") or "").strip() + api_key = os.getenv(api_env, "") if api_env else "" + if provider_name == "glm": + api_key = os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)) + + base_url = os.getenv(f"{provider_name.upper()}_BASE_URL", defaults["base_url"]) + model = os.getenv(f"{provider_name.upper()}_MODEL", defaults["model"]) + if provider_name == "ollama": + base_url = os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", defaults["base_url"])) + model = os.getenv("OLLAMA_MODEL", os.getenv("OLLAMA_CHAT_MODEL", defaults["model"])) + + return { + "id": f"env-{provider_name}", + "name": f"{defaults['name']} (ENV)", + "provider": provider_name, + "api_key": str(api_key or "").strip(), + "base_url": str(base_url or defaults["base_url"]).strip().rstrip("/"), + "model": str(model or defaults["model"]).strip(), + } + + def _env_engine_runtime(self) -> dict[str, Any]: + provider = _normalize_provider(os.getenv("LLM_PROVIDER", "deepseek")) + runtime = self._env_provider_runtime(provider) + runtime["id"] = "env-default" + return runtime + + def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]: + provider = _normalize_provider(engine.get("provider")) + defaults = PROVIDER_DEFAULTS[provider] + runtime = dict(engine) + runtime["provider"] = provider + runtime["base_url"] = str(runtime.get("base_url") or defaults["base_url"]).strip().rstrip("/") + runtime["model"] = str(runtime.get("model") or defaults["model"]).strip() + runtime["api_key"] = str(runtime.get("api_key") or "").strip() + runtime["name"] = str(runtime.get("name") or defaults["name"]).strip() + if not runtime.get("id"): + runtime["id"] = str(uuid4()) + return runtime + + async def _log_ai_call( + self, + *, + tenant_id: int, + function_name: str, + prompt: str, + response: str, + status: str, + provider: str = "", + model: str = "", + ) -> None: + tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) + cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used) + row_payload = { + "tenant_id": tenant_id, + "user_id": None, + "function_name": function_name, + "prompt": prompt[:8000], + "response": (response or "")[:8000], + "tokens_used": tokens_used, + "cost": cost, + "status": status, + } + async with AsyncSessionLocal() as session: + for attempt in range(2): + try: + session.add(AICallLog(**row_payload)) + await session.commit() + return + except OperationalError as exc: + await session.rollback() + if "database is locked" not in str(exc).lower() or attempt >= 1: + return + await asyncio.sleep(0.05 * (attempt + 1)) + except Exception: + await session.rollback() + return + + @staticmethod + def _estimate_cost(provider: str, model: str, tokens: int) -> float: + _ = model + per_1k = { + "deepseek": 0.0003, + "glm": 0.0006, + "openai": 0.0012, + "ollama": 0.0, + }.get(provider, 0.0005) + return round((tokens / 1000.0) * per_1k, 6) + + @staticmethod + def _parse_json_object(text: str) -> dict[str, Any]: + raw = (text or "").strip() + if raw.startswith("```"): + parts = raw.split("```") + if len(parts) >= 2: + raw = parts[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + @staticmethod + def _parse_json_array(text: str) -> list[dict]: + raw = (text or "").strip() + if raw.startswith("```"): + parts = raw.split("```") + if len(parts) >= 2: + raw = parts[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [item for item in parsed if isinstance(item, dict)] + except Exception: + return [] + return [] + + @staticmethod + def _extract_text_from_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (int, float, bool)): + return str(value) + if isinstance(value, dict): + parts: list[str] = [] + for key in ("text", "content", "value", "output_text", "input_text", "arguments", "json"): + if key in value: + parts.append(LLMService._extract_text_from_value(value.get(key))) + return "".join(parts) + if isinstance(value, list): + return "".join(LLMService._extract_text_from_value(item) for item in value) + return str(value) + + @staticmethod + def _sanitize_visible_answer(text: str) -> str: + cleaned = str(text or "") + if not cleaned: + return "" + cleaned = re.sub(r".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) + return cleaned.strip() + + +llm_service = LLMService() diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py new file mode 100644 index 00000000..72aa9e81 --- /dev/null +++ b/backend/app/services/llm_client.py @@ -0,0 +1,184 @@ +""" +统一 LLM 客户端 - 支持多种提供商 + +支持的提供商: +- ollama: 本地运行 (免费,但占用 CPU/内存) +- deepseek: 云端 API (便宜,¥1/百万 tokens) +- openai: 云端 API (GPT-4) +- qwen: 阿里云通义千问 (便宜,国内稳定) +""" + +import httpx +from typing import Dict, List, Optional, Any +import os +import json + + +class LLMClient: + """统一 LLM 客户端""" + + def __init__(self): + self.provider = os.getenv("LLM_PROVIDER", "deepseek").lower() + self.client = self._create_client() + + def _create_client(self) -> 'LLMProvider': + """根据配置创建对应的提供商客户端""" + if self.provider == "ollama": + return OllamaClient() + elif self.provider == "deepseek": + return OpenAICompatibleClient( + api_key=os.getenv("DEEPSEEK_API_KEY"), + base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"), + model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + ) + elif self.provider == "openai": + return OpenAICompatibleClient( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), + model=os.getenv("OPENAI_MODEL", "gpt-4o-mini") + ) + elif self.provider == "qwen": + return OpenAICompatibleClient( + api_key=os.getenv("QWEN_API_KEY"), + base_url=os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"), + model=os.getenv("QWEN_MODEL", "qwen-plus") + ) + else: + raise ValueError(f"不支持的 LLM 提供商: {self.provider}") + + def generate(self, prompt: str, **kwargs) -> str: + """生成文本""" + return self.client.generate(prompt, **kwargs) + + def generate_json(self, prompt: str, **kwargs) -> Dict: + """生成 JSON 格式响应""" + return self.client.generate_json(prompt, **kwargs) + + def get_embedding(self, text: str) -> List[float]: + """获取向量嵌入""" + return self.client.get_embedding(text) + + +class LLMProvider: + """LLM 提供商基类""" + def generate(self, prompt: str, **kwargs) -> str: + raise NotImplementedError + + def generate_json(self, prompt: str, **kwargs) -> Dict: + raise NotImplementedError + + def get_embedding(self, text: str) -> List[float]: + raise NotImplementedError + + +class OllamaClient(LLMProvider): + """Ollama 本地客户端""" + + def __init__(self): + self.host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip('/') + self.chat_model = os.getenv("OLLAMA_CHAT_MODEL", "llama3:8b") + self.embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text") + self.client = httpx.Client(base_url=self.host, timeout=300.0, trust_env=False) + + def generate(self, prompt: str, **kwargs) -> str: + response = self.client.post("/api/generate", json={ + "model": self.chat_model, + "prompt": prompt, + "stream": False, + **kwargs + }) + response.raise_for_status() + return response.json()["response"] + + def generate_json(self, prompt: str, **kwargs) -> Dict: + response = self.client.post("/api/generate", json={ + "model": self.chat_model, + "prompt": prompt, + "stream": False, + "format": "json", + **kwargs + }) + response.raise_for_status() + content = response.json()["response"] + # 清理 markdown 代码块 + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + return json.loads(content) + + def get_embedding(self, text: str) -> List[float]: + response = self.client.post("/api/embeddings", json={ + "model": self.embedding_model, + "prompt": text + }) + response.raise_for_status() + return response.json().get("embedding", []) + + +class OpenAICompatibleClient(LLMProvider): + """OpenAI 兼容客户端 (适用于 DeepSeek, Qwen 等)""" + + def __init__(self, api_key: str, base_url: str, model: str): + self.model = model + self.base_url = base_url + self.api_key = api_key + # 延迟初始化客户端,在实际调用时才检查 API_KEY + self._client = None + + @property + def client(self): + """延迟初始化客户端""" + if self._client is None: + if not self.api_key: + raise ValueError(f"请设置 {self.base_url} 的 API_KEY") + self._client = httpx.Client( + base_url=self.base_url, + timeout=120.0, + headers={"Authorization": f"Bearer {self.api_key}"} + ) + return self._client + + def generate(self, prompt: str, **kwargs) -> str: + response = self.client.post("/chat/completions", json={ + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + **kwargs + }) + response.raise_for_status() + return response.json()["choices"][0]["message"]["content"] + + def generate_json(self, prompt: str, **kwargs) -> Dict: + response = self.client.post("/chat/completions", json={ + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + "response_format": {"type": "json_object"}, + **kwargs + }) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + return json.loads(content) + + def get_embedding(self, text: str) -> List[float]: + # DeepSeek 不支持 embedding,使用替代方案或返回空 + if "deepseek" in self.base_url: + print("[WARNING] DeepSeek 不支持 embedding API,使用简化向量") + # 简化方案:基于文本内容生成伪向量(实际使用时应该用其他服务) + import hashlib + hash_obj = hashlib.md5(text.encode()) + # 生成 768 维伪向量(与 nomic-embed-text 维度一致) + return [float((hash_obj.digest()[i % 16] / 255.0) * 2 - 1) for _ in range(768)] + + response = self.client.post("/embeddings", json={ + "model": self.model, + "input": text + }) + response.raise_for_status() + return response.json()["data"][0]["embedding"] + + +# 单例实例 +llm_client = LLMClient() diff --git a/backend/app/services/model_capability_service.py b/backend/app/services/model_capability_service.py new file mode 100644 index 00000000..64c9826b --- /dev/null +++ b/backend/app/services/model_capability_service.py @@ -0,0 +1,701 @@ +"""模型能力矩阵核心服务 + +AI 开发团队专属垂直能力矩阵的核心业务逻辑: +- 模型-需求智能匹配校验 +- 算力成本跟踪与 ROI 核算 +- 能力差距检测与预警 +- 成本优化建议 +""" +import json +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import select, and_, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ( + ModelVersion, + ModelRequirementBinding, + TruthNode, + Task, + AICallLog, + ModelCapabilityGap, + CostTracking, + User, +) +from ..services.ai_service import AIService + + +class ModelCapabilityService: + """模型能力矩阵核心服务""" + + def __init__(self, db: AsyncSession, ai_service: AIService): + self.db = db + self.ai_service = ai_service + + # ======================================================================== + # 模型-需求智能匹配校验 + # ======================================================================== + + async def validate_model_requirement_match( + self, + truth_node_id: int, + model_version_id: int, + tenant_id: int, + project_id: Optional[int] = None, + ) -> Dict[str, Any]: + """智能匹配校验:评估需求与模型的匹配度 + + Args: + truth_node_id: 宪章(需求)ID + model_version_id: 模型版本 ID + tenant_id: 租户 ID + + Returns: + 包含匹配度、成本预估、风险提示、能力差距等信息的字典 + """ + # 获取宪章和模型信息 + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + model_version = await self._get_model_version(model_version_id) + + if not truth_node: + raise ValueError(f"宪章 {truth_node_id} 不存在") + if not model_version: + raise ValueError(f"模型版本 {model_version_id} 不存在") + if project_id is not None: + await self.validate_truth_node_in_project( + truth_node_id=truth_node_id, + tenant_id=tenant_id, + project_id=project_id, + ) + + # 格式化模型信息用于 AI 分析 + model_info = self._format_model_info(model_version) + charter_content = str(truth_node.content or truth_node.title or "") + + # AI 评估匹配度 + evaluation = await self.ai_service.evaluate_model_requirement( + charter_content=charter_content, + model_version_id=model_version_id, + model_info=json.dumps(model_info, ensure_ascii=False), + tenant_id=tenant_id, + db=self.db, + ) + + # 创建或更新绑定记录 + binding = await self._upsert_binding( + truth_node_id, model_version_id, evaluation, tenant_id + ) + + # 检测能力差距 + gaps = await self._detect_capability_gaps( + truth_node_id, model_version_id, evaluation, tenant_id + ) + + return { + "binding_id": binding.id, + "compatibility_score": evaluation.get("compatibility_score", 0), + "cost_estimate_usd": evaluation.get("cost_estimate_usd", 0.0), + "cost_estimate_tokens_per_day": evaluation.get("cost_estimate_tokens_per_day", 0), + "risk_hints": evaluation.get("risk_hints", []), + "capability_gaps": gaps, + "recommendations": evaluation.get("recommendations", []), + } + + async def validate_truth_node_in_project( + self, truth_node_id: int, tenant_id: int, project_id: int + ) -> TruthNode: + """校验宪章是否属于指定项目。""" + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + if not truth_node: + raise ValueError(f"宪章 {truth_node_id} 不存在") + if truth_node.project_id != project_id: + raise ValueError( + f"宪章 {truth_node_id} 不属于项目 {project_id},拒绝跨项目操作" + ) + return truth_node + + async def _get_truth_node( + self, truth_node_id: int, tenant_id: int + ) -> Optional[TruthNode]: + """获取宪章""" + result = await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tenant_id, + ) + ) + ) + return result.scalar_one_or_none() + + async def _get_model_version( + self, model_version_id: int + ) -> Optional[ModelVersion]: + """获取模型版本""" + result = await self.db.execute( + select(ModelVersion).where(ModelVersion.id == model_version_id) + ) + return result.scalar_one_or_none() + + def _format_model_info(self, model: ModelVersion) -> Dict[str, Any]: + """格式化模型信息用于 AI 分析""" + return { + "name": model.name, + "provider": model.provider, + "version": model.version, + "capability_boundaries": model.capability_boundaries or {}, + "cost_per_1k_tokens": float(model.cost_per_1k_tokens) if model.cost_per_1k_tokens else 0.0, + } + + async def _upsert_binding( + self, + truth_node_id: int, + model_version_id: int, + evaluation: Dict[str, Any], + tenant_id: int, + ) -> ModelRequirementBinding: + """创建或更新模型-需求绑定""" + # 检查是否已存在绑定 + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.truth_node_id == truth_node_id, + ModelRequirementBinding.model_version_id == model_version_id, + ) + ) + ) + binding = result.scalar_one_or_none() + + compatibility_score = evaluation.get("compatibility_score", 0) + cost_estimate = evaluation.get("cost_estimate_usd", 0.0) + risk_notes = ", ".join(evaluation.get("risk_hints", [])) + + if binding: + # 更新现有绑定 + binding.compatibility_score = int(compatibility_score) + binding.estimated_cost = float(cost_estimate) + binding.risk_notes = risk_notes[:1000] if risk_notes else None # 限制长度 + else: + # 创建新绑定 + binding = ModelRequirementBinding( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + model_version_id=model_version_id, + compatibility_score=int(compatibility_score), + estimated_cost=float(cost_estimate), + risk_notes=risk_notes[:1000] if risk_notes else None, + ) + self.db.add(binding) + + await self.db.commit() + await self.db.refresh(binding) + return binding + + async def _detect_capability_gaps( + self, + truth_node_id: int, + model_version_id: int, + evaluation: Dict[str, Any], + tenant_id: int, + ) -> List[Dict[str, Any]]: + """检测能力差距并创建预警记录""" + gaps = [] + + # 检查兼容性评分 + compatibility = evaluation.get("compatibility_score", 100) + if compatibility < 60: + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="capability", + severity="critical" if compatibility < 40 else "high", + description=f"模型兼容性评分仅 {compatibility}/100,可能无法满足需求", + ai_analysis={"compatibility_score": compatibility}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + # 检查成本风险 + cost_estimate = evaluation.get("cost_estimate_usd", 0) + if cost_estimate > 100: # 日成本超过 $100 视为高风险 + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="cost", + severity="high" if cost_estimate > 500 else "medium", + description=f"预估日成本 ${cost_estimate:.2f},超出常规范围", + ai_analysis={"cost_estimate_usd": cost_estimate}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + # 检查风险提示 + risk_hints = evaluation.get("risk_hints", []) + if risk_hints: + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="risk", + severity="medium", + description="检测到潜在风险", + ai_analysis={"risk_hints": risk_hints}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + return gaps + + async def _create_capability_gap( + self, + truth_node_id: int, + model_version_id: int, + gap_type: str, + severity: str, + description: str, + ai_analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + """创建能力差距记录""" + # 检查是否已存在相同类型的未解决差距 + existing = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.truth_node_id == truth_node_id, + ModelCapabilityGap.model_version_id == model_version_id, + ModelCapabilityGap.gap_type == gap_type, + ModelCapabilityGap.status.in_(["open", "acknowledged"]), + ) + ) + ) + existing_gap = existing.scalar_one_or_none() + + if existing_gap: + # 更新现有差距 + existing_gap.description = description + existing_gap.ai_analysis = ai_analysis + existing_gap.severity = severity + await self.db.commit() + await self.db.refresh(existing_gap) + return { + "id": existing_gap.id, + "gap_type": existing_gap.gap_type, + "severity": existing_gap.severity, + "description": existing_gap.description, + "status": existing_gap.status, + } + else: + # 创建新差距 + gap = ModelCapabilityGap( + tenant_id=tenant_id, + model_version_id=model_version_id, + truth_node_id=truth_node_id, + gap_type=gap_type, + severity=severity, + description=description, + ai_analysis=ai_analysis, + status="open", + ) + self.db.add(gap) + await self.db.commit() + await self.db.refresh(gap) + return { + "id": gap.id, + "gap_type": gap.gap_type, + "severity": gap.severity, + "description": gap.description, + "status": gap.status, + } + + # ======================================================================== + # 算力成本跟踪与 ROI 核算 + # ======================================================================== + + async def track_cost_and_roi( + self, + truth_node_id: int, + period: str = "weekly", + tenant_id: int = 1, + ) -> Dict[str, Any]: + """算力成本跟踪与 ROI 核算 + + Args: + truth_node_id: 宪章(需求)ID + period: 周期类型(daily, weekly, monthly, project) + tenant_id: 租户 ID + + Returns: + 包含成本明细、ROI 分析等信息的字典 + """ + # 获取相关任务和模型绑定 + tasks = await self._get_truth_node_tasks(truth_node_id, tenant_id) + bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) + + if not tasks: + return { + "total_tokens": 0, + "total_cost_usd": 0.0, + "task_breakdown": [], + "roi_analysis": {}, + "cost_variance": 0.0, + } + + # 汇总 token 使用量和成本 + total_tokens = 0 + total_cost = 0.0 + task_costs = [] + + for task in tasks: + # 从 AI 调用日志统计 token + task_tokens = await self._get_task_token_usage(task.id) + task_cost = await self._calculate_task_cost(task, bindings) + + total_tokens += task_tokens + total_cost += task_cost + task_costs.append({ + "task_id": task.id, + "task_name": task.title, + "tokens": task_tokens, + "cost_usd": round(task_cost, 4), + }) + + # 获取或创建成本跟踪记录 + cost_tracking = await self._get_or_create_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + bindings=bindings, + ) + + # 更新实际数据 + cost_tracking.actual_tokens = total_tokens + cost_tracking.actual_cost_usd = round(total_cost, 4) + + # AI 分析 ROI + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + roi_analysis = await self._analyze_cost_roi( + total_cost=total_cost, + total_tokens=total_tokens, + task_count=len(tasks), + truth_node_content=str(truth_node.content or truth_node.title or "") if truth_node else "", + tenant_id=tenant_id, + ) + + cost_tracking.roi_score = roi_analysis.get("roi_score") + cost_tracking.roi_analysis = roi_analysis + + # 生成优化建议 + optimization = await self._generate_cost_optimization( + truth_node_id=truth_node_id, + tenant_id=tenant_id, + total_cost=total_cost, + ) + cost_tracking.optimization_suggestions = optimization + + await self.db.commit() + await self.db.refresh(cost_tracking) + + # 计算成本偏差 + cost_variance = total_cost - (cost_tracking.estimated_cost_usd or 0) + + return { + "total_tokens": total_tokens, + "total_cost_usd": round(total_cost, 4), + "task_breakdown": task_costs, + "roi_analysis": roi_analysis, + "cost_variance": round(cost_variance, 4), + "optimization_suggestions": optimization, + } + + async def _get_truth_node_tasks( + self, truth_node_id: int, tenant_id: int + ) -> List[Task]: + """获取宪章下的所有任务""" + result = await self.db.execute( + select(Task).where( + and_( + Task.truth_node_id == truth_node_id, + Task.tenant_id == tenant_id, + ) + ) + ) + return list(result.scalars().all()) + + async def _get_truth_node_bindings( + self, truth_node_id: int, tenant_id: int + ) -> List[ModelRequirementBinding]: + """获取宪章的模型绑定""" + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.truth_node_id == truth_node_id, + ModelRequirementBinding.tenant_id == tenant_id, + ) + ) + ) + return list(result.scalars().all()) + + async def _get_task_token_usage(self, task_id: int) -> int: + """从 AI 调用日志统计任务的 token 使用量""" + # 注意:需要根据实际日志记录方式调整 + # 这里假设日志中有 task_id 或相关标识 + result = await self.db.execute( + select(func.sum(AICallLog.total_tokens)).where( + AICallLog.function_name.contains(f"task_{task_id}") + ) + ) + total = result.scalar() or 0 + return int(total) + + async def _calculate_task_cost( + self, task: Task, bindings: List[ModelRequirementBinding] + ) -> float: + """计算任务成本""" + # 如果有绑定记录,使用绑定的预估成本 + if bindings: + return sum(float(b.estimated_cost or 0) for b in bindings) / max(len(bindings), 1) + return 0.0 + + async def _get_or_create_cost_tracking( + self, + truth_node_id: int, + period: str, + tenant_id: int, + bindings: List[ModelRequirementBinding], + ) -> CostTracking: + """获取或创建成本跟踪记录""" + # 查找现有记录 + result = await self.db.execute( + select(CostTracking).where( + and_( + CostTracking.truth_node_id == truth_node_id, + CostTracking.period == period, + CostTracking.tenant_id == tenant_id, + ) + ).order_by(desc(CostTracking.created_at)) + ) + tracking = result.scalar_one_or_none() + + if not tracking: + # 创建新记录 + tracking = CostTracking( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + model_version_id=bindings[0].model_version_id if bindings else None, + period=period, + estimated_tokens=0, + actual_tokens=0, + estimated_cost_usd=sum(float(b.estimated_cost or 0) for b in bindings), + actual_cost_usd=0.0, + roi_score=None, + roi_analysis={}, + optimization_suggestions={}, + ) + self.db.add(tracking) + await self.db.commit() + await self.db.refresh(tracking) + + return tracking + + async def _analyze_cost_roi( + self, + total_cost: float, + total_tokens: int, + task_count: int, + truth_node_content: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析成本效益""" + from ..config.ai_prompts import PROMPT_ANALYZE_COST_ROI + + prompt = PROMPT_ANALYZE_COST_ROI.format( + total_cost=f"${total_cost:.2f}", + total_tokens=f"{total_tokens:,}", + task_count=task_count, + truth_node_content=truth_node_content[:2000], # 限制长度 + ) + + try: + text = await self.ai_service._invoke_with_retry( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="analyze_cost_roi", + ) + return self.ai_service._parse_json(text) + except Exception: + # 失败时返回默认分析 + return { + "roi_score": 5.0, + "cost_efficiency": "fair", + "cost_per_task": round(total_cost / max(task_count, 1), 2), + "optimization_suggestions": [], + "benchmark_comparison": "无法分析", + } + + async def _generate_cost_optimization( + self, truth_node_id: int, tenant_id: int, total_cost: float + ) -> Dict[str, Any]: + """生成成本优化建议""" + # 简单的优化建议逻辑 + suggestions = [] + + # 检查是否可以使用更便宜的模型 + bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) + if bindings: + for binding in bindings: + model = await self._get_model_version(binding.model_version_id) + if model and model.cost_per_1k_tokens and model.cost_per_1k_tokens > 0.001: + suggestions.append({ + "type": "model_downgrade", + "priority": "medium", + "description": f"考虑切换到更便宜的模型替代 {model.name}", + "estimated_savings_percent": 30, + }) + + # 检查是否可以启用缓存 + suggestions.append({ + "type": "cache_optimization", + "priority": "high", + "description": "启用 Redis 缓存可减少 20-40% 重复调用", + "estimated_savings_percent": 30, + }) + + return { + "suggestions": suggestions, + "total_potential_savings_percent": 30 if suggestions else 0, + } + + # ======================================================================== + # 能力差距管理 + # ======================================================================== + + async def list_capability_gaps( + self, + tenant_id: int, + status: Optional[str] = None, + severity: Optional[str] = None, + limit: int = 100, + ) -> Dict[str, Any]: + """列出能力差距记录""" + query = select(ModelCapabilityGap).where( + ModelCapabilityGap.tenant_id == tenant_id + ) + + if status: + query = query.where(ModelCapabilityGap.status == status) + if severity: + query = query.where(ModelCapabilityGap.severity == severity) + + query = query.order_by(desc(ModelCapabilityGap.created_at)).limit(limit) + + result = await self.db.execute(query) + gaps = result.scalars().all() + + return { + "gaps": [ + { + "id": g.id, + "gap_type": g.gap_type, + "severity": g.severity, + "description": g.description, + "ai_analysis": g.ai_analysis, + "status": g.status, + "created_at": g.created_at.isoformat() if g.created_at else None, + "resolved_at": g.resolved_at.isoformat() if g.resolved_at else None, + } + for g in gaps + ], + "total": len(gaps), + } + + async def acknowledge_gap( + self, gap_id: int, tenant_id: int + ) -> Dict[str, Any]: + """确认能力差距""" + result = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.id == gap_id, + ModelCapabilityGap.tenant_id == tenant_id, + ) + ) + ) + gap = result.scalar_one_or_none() + + if not gap: + raise ValueError(f"能力差距 {gap_id} 不存在") + + gap.status = "acknowledged" + await self.db.commit() + + return {"id": gap.id, "status": gap.status} + + async def resolve_gap( + self, gap_id: int, tenant_id: int, resolution_notes: Optional[str] = None + ) -> Dict[str, Any]: + """解决能力差距""" + result = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.id == gap_id, + ModelCapabilityGap.tenant_id == tenant_id, + ) + ) + ) + gap = result.scalar_one_or_none() + + if not gap: + raise ValueError(f"能力差距 {gap_id} 不存在") + + gap.status = "resolved" + gap.resolved_at = datetime.utcnow() + gap.resolution_notes = resolution_notes + await self.db.commit() + + return {"id": gap.id, "status": gap.status} + + # ======================================================================== + # 成本跟踪查询 + # ======================================================================== + + async def get_cost_tracking( + self, truth_node_id: int, period: str = "weekly", tenant_id: int = 1 + ) -> Dict[str, Any]: + """获取成本跟踪记录""" + result = await self.db.execute( + select(CostTracking).where( + and_( + CostTracking.truth_node_id == truth_node_id, + CostTracking.period == period, + CostTracking.tenant_id == tenant_id, + ) + ).order_by(desc(CostTracking.created_at)) + ) + tracking = result.scalar_one_or_none() + + if not tracking: + return { + "truth_node_id": truth_node_id, + "period": period, + "estimated_tokens": 0, + "actual_tokens": 0, + "estimated_cost_usd": 0.0, + "actual_cost_usd": 0.0, + "roi_score": None, + "roi_analysis": {}, + "optimization_suggestions": {}, + } + + return { + "id": tracking.id, + "truth_node_id": tracking.truth_node_id, + "period": tracking.period, + "estimated_tokens": tracking.estimated_tokens, + "actual_tokens": tracking.actual_tokens, + "estimated_cost_usd": float(tracking.estimated_cost_usd), + "actual_cost_usd": float(tracking.actual_cost_usd), + "roi_score": tracking.roi_score, + "roi_analysis": tracking.roi_analysis, + "optimization_suggestions": tracking.optimization_suggestions, + } diff --git a/backend/app/services/model_lifecycle_service.py b/backend/app/services/model_lifecycle_service.py new file mode 100644 index 00000000..0bdb99ef --- /dev/null +++ b/backend/app/services/model_lifecycle_service.py @@ -0,0 +1,211 @@ +"""模型生命周期管理服务(P1)""" + +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ( + ModelRequirementBinding, + ModelUpdateImpact, + ModelVersion, +) + + +class ModelLifecycleService: + """模型更新影响评估与迁移任务生成。""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def assess_model_update_impact( + self, + old_model_version_id: int, + new_model_version_id: int, + tenant_id: int, + ) -> Dict[str, Any]: + old_model = await self._get_model(old_model_version_id) + new_model = await self._get_model(new_model_version_id) + if not old_model or not new_model: + raise ValueError("模型版本不存在") + + affected = await self._get_affected_truth_nodes(old_model_version_id, tenant_id) + cost_change = self._calc_cost_change_percent(old_model, new_model) + performance_change = self._infer_performance_change(old_model, new_model) + risks = self._infer_compatibility_risks(old_model, new_model) + recommendation = self._recommend(cost_change, performance_change, risks) + + impact = ModelUpdateImpact( + tenant_id=tenant_id, + old_model_version_id=old_model_version_id, + new_model_version_id=new_model_version_id, + affected_truth_nodes=affected, + cost_change_percentage=cost_change, + performance_change=performance_change, + compatibility_risks=risks, + migration_tasks={}, + recommendation=recommendation, + status="pending_review", + ) + self.db.add(impact) + await self.db.commit() + await self.db.refresh(impact) + + return { + "impact_id": impact.id, + "affected_truth_nodes": affected, + "cost_change_percentage": cost_change, + "performance_change": performance_change, + "compatibility_risks": risks, + "recommendation": recommendation, + "status": impact.status, + } + + async def generate_migration_tasks( + self, + impact_id: int, + tenant_id: int, + max_tasks: int = 5, + ) -> Dict[str, Any]: + impact = await self._get_impact(impact_id, tenant_id) + if not impact: + raise ValueError("影响评估记录不存在") + + tasks: List[Dict[str, Any]] = [] + affected_map = impact.affected_truth_nodes or {} + for truth_node_id, score in list(affected_map.items())[:max_tasks]: + tasks.append( + { + "title": f"评估并迁移宪章 {truth_node_id} 到新模型", + "type": "ai", + "priority": "high" if float(score) >= 0.7 else "medium", + "estimated_hours": 2.0 if float(score) >= 0.7 else 1.0, + "notes": f"旧模型 -> 新模型影响分: {score}", + } + ) + + if impact.compatibility_risks: + tasks.append( + { + "title": "执行回归验证(模型升级)", + "type": "backend", + "priority": "high", + "estimated_hours": 3.0, + "notes": "覆盖接口行为、输出一致性、预算消耗变化", + } + ) + + impact.migration_tasks = {"tasks": tasks[:max_tasks]} + impact.status = "in_progress" + await self.db.commit() + await self.db.refresh(impact) + + return { + "impact_id": impact.id, + "status": impact.status, + "migration_tasks": impact.migration_tasks, + } + + async def list_impacts(self, tenant_id: int, limit: int = 50) -> Dict[str, Any]: + result = await self.db.execute( + select(ModelUpdateImpact) + .where(ModelUpdateImpact.tenant_id == tenant_id) + .order_by(ModelUpdateImpact.created_at.desc()) + .limit(limit) + ) + impacts = result.scalars().all() + return { + "items": [ + { + "id": i.id, + "old_model_version_id": i.old_model_version_id, + "new_model_version_id": i.new_model_version_id, + "cost_change_percentage": i.cost_change_percentage, + "recommendation": i.recommendation, + "status": i.status, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in impacts + ] + } + + async def _get_model(self, model_version_id: int) -> Optional[ModelVersion]: + result = await self.db.execute( + select(ModelVersion).where(ModelVersion.id == model_version_id) + ) + return result.scalar_one_or_none() + + async def _get_impact(self, impact_id: int, tenant_id: int) -> Optional[ModelUpdateImpact]: + result = await self.db.execute( + select(ModelUpdateImpact).where( + and_(ModelUpdateImpact.id == impact_id, ModelUpdateImpact.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def _get_affected_truth_nodes( + self, old_model_version_id: int, tenant_id: int + ) -> Dict[str, float]: + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.model_version_id == old_model_version_id, + ModelRequirementBinding.tenant_id == tenant_id, + ) + ) + ) + bindings = result.scalars().all() + + affected: Dict[str, float] = {} + for b in bindings: + # 兼容度越低,升级影响越高。 + impact_score = round(max(0.1, (100 - int(b.compatibility_score)) / 100), 2) + affected[str(b.truth_node_id)] = impact_score + + # 如果当前没有绑定,返回空映射。 + return affected + + def _calc_cost_change_percent(self, old_model: ModelVersion, new_model: ModelVersion) -> float: + old_cost = float(old_model.cost_per_1k_tokens or 0.0) + new_cost = float(new_model.cost_per_1k_tokens or 0.0) + if old_cost <= 0: + return 0.0 + return round(((new_cost - old_cost) / old_cost) * 100, 2) + + def _infer_performance_change( + self, old_model: ModelVersion, new_model: ModelVersion + ) -> Dict[str, float]: + old_caps = old_model.capability_boundaries or {} + new_caps = new_model.capability_boundaries or {} + old_ctx = float(old_caps.get("context_window", 0) or 0) + new_ctx = float(new_caps.get("context_window", 0) or 0) + ctx_change = 0.0 if old_ctx <= 0 else round(((new_ctx - old_ctx) / old_ctx) * 100, 2) + quality_change = round(10.0 if len(new_caps.keys()) > len(old_caps.keys()) else 0.0, 2) + return { + "context_window_change_percent": ctx_change, + "quality_change_percent": quality_change, + } + + def _infer_compatibility_risks( + self, old_model: ModelVersion, new_model: ModelVersion + ) -> Dict[str, Any]: + risks: List[Dict[str, str]] = [] + if old_model.provider != new_model.provider: + risks.append({"type": "provider_change", "severity": "high"}) + if old_model.name != new_model.name: + risks.append({"type": "model_family_change", "severity": "medium"}) + return {"risks": risks, "count": len(risks)} + + def _recommend( + self, + cost_change: float, + performance_change: Dict[str, float], + risks: Dict[str, Any], + ) -> str: + if risks.get("count", 0) > 0 and performance_change.get("quality_change_percent", 0) <= 0: + return "evaluate" + if cost_change <= 10 and performance_change.get("quality_change_percent", 0) >= 5: + return "immediate_upgrade" + if cost_change > 30 and performance_change.get("quality_change_percent", 0) < 5: + return "defer" + return "evaluate" diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..c1e1babe --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,241 @@ +"""站内通知服务""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Notification, User + + +class NotificationService: + def __init__(self, db: AsyncSession): + self.db = db + + async def create_notification( + self, + *, + tenant_id: int, + user_id: int, + title: str, + content: Optional[str] = None, + category: str = "system", + source: Optional[str] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + target_url: Optional[str] = None, + kind: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + blocking_reason: Optional[str] = None, + recommended_action: Optional[str] = None, + dedupe_key: Optional[str] = None, + occurrence_count: int = 1, + payload: Optional[dict[str, Any]] = None, + commit: bool = False, + ) -> Notification: + normalized_dedupe_key = (dedupe_key or "").strip()[:255] or None + if normalized_dedupe_key: + existing = ( + await self.db.execute( + select(Notification) + .where( + and_( + Notification.tenant_id == int(tenant_id), + Notification.user_id == int(user_id), + Notification.dedupe_key == normalized_dedupe_key, + Notification.is_read.is_(False), + ) + ) + .order_by(Notification.updated_at.desc(), Notification.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + existing.category = (category or existing.category or "system")[:50] + existing.title = (title or existing.title or "系统通知")[:200] + existing.content = (content or existing.content or "")[:4000] or None + existing.source = (source or existing.source or "")[:50] or None + existing.entity_type = (entity_type or existing.entity_type or "")[:50] or None + existing.entity_id = int(entity_id) if entity_id is not None else existing.entity_id + existing.target_url = (target_url or existing.target_url or "")[:500] or None + existing.kind = (kind or existing.kind or "")[:30] or None + existing.priority = (priority or existing.priority or "")[:20] or None + existing.status = (status or existing.status or "")[:30] or None + existing.blocking_reason = (blocking_reason or existing.blocking_reason or "")[:2000] or None + existing.recommended_action = (recommended_action or existing.recommended_action or "")[:80] or None + existing.payload = payload or existing.payload + existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max(1, int(occurrence_count or 1)) + await self.db.flush() + if commit: + await self.db.commit() + return existing + + row = Notification( + tenant_id=int(tenant_id), + user_id=int(user_id), + category=(category or "system")[:50], + title=(title or "系统通知")[:200], + content=(content or "")[:4000] or None, + source=(source or "")[:50] or None, + entity_type=(entity_type or "")[:50] or None, + entity_id=int(entity_id) if entity_id is not None else None, + target_url=(target_url or "")[:500] or None, + kind=(kind or "")[:30] or None, + priority=(priority or "")[:20] or None, + status=(status or "")[:30] or None, + blocking_reason=(blocking_reason or "")[:2000] or None, + recommended_action=(recommended_action or "")[:80] or None, + dedupe_key=normalized_dedupe_key, + occurrence_count=max(1, int(occurrence_count or 1)), + payload=payload or None, + is_read=False, + ) + self.db.add(row) + await self.db.flush() + if commit: + await self.db.commit() + return row + + async def create_notifications( + self, + *, + tenant_id: int, + user_ids: Iterable[int], + title: str, + content: Optional[str] = None, + category: str = "system", + source: Optional[str] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + target_url: Optional[str] = None, + kind: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + blocking_reason: Optional[str] = None, + recommended_action: Optional[str] = None, + dedupe_key: Optional[str] = None, + occurrence_count: int = 1, + payload: Optional[dict[str, Any]] = None, + commit: bool = False, + ) -> list[Notification]: + rows: list[Notification] = [] + for user_id in user_ids: + if user_id is None: + continue + rows.append( + await self.create_notification( + tenant_id=tenant_id, + user_id=int(user_id), + title=title, + content=content, + category=category, + source=source, + entity_type=entity_type, + entity_id=entity_id, + target_url=target_url, + kind=kind, + priority=priority, + status=status, + blocking_reason=blocking_reason, + recommended_action=recommended_action, + dedupe_key=dedupe_key, + occurrence_count=occurrence_count, + payload=payload, + commit=False, + ) + ) + if commit and rows: + await self.db.commit() + return rows + + async def notify_signal_created( + self, + *, + tenant_id: int, + signal_id: int, + signal_content: str, + source: str = "feishu", + target_url: Optional[str] = None, + recipient_user_ids: Optional[Iterable[int]] = None, + proposer: Optional[str] = None, + occurred_at: Optional[datetime] = None, + ) -> int: + """向指定用户(或租户内全部活跃用户)发送“新需求已生成”通知。""" + user_ids = await self._resolve_recipients( + tenant_id=tenant_id, + recipient_user_ids=recipient_user_ids, + ) + if not user_ids: + return 0 + + snippet = (signal_content or "").strip() + if len(snippet) > 80: + snippet = f"{snippet[:80]}..." + if not snippet: + snippet = "飞书消息触发了新的需求信号。" + + proposer_name = (proposer or "").strip() or "未知用户" + occurred_text = self._format_cn_time(occurred_at) + title = "AI 已识别新需求" + content = ( + f"提出人:{proposer_name}\n" + f"时间:{occurred_text}\n" + f"内容:{snippet}\n" + f"信号ID:{signal_id}" + ) + + rows = await self.create_notifications( + tenant_id=tenant_id, + user_ids=user_ids, + category="requirement", + title=title, + content=content, + source=source, + entity_type="signal", + entity_id=signal_id, + target_url=target_url, + kind="info", + priority="medium", + status="notified", + recommended_action="view_result", + dedupe_key=f"signal-created:{signal_id}", + payload={ + "message_class": "info", + "project_id": None, + "project_name": None, + "summary": snippet, + }, + commit=True, + ) + return len(rows) + + @staticmethod + def _format_cn_time(dt: Optional[datetime]) -> str: + if not dt: + dt = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + cn_tz = timezone(timedelta(hours=8)) + return dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + + async def _resolve_recipients( + self, + *, + tenant_id: int, + recipient_user_ids: Optional[Iterable[int]] = None, + ) -> list[int]: + if recipient_user_ids: + return [int(uid) for uid in recipient_user_ids if uid is not None] + + result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + ) + ) + ) + return [int(uid) for uid in result.scalars().all()] diff --git a/backend/app/services/openclaw_commander_service.py b/backend/app/services/openclaw_commander_service.py new file mode 100644 index 00000000..2b4273f4 --- /dev/null +++ b/backend/app/services/openclaw_commander_service.py @@ -0,0 +1,2417 @@ +"""OpenClaw 指挥层与操作执行服务。""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +import hashlib +import json +import re +import time +from types import SimpleNamespace +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_OPENCLAW_COMMAND_DISPATCH +from ..models import ( + OperationExecutionLedger, + Project, + SkillTag, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, +) +from . import workload_service +from .adaptive_rag_service import AdaptiveRAGService +from .ai_orchestration_run_service import AIOrchestrationRunService +from .context_cache import context_cache_service +from .llm import llm_service +from .operation_catalog_service import operation_catalog_service +from .operation_log_service import log_operation +from .notification_service import NotificationService +from .skill_score_service import process_task_completion +from .sstc_service import ALIGNED_STATUSES +from .task_persistence import build_task_create_draft, persist_task +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence +from ..utils.title import compact_title + +SUPPORTED_COMMANDER_OPERATION_CODES = ( + "project.create", + "project.update", + "project.archive", + "project.reopen", + "task.create", + "task.assign", + "task.prioritize", + "task.status_transition", + "notification.create", + "personnel.workload_update", +) +PERSISTENT_IDEMPOTENCY_OPERATION_CODES = {"notification.create", "project.create"} +SUPPORTED_PROJECT_STATUSES = {"active", "archived", "done"} +SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} +DONE_TASK_STATUSES = {"done", "completed", "closed"} +SOURCE_ONBOARDING_FALLBACK_RE = re.compile( + r"(新增|增加|添加|接入|配置|注册|建立).{0,10}(信号源|来源|监控源|github|飞书|webhook|rss|url|source|channel)", + re.IGNORECASE, +) + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class OpenClawCommanderService: + """将自然语言指令转成可执行 operation plan。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def dispatch_command( + self, + *, + tenant_id: int, + operator_user: User, + project_id: int, + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str = "fast", + trace_id: Optional[str] = None, + persist_run: bool = True, + ) -> dict[str, Any]: + project_row = await self._load_project(tenant_id=tenant_id, project_id=project_id) + if project_row is None: + raise ValueError("project not found") + project = self._build_project_snapshot(project_row) + + members = await self._load_project_team_context( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + tasks, task_summary = await self._load_project_tasks(tenant_id=tenant_id, project_id=project_id, members=members) + planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) + trace_id = str(trace_id or f"openclaw-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}") + + plan, planner_status, planner_diagnostics = await self._plan_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + tasks=tasks, + task_summary=task_summary, + members=members, + instruction=instruction, + dry_run=dry_run, + max_operations=max_operations, + response_mode=response_mode, + project_directory=[self._serialize_project_directory_item(project_row)], + ) + + executor = OperationExecutorService(self.db) + execution = await executor.execute_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + operations=plan.get("operations") or [], + dry_run=dry_run, + trace_id=trace_id, + ) + openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) + run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" + + result = { + "trace_id": trace_id, + "response_mode": response_mode, + "run_status": run_status, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "openclaw_status": openclaw_status, + "project": { + "id": str(project.id), + "title": project.title or project.name, + "status": project.status, + }, + "prompt_role": { + "role_key": str(planner_role.get("role_key") or ""), + "role_name": str(planner_role.get("role_name") or ""), + }, + "instruction": instruction, + "dry_run": bool(dry_run), + "summary": str(plan.get("summary") or "").strip(), + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "task_summary": task_summary, + "member_count": len(members), + "planned_operations": len(plan.get("operations") or []), + "execution": execution, + "pending_workers": [], + } + if persist_run: + run_service = AIOrchestrationRunService(self.db) + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="openclaw_dispatch", + project_id=int(project.id), + signal_id=None, + response_mode=response_mode, + status=run_status, + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(result), + created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None, + completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, + ) + await self.db.commit() + return result + + async def dispatch_global_command( + self, + *, + tenant_id: int, + operator_user: User, + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str = "fast", + trace_id: Optional[str] = None, + persist_run: bool = False, + ) -> dict[str, Any]: + project = self._build_global_project_snapshot() + project_directory = await self._load_project_directory(tenant_id=tenant_id) + planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) + trace_id = str(trace_id or f"openclaw-{tenant_id}-global-{int(time.time() * 1000)}-{uuid4().hex[:8]}") + + plan, planner_status, planner_diagnostics = await self._plan_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + tasks=[], + task_summary={"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, + members=[], + instruction=instruction, + dry_run=dry_run, + max_operations=max_operations, + response_mode=response_mode, + project_directory=project_directory, + ) + + executor = OperationExecutorService(self.db) + execution = await executor.execute_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=None, + operations=plan.get("operations") or [], + dry_run=dry_run, + trace_id=trace_id, + ) + openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) + run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" + project_ref = self._extract_project_reference_from_execution(execution) or { + "id": None, + "title": "", + "status": "", + } + result = { + "trace_id": trace_id, + "response_mode": response_mode, + "run_status": run_status, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "openclaw_status": openclaw_status, + "project": project_ref, + "prompt_role": { + "role_key": str(planner_role.get("role_key") or ""), + "role_name": str(planner_role.get("role_name") or ""), + }, + "instruction": instruction, + "dry_run": bool(dry_run), + "summary": str(plan.get("summary") or "").strip(), + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "task_summary": {"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, + "member_count": 0, + "planned_operations": len(plan.get("operations") or []), + "execution": execution, + "pending_workers": [], + } + resolved_project_id = self._to_int(project_ref.get("id")) if isinstance(project_ref, dict) else None + if persist_run and resolved_project_id is not None and resolved_project_id > 0: + run_service = AIOrchestrationRunService(self.db) + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="openclaw_dispatch", + project_id=int(resolved_project_id), + signal_id=None, + response_mode=response_mode, + status=run_status, + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(result), + created_by_user_id=int(operator_user.id) if getattr(operator_user, "id", None) else None, + completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, + ) + await self.db.commit() + return result + + @staticmethod + def _build_project_snapshot(project: Project) -> SimpleNamespace: + return SimpleNamespace( + id=int(project.id), + owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, + title=str(project.title or "").strip(), + name=str(project.name or "").strip(), + status=str(project.status or "").strip(), + assigned_role=str(project.assigned_role or "").strip(), + description=str(project.description or "").strip(), + ) + + @staticmethod + def _build_global_project_snapshot() -> SimpleNamespace: + return SimpleNamespace( + id=0, + owner_id=None, + title="", + name="", + status="", + assigned_role="", + description="", + ) + + @staticmethod + def _serialize_project_directory_item(project: Project) -> dict[str, Any]: + return { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "status": str(project.status or "").strip(), + } + + async def _plan_operations( + self, + *, + tenant_id: int, + operator_user: User, + project: Project, + tasks: list[dict[str, Any]], + task_summary: dict[str, Any], + members: list[dict[str, Any]], + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str, + project_directory: Optional[list[dict[str, Any]]] = None, + ) -> tuple[dict[str, Any], str, dict[str, Any]]: + command_payload = { + "instruction": instruction, + "operator_user_id": int(operator_user.id), + "operator_name": str(operator_user.name or operator_user.username or operator_user.id), + "operator_role": str(operator_user.role or ""), + "dry_run": bool(dry_run), + "max_operations": int(max_operations), + } + project_payload = { + "scope": "project" if self._to_int(project.id) and int(project.id) > 0 else "global", + "project_id": int(project.id) if self._to_int(project.id) and int(project.id) > 0 else None, + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:400], + "status": str(project.status or "").strip(), + "assigned_role": str(project.assigned_role or "").strip(), + "task_summary": task_summary, + "candidate_projects": project_directory or [], + } + operation_catalog_payload = [ + { + "operation_code": item["operation_code"], + "description": item["description"], + "allowed_fields": sorted( + list( + ( + (item.get("input_schema") or {}).get("properties") or {} + ).keys() + ) + ), + } + for item in self._commander_operation_catalog() + ] + + prompt = PROMPT_OPENCLAW_COMMAND_DISPATCH.format( + command_json=json.dumps(command_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + tasks_json=json.dumps(tasks, ensure_ascii=False), + members_json=json.dumps(members, ensure_ascii=False), + operation_catalog_json=json.dumps(operation_catalog_payload, ensure_ascii=False), + max_operations=int(max_operations), + ) + + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="openclaw_command_dispatch", + use_prompt_profile=False, + temperature=0.0, + max_tokens=520, + response_mode=response_mode, + ) + diagnostics = self._build_planner_diagnostics(extraction) + if extraction.payload is None: + fallback = await self._build_fallback_plan( + tenant_id=tenant_id, + project_id=self._to_int(project.id), + instruction=instruction, + max_operations=max_operations, + error=str(extraction.error_code or "structured_unknown"), + project_directory=project_directory, + ) + return fallback, "fallback", diagnostics + plan = extraction.payload + normalized_ops = self._normalize_operations(plan.get("operations"), max_operations=max_operations) + return ( + { + "summary": str(plan.get("summary") or "").strip() or "OpenClaw 已生成操作计划。", + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "operations": normalized_ops, + }, + "planned", + diagnostics, + ) + except Exception as exc: + fallback = await self._build_fallback_plan( + tenant_id=tenant_id, + project_id=self._to_int(project.id), + instruction=instruction, + max_operations=max_operations, + error=str(exc), + project_directory=project_directory, + ) + return ( + fallback, + "fallback", + { + "used_fallback": True, + "error_code": "structured_empty", + "source_field": None, + "provider": "", + "model": "", + }, + ) + + async def _build_fallback_plan( + self, + *, + tenant_id: int, + project_id: Optional[int], + instruction: str, + max_operations: int, + error: str, + project_directory: Optional[list[dict[str, Any]]] = None, + ) -> dict[str, Any]: + instruction_text = str(instruction or "").strip() + text = instruction_text.lower() + project_ops = await self._build_project_fallback_operations( + tenant_id=tenant_id, + project_id=project_id, + instruction=instruction_text, + max_operations=max_operations, + project_directory=project_directory, + ) + if project_ops: + return { + "summary": "OpenClaw 规划失败,已回退到项目命令最小执行闭环。", + "confidence": 0.58, + "operations": project_ops, + } + if self._looks_like_signal_source_instruction(instruction_text): + if project_id is None: + return { + "summary": "当前信号源指令缺少项目上下文,系统未自动执行。", + "confidence": 0.0, + "operations": [], + } + operations = await self._build_signal_source_fallback_operations( + tenant_id=tenant_id, + project_id=int(project_id), + instruction=instruction_text, + max_operations=max_operations, + ) + if operations: + return { + "summary": "OpenClaw 规划失败,已回退为信号源接入最小闭环。", + "confidence": 0.28, + "operations": operations, + } + return { + "summary": "OpenClaw 未生成可安全执行的信号源接入动作,等待人工补充上下文。", + "confidence": 0.0, + "operations": [], + } + + unassigned_only = any(token in text for token in ("未分配", "没人负责", "unassigned")) + rebalance = any(token in text for token in ("重平衡", "优化负载", "重新分配", "调度")) + assign_like = rebalance or any(token in text for token in ("分配", "负责人", "指派", "接管")) + + if not assign_like: + return { + "summary": "当前指令缺少足够上下文,系统未执行任何动作。", + "confidence": 0.0, + "operations": [], + } + if project_id is None: + return { + "summary": "当前指令缺少项目上下文,系统未自动执行分配动作。", + "confidence": 0.0, + "operations": [], + } + + proposals = await workload_service.build_ai_optimize_preview( + self.db, + tenant_id=tenant_id, + project_id=int(project_id), + max_moves=max_operations, + unassigned_only=unassigned_only and not rebalance, + ) + operations = [ + { + "operation_code": "task.assign", + "payload": { + "task_id": int(item.get("task_id")), + "assignee_id": int(item.get("new_assignee_id")), + }, + "reason": str(item.get("reason") or "fallback_workload_optimizer"), + } + for item in proposals[:max_operations] + if self._to_int(item.get("task_id")) and self._to_int(item.get("new_assignee_id")) + ] + + if operations: + return { + "summary": "OpenClaw 规划失败,已回退到内置负载优化器生成分配计划。", + "confidence": 0.35, + "operations": operations, + } + return { + "summary": "系统未生成可安全执行的分配动作,等待补充更明确的负责人或任务上下文。", + "confidence": 0.0, + "operations": [], + } + + @staticmethod + def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: + return { + "used_fallback": extraction.error_code is not None, + "error_code": extraction.error_code, + "source_field": extraction.source_field or None, + "provider": str(extraction.provider or ""), + "model": str(extraction.model or ""), + } + + @staticmethod + def _resolve_openclaw_status(*, execution: dict[str, Any], dry_run: bool) -> str: + if dry_run: + return "planned" + failed_count = int(execution.get("failed_count") or 0) + applied_count = int(execution.get("applied_count") or 0) + skipped_count = int(execution.get("skipped_count") or 0) + if failed_count > 0 and applied_count == 0 and skipped_count == 0: + return "failed" + if failed_count > 0: + return "partial" + return "completed" + + @staticmethod + def _looks_like_signal_source_instruction(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")) + if len(normalized) < 6: + return False + if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")): + return True + return bool(SOURCE_ONBOARDING_FALLBACK_RE.search(normalized)) + + async def _build_signal_source_fallback_operations( + self, + *, + tenant_id: int, + project_id: int, + instruction: str, + max_operations: int, + ) -> list[dict[str, Any]]: + if max_operations <= 0: + return [] + project = await self._load_project(tenant_id=tenant_id, project_id=project_id) + if project is None: + return [] + + source_name = self._extract_signal_source_name(instruction) + charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) + recipient_id = await self._resolve_signal_source_notification_recipient( + tenant_id=tenant_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + operations: list[dict[str, Any]] = [] + + if charter is not None and len(operations) < max_operations: + operations.append( + { + "operation_code": "task.create", + "payload": { + "project_id": int(project_id), + "title": f"接入{source_name}信号源"[:120], + "description": self._build_signal_source_task_description( + source_name=source_name, + instruction=instruction, + ), + "priority": "high", + }, + "reason": "fallback_signal_source_onboarding", + } + ) + + if recipient_id is not None and len(operations) < max_operations: + operations.append( + { + "operation_code": "notification.create", + "payload": { + "user_id": int(recipient_id), + "title": f"{source_name}信号源接入待确认"[:120], + "content": self._build_signal_source_notification_content( + source_name=source_name, + instruction=instruction, + charter_ready=charter is not None, + ), + }, + "reason": "fallback_signal_source_onboarding", + } + ) + + return operations[:max_operations] + + @staticmethod + def _extract_signal_source_name(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "未命名" + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) + if repo_match: + return repo_match.group(1)[:80] + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + candidate = quoted_match.group(1).strip(" []【】()()::\"'“”") + if candidate: + return candidate[:80] + for pattern in ( + r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)", + r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[,。,.;;::].*$", "", match.group(1)).strip(" []【】()()::\"'“”") + candidate = re.sub( + r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", + "", + candidate, + flags=re.IGNORECASE, + ) + candidate = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", candidate, flags=re.IGNORECASE).strip() + if candidate: + return candidate[:80] + lower = cleaned.lower() + if "github" in lower: + return "GitHub" + if "飞书" in cleaned or "lark" in lower: + return "Feishu" + if "webhook" in lower: + return "Webhook" + return cleaned[:80] + + @staticmethod + def _build_signal_source_task_description(*, source_name: str, instruction: str) -> str: + return ( + f"为信号源“{source_name}”完成最小接入任务。" + "包含接入配置、权限校验、数据字段确认和回流验证。" + f"原始指令:{instruction[:400]}" + )[:5000] + + @staticmethod + def _build_signal_source_notification_content(*, source_name: str, instruction: str, charter_ready: bool) -> str: + suffix = ( + "已自动生成接入任务,请确认负责人和接入边界。" + if charter_ready + else "当前项目尚未有 aligned charter,系统只先发出确认通知,避免无宪章自动建任务。" + ) + return f"OpenClaw 已识别到新增信号源请求:{source_name}。{suffix} 原始指令:{instruction[:300]}".strip() + + async def _resolve_signal_source_notification_recipient( + self, + *, + tenant_id: int, + owner_id: Optional[int], + ) -> Optional[int]: + if owner_id is not None: + owner = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.id == int(owner_id), + User.is_active.is_(True), + User.status == "active", + ) + ) + ) + ).scalar_one_or_none() + if owner is not None: + return int(owner) + + manager = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.status == "active", + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + return int(manager) if manager is not None else None + + def _commander_operation_catalog(self) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for code in SUPPORTED_COMMANDER_OPERATION_CODES: + row = operation_catalog_service.get_operation(code) + if row is not None: + items.append(row) + return items + + async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]: + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + + async def _load_project_directory(self, *, tenant_id: int, limit: int = 20) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.status != "inactive", + ) + ).order_by(Project.updated_at.desc(), Project.id.desc()).limit(limit) + ) + ).scalars().all() + return [self._serialize_project_directory_item(row) for row in rows] + + async def _build_project_fallback_operations( + self, + *, + tenant_id: int, + project_id: Optional[int], + instruction: str, + max_operations: int, + project_directory: Optional[list[dict[str, Any]]], + ) -> list[dict[str, Any]]: + if max_operations <= 0: + return [] + title = self._extract_project_create_title(instruction) + if title: + payload: dict[str, Any] = {"title": title} + description = self._extract_project_create_description(instruction=instruction, title=title) + if description: + payload["description"] = description + return [ + { + "operation_code": "project.create", + "payload": payload, + "reason": "explicit_project_create", + } + ] + + resolved_project_id = project_id or await self._resolve_project_id_from_instruction( + tenant_id=tenant_id, + instruction=instruction, + project_directory=project_directory, + ) + if resolved_project_id is None: + return [] + + status_operation = self._build_project_status_fallback_operation( + project_id=int(resolved_project_id), + instruction=instruction, + ) + return [status_operation] if status_operation else [] + + async def _resolve_project_id_from_instruction( + self, + *, + tenant_id: int, + instruction: str, + project_directory: Optional[list[dict[str, Any]]], + ) -> Optional[int]: + directory = project_directory or await self._load_project_directory(tenant_id=tenant_id, limit=50) + normalized = re.sub(r"\s+", "", str(instruction or "")).lower() + for item in directory: + title = re.sub(r"\s+", "", str(item.get("title") or "")).lower() + project_id = self._to_int(item.get("project_id")) + if project_id is None or not title or len(title) < 2: + continue + if title in normalized: + return int(project_id) + return None + + @staticmethod + def _normalize_project_title_candidate(candidate: str) -> str: + cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") + if not cleaned: + return "" + normalized = re.sub(r"\s+", "", cleaned).lower() + generic_tokens = { + "项目", + "一个项目", + "新项目", + "项目列表", + "项目列表里面", + "项目库", + "项目台账", + "项目主数据", + "pmflow", + "pmflow项目列表", + } + if normalized in generic_tokens: + return "" + if normalized.startswith(("在项目列表", "到项目列表", "进项目列表", "在pmflow", "到pmflow", "进pmflow")): + return "" + return compact_title(cleaned, max_len=24) + + @staticmethod + def _extract_project_create_title(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned) + if quoted_match: + return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1)) + for pattern in ( + r"(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", + r"(?:创建|新建|建立)项目[::,,\s]*(.+)", + r"项目(?:叫|名为|名称是|名称为)[::,,\s]*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”") + candidate = re.sub(r"^(?:一个|个|新项目)\s*", "", candidate, flags=re.IGNORECASE) + if candidate: + return OpenClawCommanderService._normalize_project_title_candidate(candidate) + return "" + + @staticmethod + def _extract_project_create_description(*, instruction: str, title: str) -> str: + cleaned = re.sub(r"\s+", " ", str(instruction or "")).strip() + if not cleaned: + return "" + if title: + cleaned = cleaned.replace(title, "", 1).strip(" ,,::") + cleaned = re.sub( + r"^(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ,,::") + if not cleaned: + return "" + return cleaned[:4000] + + @staticmethod + def _build_project_status_fallback_operation(*, project_id: int, instruction: str) -> Optional[dict[str, Any]]: + normalized = re.sub(r"\s+", "", str(instruction or "")).lower() + if any(token in normalized for token in ("归档", "archive", "停用项目", "项目归档", "状态改成archived", "状态改为archived")): + return { + "operation_code": "project.archive", + "payload": {"project_id": int(project_id)}, + "reason": "explicit_project_archive", + } + if any( + token in normalized + for token in ( + "重新打开", + "重开", + "恢复active", + "恢复为active", + "恢复项目", + "激活项目", + "设为active", + "设成active", + "改成active", + "改为active", + "状态改成active", + "状态改为active", + "状态设为active", + ) + ): + return { + "operation_code": "project.reopen", + "payload": {"project_id": int(project_id)}, + "reason": "explicit_project_reopen", + } + if any( + token in normalized + for token in ( + "设为done", + "设成done", + "改成done", + "改为done", + "状态改成done", + "状态改为done", + "状态设为done", + "标记完成", + ) + ): + return { + "operation_code": "project.update", + "payload": {"project_id": int(project_id), "status": "done"}, + "reason": "explicit_project_done", + } + return None + + @staticmethod + def _extract_project_reference_from_execution(execution: dict[str, Any]) -> Optional[dict[str, Any]]: + results = execution.get("results") if isinstance(execution, dict) else None + if not isinstance(results, list): + return None + for row in results: + if not isinstance(row, dict): + continue + if str(row.get("entity_type") or "").strip() != "project": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + project_id = payload.get("project_id") or row.get("entity_id") + title = payload.get("title") or payload.get("project_title") or "" + status = payload.get("status") or "" + if project_id is None: + continue + return { + "id": str(project_id), + "title": str(title or "").strip(), + "status": str(status or "").strip(), + } + return None + + async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: + return ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + + async def _load_project_tasks( + self, + *, + tenant_id: int, + project_id: int, + members: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + ) + ).order_by(Task.id.asc()) + ) + ).scalars().all() + + member_name_map = {int(item["user_id"]): str(item.get("name") or "") for item in members if self._to_int(item.get("user_id"))} + active_tasks = [task for task in task_rows if str(task.status or "").strip().lower() not in DONE_TASK_STATUSES] + selected_tasks = active_tasks[:40] if active_tasks else task_rows[:20] + serialized = [ + { + "task_id": int(task.id), + "title": str(task.title or "").strip(), + "status": str(task.status or "").strip().lower(), + "priority": str(task.priority or "").strip().lower(), + "assignee_id": int(task.assignee_id) if task.assignee_id else None, + "assignee_name": member_name_map.get(int(task.assignee_id), "") if task.assignee_id else "", + "required_skill_tag_ids": [int(item) for item in (task.required_skill_tag_ids or []) if self._to_int(item)], + "estimated_hours": float(task.estimated_hours or 0.0), + } + for task in selected_tasks + ] + summary = { + "total": len(task_rows), + "active": len(active_tasks), + "done": len([task for task in task_rows if str(task.status or "").strip().lower() in DONE_TASK_STATUSES]), + "blocked": len([task for task in task_rows if str(task.status or "").strip().lower() == "blocked"]), + "sampled": len(serialized), + } + return serialized, summary + + async def _load_project_team_context( + self, + *, + tenant_id: int, + project_id: int, + owner_id: Optional[int], + ) -> list[dict[str, Any]]: + member_ids_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + ) + member_ids = {int(uid) for uid in member_ids_result.scalars().all()} + if owner_id: + member_ids.add(int(owner_id)) + if not member_ids: + manager_result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).limit(5) + ) + member_ids = {int(uid) for uid in manager_result.scalars().all()} + if not member_ids: + return [] + + users = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id.in_(sorted(member_ids)), + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + ).scalars().all() + if not users: + return [] + + user_ids = [int(user.id) for user in users] + workload_rows = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).scalars().all() + workload_map = {int(item.user_id): item for item in workload_rows} + + skill_rows = ( + await self.db.execute( + select( + UserSkillBinding.user_id, + SkillTag.id, + SkillTag.name, + SkillTag.slug, + UserSkillBinding.proficiency, + ) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id.in_(user_ids), + SkillTag.tenant_id == tenant_id, + ) + ) + ) + ).all() + + skill_map: dict[int, list[dict[str, Any]]] = {} + for uid, sid, name, slug, proficiency in skill_rows: + skill_map.setdefault(int(uid), []).append( + { + "skill_id": int(sid), + "name": str(name or "").strip(), + "slug": str(slug or "").strip(), + "proficiency": int(proficiency or 0), + } + ) + + members: list[dict[str, Any]] = [] + for user in users: + uid = int(user.id) + workload_row = workload_map.get(uid) + member_skills = skill_map.get(uid, []) + members.append( + { + "user_id": uid, + "name": str(user.name or user.username or uid), + "role": str(user.role or ""), + "active": bool(user.is_active and str(user.status or "").strip().lower() == "active"), + "workload_ratio": round( + float(workload_row.workload_ratio if workload_row is not None else float(user.workload or 0.0)), + 2, + ), + "current_assigned_hours": round( + float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, + 2, + ), + "max_available_hours_per_week": round( + float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, + 2, + ), + "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], + "skill_names": [str(item["name"]) for item in member_skills], + } + ) + members.sort(key=lambda item: (float(item.get("workload_ratio") or 0.0), int(item.get("user_id") or 0))) + return members + + @classmethod + def _parse_plan_response(cls, raw_response: str) -> dict[str, Any]: + content = (raw_response or "").strip() + if not content: + raise ValueError("empty llm response") + if content.startswith("```"): + parts = content.split("```") + if len(parts) >= 2: + content = parts[1].strip() + if content.startswith("json"): + content = content[4:].strip() + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + object_text = cls._extract_first_json_object(content) + if not object_text: + raise ValueError("llm json not found") + parsed = json.loads(object_text) + if not isinstance(parsed, dict): + raise ValueError("llm json is not object") + return parsed + + @classmethod + def _extract_first_json_object(cls, text: str) -> Optional[str]: + raw = str(text or "") + start = raw.find("{") + if start < 0: + return None + depth = 0 + in_str = False + escaped = False + for idx in range(start, len(raw)): + ch = raw[idx] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return raw[start : idx + 1] + return None + + def _normalize_operations(self, raw_operations: Any, *, max_operations: int) -> list[dict[str, Any]]: + if not isinstance(raw_operations, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in raw_operations: + if not isinstance(item, dict): + continue + operation_code = str(item.get("operation_code") or "").strip().lower() + payload = item.get("payload") + if not operation_code or not isinstance(payload, dict): + continue + normalized.append( + { + "operation_code": operation_code, + "payload": dict(payload), + "reason": str(item.get("reason") or "").strip()[:300], + } + ) + if len(normalized) >= max_operations: + break + return normalized + + @staticmethod + def _to_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except Exception: + return None + + @staticmethod + def _to_float(value: Any, *, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +class OperationExecutorService: + """对白名单操作做 schema 校验并执行。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + self._project_member_cache: dict[int, set[int]] = {} + + async def execute_operations( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + operations: list[dict[str, Any]], + dry_run: bool, + trace_id: str, + ) -> dict[str, Any]: + seen_idempotency_keys: set[str] = set() + results: list[dict[str, Any]] = [] + applied_count = 0 + skipped_count = 0 + failed_count = 0 + context_project_id = self._project_context_id(project) + + for index, item in enumerate(operations, start=1): + operation_code = str(item.get("operation_code") or "").strip().lower() + raw_payload = item.get("payload") + reason = str(item.get("reason") or "").strip() + persistent_claim: OperationExecutionLedger | None = None + + try: + payload = self._validate_and_normalize_payload(operation_code=operation_code, payload=raw_payload) + idempotency_key = self._build_idempotency_key( + operation_code=operation_code, + payload=payload, + tenant_id=tenant_id, + project_id=context_project_id, + ) + if idempotency_key in seen_idempotency_keys: + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + "status": "skipped", + "message": "duplicate_in_request", + } + ) + continue + seen_idempotency_keys.add(idempotency_key) + + if dry_run: + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + "status": "planned", + "message": reason or "dry_run", + } + ) + continue + + persistent_claim, duplicate_result = await self._claim_persistent_idempotency( + tenant_id=tenant_id, + project_id=context_project_id if context_project_id > 0 else None, + operation_code=operation_code, + payload=payload, + idempotency_key=idempotency_key, + trace_id=trace_id, + ) + if duplicate_result is not None: + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + **duplicate_result, + } + ) + continue + + result = await self._execute_single_operation( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + operation_code=operation_code, + payload=payload, + reason=reason, + trace_id=trace_id, + idempotency_key=idempotency_key, + persistent_claim=persistent_claim, + ) + status = str(result.get("status") or "applied") + if status == "applied": + applied_count += 1 + elif status == "skipped": + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + **result, + } + ) + except Exception as exc: + await self._safe_rollback() + failed_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": raw_payload if isinstance(raw_payload, dict) else {}, + "status": "failed", + "message": str(exc), + } + ) + + return { + "dry_run": bool(dry_run), + "requested_count": len(operations), + "applied_count": applied_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + "results": results, + } + + async def _claim_persistent_idempotency( + self, + *, + tenant_id: int, + project_id: Optional[int], + operation_code: str, + payload: dict[str, Any], + idempotency_key: str, + trace_id: str, + ) -> tuple[Optional[OperationExecutionLedger], Optional[dict[str, Any]]]: + if operation_code not in PERSISTENT_IDEMPOTENCY_OPERATION_CODES: + return None, None + + claim = OperationExecutionLedger( + tenant_id=tenant_id, + operation_code=operation_code, + project_id=int(project_id) if project_id is not None and int(project_id) > 0 else None, + idempotency_key=idempotency_key, + trace_id=trace_id, + status="processing", + payload=dict(payload), + ) + self.db.add(claim) + try: + await self.db.flush() + return claim, None + except IntegrityError: + await self._safe_rollback() + existing = await self._load_execution_claim( + tenant_id=tenant_id, + operation_code=operation_code, + idempotency_key=idempotency_key, + ) + if existing is None: + raise + if str(existing.status or "").strip().lower() == "failed": + await self.db.delete(existing) + await self.db.flush() + return await self._claim_persistent_idempotency( + tenant_id=tenant_id, + project_id=project_id, + operation_code=operation_code, + payload=payload, + idempotency_key=idempotency_key, + trace_id=trace_id, + ) + return ( + None, + { + "status": "skipped", + "message": "duplicate_persistent" + if str(existing.status or "").strip().lower() == "applied" + else "duplicate_in_progress", + "entity_type": str(existing.entity_type or "").strip() or None, + "entity_id": str(existing.entity_id or "").strip() or None, + "trace_id": str(existing.trace_id or "").strip() or None, + }, + ) + + async def _load_execution_claim( + self, + *, + tenant_id: int, + operation_code: str, + idempotency_key: str, + ) -> Optional[OperationExecutionLedger]: + return ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == tenant_id, + OperationExecutionLedger.operation_code == operation_code, + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ) + ) + ).scalar_one_or_none() + + async def _safe_rollback(self) -> None: + try: + await self.db.rollback() + except Exception: + return + + def _validate_and_normalize_payload(self, *, operation_code: str, payload: Any) -> dict[str, Any]: + row = operation_catalog_service.get_operation(operation_code) + if row is None or operation_code not in SUPPORTED_COMMANDER_OPERATION_CODES: + raise ValueError(f"unsupported operation_code: {operation_code}") + if not isinstance(payload, dict): + raise ValueError(f"{operation_code} payload must be object") + + input_schema = row.get("input_schema") if isinstance(row.get("input_schema"), dict) else {} + properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {} + required = [str(item) for item in (input_schema.get("required") or [])] + normalized: dict[str, Any] = {} + + if input_schema.get("additionalProperties") is False: + extra_keys = sorted(set(payload.keys()) - set(properties.keys())) + if extra_keys: + raise ValueError(f"{operation_code} payload has unsupported fields: {', '.join(extra_keys)}") + + for field, spec in properties.items(): + if field not in payload: + continue + normalized[field] = self._coerce_value(field=field, value=payload[field], spec=spec if isinstance(spec, dict) else {}) + + missing = [field for field in required if field not in normalized] + if missing: + raise ValueError(f"{operation_code} missing required fields: {', '.join(missing)}") + return normalized + + @classmethod + def _coerce_value(cls, *, field: str, value: Any, spec: dict[str, Any]) -> Any: + value_type = str(spec.get("type") or "").strip().lower() + if value_type == "integer": + parsed = cls._to_int(value) + if parsed is None: + raise ValueError(f"{field} must be integer") + return parsed + if value_type == "number": + if isinstance(value, bool): + raise ValueError(f"{field} must be number") + try: + parsed_float = float(value) + except Exception as exc: + raise ValueError(f"{field} must be number") from exc + minimum = spec.get("minimum") + maximum = spec.get("maximum") + if minimum is not None and parsed_float < float(minimum): + raise ValueError(f"{field} below minimum") + if maximum is not None and parsed_float > float(maximum): + raise ValueError(f"{field} above maximum") + return parsed_float + if value_type == "string": + text = str(value or "").strip() + if not text: + raise ValueError(f"{field} must be non-empty string") + enum_values = spec.get("enum") + if isinstance(enum_values, list) and enum_values: + enum_map = {str(item).strip().lower(): str(item) for item in enum_values} + matched = enum_map.get(text.lower()) + if matched is None: + raise ValueError(f"{field} must be one of {', '.join(str(item) for item in enum_values)}") + text = matched + min_length = spec.get("minLength") + max_length = spec.get("maxLength") + if min_length is not None and len(text) < int(min_length): + raise ValueError(f"{field} shorter than minLength") + if max_length is not None and len(text) > int(max_length): + raise ValueError(f"{field} longer than maxLength") + return text + if value_type == "array": + if not isinstance(value, list): + raise ValueError(f"{field} must be array") + max_items = spec.get("maxItems") + if max_items is not None and len(value) > int(max_items): + raise ValueError(f"{field} too many items") + item_spec = spec.get("items") if isinstance(spec.get("items"), dict) else {} + return [ + cls._coerce_value(field=field, value=item, spec=item_spec) + for item in value + ] + return value + + async def _execute_single_operation( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + operation_code: str, + payload: dict[str, Any], + reason: str, + trace_id: str, + idempotency_key: str, + persistent_claim: Optional[OperationExecutionLedger], + ) -> dict[str, Any]: + if operation_code == "project.create": + return await self._execute_project_create( + tenant_id=tenant_id, + operator_user=operator_user, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.update": + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.archive": + return await self._execute_project_archive( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.reopen": + return await self._execute_project_reopen( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.assign": + return await self._execute_task_assign( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.create": + return await self._execute_task_create( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.prioritize": + return await self._execute_task_prioritize( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.status_transition": + return await self._execute_task_status_transition( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "notification.create": + return await self._execute_notification_create( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + idempotency_key=idempotency_key, + persistent_claim=persistent_claim, + ) + if operation_code == "personnel.workload_update": + return await self._execute_personnel_workload_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + raise ValueError(f"unsupported operation_code: {operation_code}") + + async def _execute_project_create( + self, + *, + tenant_id: int, + operator_user: User, + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + title = compact_title(str(payload["title"]), max_len=24) + if not title: + raise ValueError("project title required") + existing = await self._load_project_by_title(tenant_id=tenant_id, title=title) + if existing is not None: + return { + "status": "skipped", + "message": "project_already_exists", + "entity_type": "project", + "entity_id": str(existing.id), + "result": { + "project_id": int(existing.id), + "title": str(existing.title or existing.name or "").strip(), + "status": str(existing.status or "").strip(), + }, + } + + owner_id = self._to_int(payload.get("owner_id")) or int(operator_user.id) + if owner_id is not None: + owner = await self._load_active_user(tenant_id=tenant_id, user_id=int(owner_id)) + if owner is None: + owner_id = int(operator_user.id) + + await ensure_project_id_sequence(self.db) + project = Project( + tenant_id=tenant_id, + name=title, + title=title, + description=str(payload.get("description") or "").strip()[:4000] or None, + owner_id=int(owner_id) if owner_id is not None else None, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action="project.create", + before_data=None, + after_data={ + "title": project.title, + "status": project.status, + "owner_id": int(project.owner_id) if project.owner_id is not None else None, + }, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return { + "status": "applied", + "message": f"project created #{int(project.id)}", + "entity_type": "project", + "entity_id": str(project.id), + "result": { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "status": str(project.status or "").strip(), + }, + } + + async def _execute_project_update( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + target_project = await self._load_project_for_operation( + tenant_id=tenant_id, + project=project, + payload_project_id=int(payload["project_id"]), + operation_code="project.update", + ) + before = { + "title": str(target_project.title or target_project.name or "").strip(), + "description": str(target_project.description or "").strip(), + "status": str(target_project.status or "").strip(), + } + changed = False + if "title" in payload: + next_title = compact_title(str(payload.get("title") or ""), max_len=24) + if not next_title: + raise ValueError("project title required") + if next_title != str(target_project.title or target_project.name or "").strip(): + target_project.title = next_title + target_project.name = next_title + changed = True + if "description" in payload: + next_description = str(payload.get("description") or "").strip()[:4000] or None + if next_description != (target_project.description or None): + target_project.description = next_description + changed = True + if "status" in payload: + next_status = self._normalize_project_status(payload.get("status")) + if next_status != str(target_project.status or "").strip().lower(): + target_project.status = next_status + changed = True + if not changed: + return { + "status": "skipped", + "message": "already_in_target_state", + "entity_type": "project", + "entity_id": str(target_project.id), + "result": { + "project_id": int(target_project.id), + "title": str(target_project.title or target_project.name or "").strip(), + "status": str(target_project.status or "").strip(), + }, + } + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(target_project.id), + entity_type="project", + entity_id=str(target_project.id), + action="project.update", + before_data=before, + after_data={ + "title": str(target_project.title or target_project.name or "").strip(), + "description": str(target_project.description or "").strip(), + "status": str(target_project.status or "").strip(), + }, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return { + "status": "applied", + "message": f"project updated #{int(target_project.id)}", + "entity_type": "project", + "entity_id": str(target_project.id), + "result": { + "project_id": int(target_project.id), + "title": str(target_project.title or target_project.name or "").strip(), + "status": str(target_project.status or "").strip(), + }, + } + + async def _execute_project_archive( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload={"project_id": int(payload["project_id"]), "status": "archived"}, + reason=reason, + trace_id=trace_id, + ) + + async def _execute_project_reopen( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload={"project_id": int(payload["project_id"]), "status": "active"}, + reason=reason, + trace_id=trace_id, + ) + + async def _execute_task_create( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.create") + project_id = int(payload["project_id"]) + if project_id != int(project.id): + raise ValueError("task.create project_id mismatch") + + charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) + if charter is None: + raise ValueError("aligned charter required before task.create") + + assignee_id = self._to_int(payload.get("assignee_id")) + touched_user_ids: list[int] = [] + if assignee_id is not None: + assignee = await self._load_active_user(tenant_id=tenant_id, user_id=assignee_id) + if assignee is None: + raise ValueError("assignee not found or inactive") + member_ids = await self._get_project_member_ids( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if int(assignee.id) not in member_ids: + raise ValueError("assignee is not project member") + touched_user_ids.append(int(assignee.id)) + + due_date = self._parse_due_date_value(payload.get("due_date")) + required_skill_tag_ids = self._safe_int_list(payload.get("required_skill_tag_ids")) + task = await persist_task( + self.db, + tenant_id=tenant_id, + truth_node_id=int(charter.id), + project_id=project_id, + draft=build_task_create_draft( + title=str(payload["title"]), + description=str(payload.get("description") or "").strip() or None, + assignee_id=assignee_id, + required_skill_tag_ids=required_skill_tag_ids, + priority=payload.get("priority"), + status="todo", + estimated_hours=self._to_float(payload.get("estimated_hours"), default=0.0) if payload.get("estimated_hours") is not None else None, + due_date=due_date, + ), + actor_user_id=int(operator_user.id), + action="task.create", + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + commit=not touched_user_ids, + refresh_workload=bool(touched_user_ids), + ) + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for user_id in touched_user_ids: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=user_id) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task created #{int(task.id)}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_assign( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.assign") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + assignee = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["assignee_id"])) + if assignee is None: + raise ValueError("assignee not found or inactive") + + member_ids = await self._get_project_member_ids(tenant_id=tenant_id, project_id=int(project.id), owner_id=int(project.owner_id) if project.owner_id else None) + if int(assignee.id) not in member_ids: + raise ValueError("assignee is not project member") + + current_assignee_id = int(task.assignee_id) if task.assignee_id else None + if current_assignee_id == int(assignee.id): + return { + "status": "skipped", + "message": "already_assigned", + "entity_type": "task", + "entity_id": str(task.id), + } + + before = { + "assignee_id": current_assignee_id, + "status": task.status, + } + task.assignee_id = int(assignee.id) + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.assign", + before_data=before, + after_data={"assignee_id": int(assignee.id), "status": task.status}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + + touched_user_ids: list[int] = [] + if current_assignee_id: + touched_user_ids.append(current_assignee_id) + touched_user_ids.append(int(assignee.id)) + for uid in touched_user_ids: + await workload_service.refresh_user_assigned_hours_from_tasks(self.db, tenant_id=tenant_id, user_id=uid) + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for uid in sorted(set(touched_user_ids)): + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task assigned to user#{int(assignee.id)}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_prioritize( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.prioritize") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + before_priority = self._normalize_task_priority(task.priority) + next_priority = self._normalize_task_priority(payload.get("priority")) + if before_priority == next_priority: + return { + "status": "skipped", + "message": "already_in_target_priority", + "entity_type": "task", + "entity_id": str(task.id), + } + + task.priority = next_priority + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.prioritize", + before_data={"priority": before_priority}, + after_data={"priority": next_priority}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if task.assignee_id: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id)) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task priority updated to {next_priority}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_status_transition( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.status_transition") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + status = str(payload["status"]).strip().lower() + if status not in SUPPORTED_TASK_STATUSES: + raise ValueError("unsupported task status") + before_status = str(task.status or "").strip().lower() + if before_status == status: + return { + "status": "skipped", + "message": "already_in_target_status", + "entity_type": "task", + "entity_id": str(task.id), + } + + task.status = status + if status in DONE_TASK_STATUSES: + task.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.status_transition", + before_data={"status": before_status}, + after_data={"status": status}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + + if task.assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + self.db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + else: + await self.db.commit() + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if task.assignee_id: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(task.assignee_id)) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + if status in DONE_TASK_STATUSES and before_status not in DONE_TASK_STATUSES and task.assignee_id: + asyncio.create_task( + process_task_completion( + task_id=int(task.id), + tenant_id=tenant_id, + operator_user_id=int(operator_user.id), + ) + ) + + return { + "status": "applied", + "message": f"task status updated to {status}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_notification_create( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + idempotency_key: str, + persistent_claim: Optional[OperationExecutionLedger], + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="notification.create") + user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) + if user is None: + raise ValueError("notification target user not found or inactive") + + notification = await NotificationService(self.db).create_notification( + tenant_id=tenant_id, + user_id=int(user.id), + category="system", + title=str(payload["title"]).strip(), + content=str(payload["content"]).strip(), + source="openclaw_commander", + entity_type="project", + entity_id=int(project.id), + target_url=f"/projects/{int(project.id)}/board", + commit=False, + ) + if persistent_claim is not None: + persistent_claim.status = "applied" + persistent_claim.entity_type = "notification" + persistent_claim.entity_id = str(notification.id) + persistent_claim.result_payload = { + "status": "applied", + "notification_id": int(notification.id), + "idempotency_key": idempotency_key, + } + persistent_claim.error_message = None + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="notification", + entity_id=str(notification.id), + action="notification.create", + before_data=None, + after_data={ + "user_id": int(user.id), + "title": notification.title, + "source": notification.source, + }, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + + return { + "status": "applied", + "message": f"notification created for user#{int(user.id)}", + "entity_type": "notification", + "entity_id": str(notification.id), + } + + async def _execute_personnel_workload_update( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="personnel.workload_update") + user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) + if user is None: + raise ValueError("target user not found or inactive") + + member_ids = await self._get_project_member_ids( + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if int(user.id) not in member_ids: + raise ValueError("target user is not project member") + + before_workload = await self._load_user_workload(tenant_id=tenant_id, user_id=int(user.id)) + result = await workload_service.update_user_max_weekly_hours( + self.db, + tenant_id=tenant_id, + user_id=int(user.id), + max_weekly_hours=float(payload["max_weekly_hours"]), + ) + if result is None: + raise ValueError("target user workload not found") + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="user_workload", + entity_id=str(user.id), + action="personnel.workload_update", + before_data=before_workload, + after_data=result, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(user.id)) + + return { + "status": "applied", + "message": f"user#{int(user.id)} max weekly hours updated", + "entity_type": "user_workload", + "entity_id": str(user.id), + } + + async def _load_task(self, *, tenant_id: int, task_id: int) -> Optional[Task]: + return ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.id == int(task_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _load_project_by_title(self, *, tenant_id: int, title: str) -> Optional[Project]: + normalized = compact_title(str(title or ""), max_len=24) + if not normalized: + return None + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + or_( + func.lower(Project.title) == normalized.lower(), + func.lower(Project.name) == normalized.lower(), + ), + Project.status != "inactive", + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _load_project_for_operation( + self, + *, + tenant_id: int, + project: Optional[Project | SimpleNamespace], + payload_project_id: int, + operation_code: str, + ) -> Project: + context_project_id = self._project_context_id(project) + if context_project_id > 0 and context_project_id != int(payload_project_id): + raise ValueError(f"{operation_code} project_id mismatch") + row = await self._load_project(tenant_id=tenant_id, project_id=int(payload_project_id)) + if row is None: + raise ValueError("project not found") + return row + + async def _load_active_user(self, *, tenant_id: int, user_id: int) -> Optional[User]: + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == int(user_id), + User.is_active.is_(True), + User.status == "active", + ) + ) + ) + ).scalar_one_or_none() + + async def _load_user_workload(self, *, tenant_id: int, user_id: int) -> Optional[dict[str, Any]]: + row = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return None + return { + "user_id": str(user_id), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + "max_weekly_hours": float(row.max_available_hours_per_week or 40.0), + "workload_ratio": float(row.workload_ratio or 0.0), + } + + async def _get_project_member_ids(self, *, tenant_id: int, project_id: int, owner_id: Optional[int]) -> set[int]: + cached = self._project_member_cache.get(int(project_id)) + if cached is not None: + return set(cached) + rows = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + member_ids = {int(uid) for uid in rows.scalars().all()} + if owner_id: + member_ids.add(int(owner_id)) + self._project_member_cache[int(project_id)] = set(member_ids) + return member_ids + + async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: + return ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + + @staticmethod + def _project_context_id(project: Optional[Project | SimpleNamespace]) -> int: + if project is None: + return 0 + try: + return max(0, int(getattr(project, "id", 0) or 0)) + except Exception: + return 0 + + def _require_project_context( + self, + *, + project: Optional[Project | SimpleNamespace], + operation_code: str, + ) -> Project | SimpleNamespace: + if self._project_context_id(project) <= 0: + raise ValueError(f"{operation_code} requires project context") + if project is None: + raise ValueError(f"{operation_code} requires project context") + return project + + def _build_idempotency_key( + self, + *, + operation_code: str, + payload: dict[str, Any], + tenant_id: int, + project_id: int, + ) -> str: + row = operation_catalog_service.get_operation(operation_code) + fields = [] + if isinstance(row, dict): + fields = [str(item) for item in ((row.get("idempotency_key") or {}).get("fields") or [])] + + parts: dict[str, Any] = {} + for field in fields: + if field == "tenant_id": + parts[field] = int(tenant_id) + elif field == "project_id": + parts[field] = int(project_id) + elif field == "content_hash": + parts[field] = hashlib.sha256(str(payload.get("content") or "").strip().encode("utf-8")).hexdigest()[:16] + else: + parts[field] = payload.get(field) + encoded = json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(f"{operation_code}|{encoded}".encode("utf-8")).hexdigest()[:24] + + def _sync_task_rag(self, *, task: Task, tenant_id: int) -> None: + try: + content = ( + (task.description or "") + + f"\n状态: {task.status}" + + f"\n负责人: {int(task.assignee_id) if task.assignee_id else '未分配'}" + ) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=int(task.project_id), + title=str(task.title or "任务"), + content=content, + ) + ) + except Exception: + return + + @staticmethod + def _to_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except Exception: + return None + + @staticmethod + def _safe_int_list(value: Any) -> list[int]: + if not isinstance(value, list): + return [] + items: list[int] = [] + for raw in value: + try: + items.append(int(raw)) + except Exception: + continue + return items + + @classmethod + def _normalize_task_priority(cls, value: Any) -> str: + text = str(value or "medium").strip().lower() + if text in {"p0", "critical", "high"}: + return "high" + if text in {"p2", "p3", "low"}: + return "low" + return "medium" + + @staticmethod + def _normalize_project_status(value: Any) -> str: + text = str(value or "active").strip().lower() + if text not in SUPPORTED_PROJECT_STATUSES: + raise ValueError("unsupported project status") + return text + + @staticmethod + def _parse_due_date_value(value: Any) -> Optional[datetime]: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise ValueError("invalid due_date") from exc diff --git a/backend/app/services/operation_catalog_service.py b/backend/app/services/operation_catalog_service.py new file mode 100644 index 00000000..ab51847b --- /dev/null +++ b/backend/app/services/operation_catalog_service.py @@ -0,0 +1,89 @@ +"""Operation Catalog 服务。""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Optional + +from ..config.operation_catalog import OPERATION_CATALOG, OperationCatalogEntry + + +REQUIRED_OPERATION_CODES = { + "project.create", + "project.update", + "project.archive", + "project.reopen", + "task.create", + "task.update", + "task.assign", + "task.prioritize", + "task.status_transition", + "task.delete", + "personnel.role_update", + "personnel.workload_update", + "personnel.availability_update", + "knowledge.doc_upsert", + "knowledge.chunk_upsert", + "knowledge.link_upsert", + "knowledge.reindex", + "notification.create", + "notification.ack", + "notification.close", + "file.write_whitelist", +} + + +class OperationCatalogService: + def __init__(self) -> None: + self._catalog: tuple[OperationCatalogEntry, ...] = OPERATION_CATALOG + + def list_operations(self, *, domain: Optional[str] = None) -> list[dict[str, Any]]: + rows = self._catalog + if domain: + domain_key = str(domain).strip().lower() + rows = tuple( + row for row in rows + if str(row.domain).strip().lower() == domain_key + ) + return [row.model_dump() for row in rows] + + def get_operation(self, operation_code: str) -> Optional[dict[str, Any]]: + target = str(operation_code or "").strip().lower() + for row in self._catalog: + if str(row.operation_code).strip().lower() == target: + return row.model_dump() + return None + + def lint_catalog(self) -> dict[str, Any]: + codes = [row.operation_code for row in self._catalog] + domains = [str(row.domain) for row in self._catalog] + code_counter = Counter(codes) + duplicate_codes = sorted([code for code, count in code_counter.items() if count > 1]) + missing_required = sorted(REQUIRED_OPERATION_CODES - set(codes)) + + invalid_entries: list[str] = [] + for row in self._catalog: + if not row.operation_code: + invalid_entries.append("empty_operation_code") + if not row.input_schema: + invalid_entries.append(f"{row.operation_code}:missing_input_schema") + if not row.rbac_policy: + invalid_entries.append(f"{row.operation_code}:missing_rbac_policy") + if not row.idempotency_key.fields: + invalid_entries.append(f"{row.operation_code}:missing_idempotency_fields") + if not row.audit_fields: + invalid_entries.append(f"{row.operation_code}:missing_audit_fields") + + ok = not duplicate_codes and not missing_required and not invalid_entries + return { + "ok": ok, + "operation_count": len(self._catalog), + "domain_count": len(set(domains)), + "domains": sorted(set(domains)), + "duplicate_codes": duplicate_codes, + "missing_required_codes": missing_required, + "invalid_entries": invalid_entries, + } + + +operation_catalog_service = OperationCatalogService() diff --git a/backend/app/services/operation_log_service.py b/backend/app/services/operation_log_service.py new file mode 100644 index 00000000..75819850 --- /dev/null +++ b/backend/app/services/operation_log_service.py @@ -0,0 +1,40 @@ +"""操作日志服务""" + +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import OperationLog + + +async def log_operation( + db: AsyncSession, + *, + tenant_id: int, + user_id: int, + project_id: Optional[int], + entity_type: str, + entity_id: str, + action: str, + before_data: Optional[dict] = None, + after_data: Optional[dict] = None, + remark: Optional[str] = None, +) -> None: + """记录操作日志,失败时不影响主流程。""" + try: + db.add( + OperationLog( + tenant_id=tenant_id, + user_id=user_id, + project_id=project_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + before_data=before_data, + after_data=after_data, + remark=remark, + ) + ) + except Exception: + # 日志失败不阻断主交易 + return diff --git a/backend/app/services/plane.py b/backend/app/services/plane.py new file mode 100644 index 00000000..be9d565f --- /dev/null +++ b/backend/app/services/plane.py @@ -0,0 +1,14 @@ +"""Plane 服务兼容层(最小可运行实现)。""" + +from __future__ import annotations + + +class PlaneService: + async def sync_tasks(self, tasks: list[dict], is_urgent: bool = False) -> dict: + return {"synced_count": len(tasks or []), "urgent": is_urgent} + + async def update_issue_status(self, issue_key: str, status: str) -> dict: + return {"issue_key": issue_key, "status": status} + + +plane_service = PlaneService() diff --git a/backend/app/services/project_knowledge_graph.py b/backend/app/services/project_knowledge_graph.py new file mode 100644 index 00000000..2c6ec012 --- /dev/null +++ b/backend/app/services/project_knowledge_graph.py @@ -0,0 +1,1769 @@ +"""Project Knowledge Graph service. + +第一阶段目标: +1. 构建统一实体/关系/证据写入能力 +2. 支持结构化数据(project/task/signal/user)同步 +3. 支持代码目录扫描快照 +4. 提供图谱概览统计 +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ( + IngestEvent, + IngestOffset, + KGEvidence, + KGEntity, + KGRelation, + KGSyncRun, + Project, + FeishuItem, + Signal, + SymbolDefinition, + SymbolReference, + Task, + User, +) +from .context_cache import context_cache_service +from .symbol_index_service import SymbolIndexService + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class ProjectKnowledgeGraphService: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def upsert_entity( + self, + *, + entity_type: str, + entity_key: str, + display_name: str, + payload: Optional[dict[str, Any]] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + ) -> KGEntity: + row = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.entity_type == str(entity_type).strip()[:40], + KGEntity.entity_key == str(entity_key).strip()[:255], + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = KGEntity( + tenant_id=self.tenant_id, + entity_type=str(entity_type).strip()[:40], + entity_key=str(entity_key).strip()[:255], + display_name=str(display_name or entity_key).strip()[:255], + payload=payload or {}, + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + last_seen_at=_utc_now_naive(), + ) + self.db.add(row) + await self.db.flush() + return row + + row.display_name = str(display_name or row.display_name).strip()[:255] + row.payload = payload or {} + row.source_type = str(source_type or row.source_type or "system").strip()[:32] + row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref + row.last_seen_at = _utc_now_naive() + await self.db.flush() + return row + + async def upsert_relation( + self, + *, + relation_type: str, + subject_entity_id: int, + object_entity_id: int, + weight: float = 1.0, + payload: Optional[dict[str, Any]] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + ) -> KGRelation: + row = ( + await self.db.execute( + select(KGRelation).where( + and_( + KGRelation.tenant_id == self.tenant_id, + KGRelation.relation_type == str(relation_type).strip()[:40], + KGRelation.subject_entity_id == int(subject_entity_id), + KGRelation.object_entity_id == int(object_entity_id), + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = KGRelation( + tenant_id=self.tenant_id, + relation_type=str(relation_type).strip()[:40], + subject_entity_id=int(subject_entity_id), + object_entity_id=int(object_entity_id), + weight=float(weight), + payload=payload or {}, + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + ) + self.db.add(row) + await self.db.flush() + return row + + row.weight = float(weight) + row.payload = payload or {} + row.source_type = str(source_type or row.source_type or "system").strip()[:32] + row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref + await self.db.flush() + return row + + async def add_evidence( + self, + *, + evidence_type: str, + content_excerpt: str, + entity_id: Optional[int] = None, + relation_id: Optional[int] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> KGEvidence: + clean = str(content_excerpt or "").strip() + digest = hashlib.md5(clean.encode("utf-8")).hexdigest() + + row = KGEvidence( + tenant_id=self.tenant_id, + entity_id=int(entity_id) if entity_id is not None else None, + relation_id=int(relation_id) if relation_id is not None else None, + evidence_type=str(evidence_type or "snapshot").strip()[:40], + content_hash=digest, + content_excerpt=clean[:4000] if clean else "(empty)", + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + meta_payload=metadata or {}, + occurred_at=_utc_now_naive(), + ) + self.db.add(row) + await self.db.flush() + return row + + async def enqueue_ingest_event( + self, + *, + channel: str, + event_key: str, + payload: Optional[dict[str, Any]] = None, + status: str = "pending", + ) -> IngestEvent: + row = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == str(channel).strip()[:32], + IngestEvent.event_key == str(event_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = IngestEvent( + tenant_id=self.tenant_id, + channel=str(channel).strip()[:32], + event_key=str(event_key).strip()[:128], + status=str(status or "pending").strip()[:20], + payload=payload or {}, + ) + self.db.add(row) + else: + row.status = str(status or row.status or "pending").strip()[:20] + row.payload = payload or row.payload or {} + row.error_message = None + await self.db.flush() + return row + + async def set_ingest_offset( + self, + *, + channel: str, + offset_key: str, + offset_value: str, + payload: Optional[dict[str, Any]] = None, + ) -> IngestOffset: + row = ( + await self.db.execute( + select(IngestOffset).where( + and_( + IngestOffset.tenant_id == self.tenant_id, + IngestOffset.channel == str(channel).strip()[:32], + IngestOffset.offset_key == str(offset_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = IngestOffset( + tenant_id=self.tenant_id, + channel=str(channel).strip()[:32], + offset_key=str(offset_key).strip()[:128], + offset_value=str(offset_value or "").strip()[:255], + payload=payload or {}, + ) + self.db.add(row) + else: + row.offset_value = str(offset_value or row.offset_value).strip()[:255] + row.payload = payload or row.payload or {} + await self.db.flush() + return row + + async def get_ingest_offset(self, *, channel: str, offset_key: str) -> Optional[str]: + row = ( + await self.db.execute( + select(IngestOffset).where( + and_( + IngestOffset.tenant_id == self.tenant_id, + IngestOffset.channel == str(channel).strip()[:32], + IngestOffset.offset_key == str(offset_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return None + return str(row.offset_value or "").strip() or None + + @staticmethod + def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + try: + dt = datetime.fromisoformat(raw) + return dt.replace(tzinfo=None) if dt.tzinfo else dt + except Exception: + return None + + async def _open_sync_run( + self, + *, + run_type: str, + trigger_source: str, + input_payload: Optional[dict[str, Any]] = None, + ) -> KGSyncRun: + now = _utc_now_naive() + row = KGSyncRun( + tenant_id=self.tenant_id, + run_type=str(run_type or "incremental").strip()[:40], + trigger_source=str(trigger_source or "system").strip()[:40], + status="running", + started_at=now, + input_payload=input_payload or {}, + result_payload={}, + ) + self.db.add(row) + await self.db.flush() + await self.db.commit() + await self.db.refresh(row) + return row + + async def _close_sync_run( + self, + *, + row: Optional[KGSyncRun], + status: str, + result_payload: Optional[dict[str, Any]] = None, + error_message: Optional[str] = None, + started_at_hint: Optional[datetime] = None, + ) -> None: + if row is None: + return + now = _utc_now_naive() + started = started_at_hint or row.started_at or now + row.status = str(status or "unknown").strip()[:20] + row.ended_at = now + row.duration_ms = max(int((now - started).total_seconds() * 1000), 0) + row.result_payload = result_payload or {} + row.error_message = (str(error_message).strip()[:2000] if error_message else None) + await self.db.commit() + + async def list_sync_runs( + self, + *, + limit: int = 50, + run_type: Optional[str] = None, + status: Optional[str] = None, + ) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 200)) + stmt = select(KGSyncRun).where(KGSyncRun.tenant_id == self.tenant_id) + if run_type: + stmt = stmt.where(KGSyncRun.run_type == str(run_type).strip()[:40]) + if status: + stmt = stmt.where(KGSyncRun.status == str(status).strip()[:20]) + rows = ( + await self.db.execute(stmt.order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()).limit(max_rows)) + ).scalars().all() + + items = [] + for row in rows: + items.append( + { + "id": int(row.id), + "run_type": str(row.run_type or ""), + "trigger_source": str(row.trigger_source or ""), + "status": str(row.status or ""), + "started_at": row.started_at.isoformat() if row.started_at else None, + "ended_at": row.ended_at.isoformat() if row.ended_at else None, + "duration_ms": int(row.duration_ms or 0), + "input_payload": row.input_payload or {}, + "result_payload": row.result_payload or {}, + "error_message": str(row.error_message or ""), + } + ) + return { + "tenant_id": self.tenant_id, + "count": len(items), + "items": items, + } + + async def get_sync_slo(self, *, lookback_hours: int = 24) -> dict[str, Any]: + hours = max(1, min(int(lookback_hours), 24 * 30)) + now = _utc_now_naive() + since = now - timedelta(hours=hours) + + runs = ( + await self.db.execute( + select(KGSyncRun).where( + and_( + KGSyncRun.tenant_id == self.tenant_id, + KGSyncRun.started_at >= since, + KGSyncRun.run_type == "incremental", + ) + ) + ) + ).scalars().all() + + total = len(runs) + success = sum(1 for x in runs if str(x.status or "") == "success") + partial = sum(1 for x in runs if str(x.status or "") == "partial") + failed = sum(1 for x in runs if str(x.status or "") == "failed") + avg_duration_ms = ( + int(sum(int(x.duration_ms or 0) for x in runs) / total) + if total > 0 + else 0 + ) + + latest_success = ( + await self.db.execute( + select(KGSyncRun) + .where( + and_( + KGSyncRun.tenant_id == self.tenant_id, + KGSyncRun.run_type == "incremental", + KGSyncRun.status == "success", + ) + ) + .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + dead_letter = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status == "dead_letter", + ) + ) + ) + ).scalar_one() + pending_events = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + ) + ).scalar_one() + + async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: + raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) + dt = self._parse_iso_dt(raw) + if dt is None: + return None + return max(int((now - dt).total_seconds() // 60), 0) + + return { + "tenant_id": self.tenant_id, + "lookback_hours": hours, + "window_started_at": since.isoformat(), + "window_ended_at": now.isoformat(), + "runs_total": total, + "runs_success": success, + "runs_partial": partial, + "runs_failed": failed, + "success_rate": round((success / total), 4) if total > 0 else 0.0, + "avg_duration_ms": avg_duration_ms, + "latest_success_at": latest_success.started_at.isoformat() if latest_success and latest_success.started_at else None, + "dead_letter_events": int(dead_letter or 0), + "pending_events": int(pending_events or 0), + "freshness_minutes": { + "db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), + "feishu_delta": await _freshness_minutes("feishu_delta", "last_feishu_item_updated_at"), + "code_scan": await _freshness_minutes("code_scan", "last_code_scan_at"), + "symbol_scan": await _freshness_minutes("symbol_scan", "last_symbol_scan_at"), + "feature_catalog": await _freshness_minutes("feature_catalog", "last_feature_catalog_sync_at"), + }, + } + + async def sync_structured_delta(self, *, limit: int = 1000) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 5000)) + last_raw = await self.get_ingest_offset(channel="db_delta", offset_key="last_structured_sync_at") + last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) + counters = { + "projects": 0, + "tasks": 0, + "signals": 0, + "users": 0, + "relations": 0, + "evidences": 0, + "from_offset": last_dt.isoformat(), + } + newest_seen = last_dt + + project_rows = ( + await self.db.execute( + select(Project) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.updated_at >= last_dt, + ) + ) + .order_by(Project.updated_at.asc(), Project.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + project_map: dict[int, int] = {} + for p in project_rows: + entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(entity.id) + counters["projects"] += 1 + if p.updated_at and p.updated_at > newest_seen: + newest_seen = p.updated_at + + user_rows = ( + await self.db.execute( + select(User) + .where( + and_( + User.tenant_id == self.tenant_id, + User.updated_at >= last_dt, + ) + ) + .order_by(User.updated_at.asc(), User.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + user_map: dict[int, int] = {} + for u in user_rows: + entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{int(u.id)}", + display_name=str(u.name or f"User#{int(u.id)}"), + payload={ + "user_id": int(u.id), + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{int(u.id)}", + ) + user_map[int(u.id)] = int(entity.id) + counters["users"] += 1 + if u.updated_at and u.updated_at > newest_seen: + newest_seen = u.updated_at + + task_rows = ( + await self.db.execute( + select(Task) + .where( + and_( + Task.tenant_id == self.tenant_id, + Task.updated_at >= last_dt, + ) + ) + .order_by(Task.updated_at.asc(), Task.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + for t in task_rows: + task_entity = await self.upsert_entity( + entity_type="task", + entity_key=f"task:{int(t.id)}", + display_name=str(t.title or f"Task#{int(t.id)}"), + payload={ + "task_id": int(t.id), + "project_id": int(t.project_id) if t.project_id is not None else None, + "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, + "status": str(t.status or "unknown"), + }, + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["tasks"] += 1 + if t.updated_at and t.updated_at > newest_seen: + newest_seen = t.updated_at + + if t.project_id is not None: + target_pid = int(t.project_id) + if target_pid not in project_map: + p = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == target_pid, + ) + ) + ) + ).scalar_one_or_none() + if p is not None: + p_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(p_entity.id) + if target_pid in project_map: + rel = await self.upsert_relation( + relation_type="belongs_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(project_map[target_pid]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"task#{int(t.id)} belongs_to project#{target_pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + if t.assignee_id is not None: + uid = int(t.assignee_id) + if uid not in user_map: + u = ( + await self.db.execute( + select(User).where(and_(User.tenant_id == self.tenant_id, User.id == uid)) + ) + ).scalar_one_or_none() + if u is not None: + u_entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{uid}", + display_name=str(u.name or f"User#{uid}"), + payload={ + "user_id": uid, + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{uid}", + ) + user_map[uid] = int(u_entity.id) + if uid in user_map: + rel = await self.upsert_relation( + relation_type="assigned_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(user_map[uid]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"task#{int(t.id)} assigned_to user#{uid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + signal_rows = ( + await self.db.execute( + select(Signal) + .where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.updated_at >= last_dt, + ) + ) + .order_by(Signal.updated_at.asc(), Signal.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + for s in signal_rows: + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"signal:{int(s.id)}", + display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], + payload={ + "signal_id": int(s.id), + "priority": str(s.priority or ""), + "status": str(s.status or ""), + "matched_project_id": ( + int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + ), + }, + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["signals"] += 1 + if s.updated_at and s.updated_at > newest_seen: + newest_seen = s.updated_at + + pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + if pid is not None: + if pid not in project_map: + p = ( + await self.db.execute( + select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == pid)) + ) + ).scalar_one_or_none() + if p is not None: + p_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{pid}", + display_name=str(p.title or p.name or f"Project#{pid}"), + payload={ + "project_id": pid, + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{pid}", + ) + project_map[pid] = int(p_entity.id) + if pid in project_map: + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_map[pid]), + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"signal#{int(s.id)} matches project#{pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"signals:{int(s.id)}", + metadata={"table": "signals"}, + ) + counters["evidences"] += 1 + + await self.enqueue_ingest_event( + channel="db_delta", + event_key=f"db_delta:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=counters, + status="done", + ) + await self.set_ingest_offset( + channel="db_delta", + offset_key="last_structured_sync_at", + offset_value=newest_seen.isoformat(), + payload={"counters": counters}, + ) + await self.db.commit() + counters["to_offset"] = newest_seen.isoformat() + return counters + + async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 2000)) + last_raw = await self.get_ingest_offset(channel="feishu_delta", offset_key="last_feishu_item_updated_at") + last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) + + rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.updated_at >= last_dt, + ) + ) + .order_by(FeishuItem.updated_at.asc(), FeishuItem.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + enqueued = 0 + newest_seen = last_dt + for item in rows: + event_key = str(item.feishu_message_id or f"feishu_item:{int(item.id)}") + await self.enqueue_ingest_event( + channel="feishu_delta", + event_key=event_key[:128], + payload={ + "feishu_item_id": int(item.id), + "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + "level": str(item.level or ""), + "route_status": str(item.route_status or ""), + }, + status="pending", + ) + enqueued += 1 + if item.updated_at and item.updated_at > newest_seen: + newest_seen = item.updated_at + + await self.set_ingest_offset( + channel="feishu_delta", + offset_key="last_feishu_item_updated_at", + offset_value=newest_seen.isoformat(), + payload={"batch_size": len(rows)}, + ) + await self.db.commit() + return { + "enqueued": enqueued, + "from_offset": last_dt.isoformat(), + "to_offset": newest_seen.isoformat(), + "candidate_count": len(rows), + } + + async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]: + batch = max(1, min(int(max_batch), 1000)) + retries = max(0, min(int(max_retry), 10)) + + rows = ( + await self.db.execute( + select(IngestEvent) + .where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + .order_by(IngestEvent.created_at.asc(), IngestEvent.id.asc()) + .limit(batch) + ) + ).scalars().all() + + stats = {"processed": 0, "done": 0, "retry": 0, "dead_letter": 0} + for ev in rows: + stats["processed"] += 1 + try: + if str(ev.channel) == "feishu_delta": + await self._process_feishu_event(ev) + ev.status = "done" + ev.error_message = None + ev.processed_at = _utc_now_naive() + stats["done"] += 1 + except Exception as exc: + ev.retry_count = int(ev.retry_count or 0) + 1 + ev.error_message = str(exc)[:1000] + if ev.retry_count > retries: + ev.status = "dead_letter" + ev.processed_at = _utc_now_naive() + stats["dead_letter"] += 1 + else: + ev.status = "retry" + stats["retry"] += 1 + + await self.db.commit() + return stats + + async def _process_feishu_event(self, event: IngestEvent) -> None: + payload = event.payload if isinstance(event.payload, dict) else {} + item_id = payload.get("feishu_item_id") + if item_id is None: + return + item = ( + await self.db.execute( + select(FeishuItem).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + if item is None: + return + + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"feishu_item:{int(item.id)}", + display_name=str(item.raw_message or f"FeishuItem#{int(item.id)}")[:255], + payload={ + "feishu_item_id": int(item.id), + "level": str(item.level or ""), + "route_status": str(item.route_status or ""), + "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + }, + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + ) + await self.add_evidence( + evidence_type="feishu_delta", + content_excerpt=str(item.raw_message or "")[:1000] or f"feishu_item#{int(item.id)}", + entity_id=int(signal_entity.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + metadata={"feishu_item_id": int(item.id)}, + ) + + if item.matched_project_id is not None: + project_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(item.matched_project_id)}", + display_name=f"Project#{int(item.matched_project_id)}", + payload={"project_id": int(item.matched_project_id)}, + source_type="db", + source_ref=f"projects:{int(item.matched_project_id)}", + ) + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_entity.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + ) + await self.add_evidence( + evidence_type="feishu_delta", + content_excerpt=f"feishu_item#{int(item.id)} matches project#{int(item.matched_project_id)}", + relation_id=int(rel.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + metadata={"feishu_item_id": int(item.id)}, + ) + + async def sync_codebase_if_due( + self, + *, + base_dir: Optional[str] = None, + interval_hours: int = 6, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="code_scan", offset_key="last_code_scan_at") + last_scan_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_scan_at": last_scan_at.isoformat(), + "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), + } + payload = await self.sync_codebase_snapshot(base_dir=base_dir) + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def sync_symbol_index_if_due( + self, + *, + base_dir: Optional[str] = None, + interval_hours: int = 6, + max_files: int = 500, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="symbol_scan", offset_key="last_symbol_scan_at") + last_scan_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_scan_at": last_scan_at.isoformat(), + "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), + } + + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + payload = await symbol_service.sync_codebase_symbols( + base_dir=base_dir, + max_files=max_files, + force_reindex=False, + ) + symbol_graph = await self._sync_symbol_entities(limit=800) + payload["kg_symbol_sync"] = symbol_graph + await self.enqueue_ingest_event( + channel="symbol_scan", + event_key=f"symbol_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="symbol_scan", + offset_key="last_symbol_scan_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def _sync_symbol_entities(self, *, limit: int = 800) -> dict[str, Any]: + max_rows = max(10, min(int(limit), 3000)) + defs = ( + await self.db.execute( + select(SymbolDefinition) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + if not defs: + return {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} + + counters = {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} + module_entity_map: dict[str, int] = {} + symbol_entity_map: dict[int, int] = {} + name_entity_map: dict[str, int] = {} + qn_entity_map: dict[str, int] = {} + + for row in defs: + qn = str(row.qualified_name or row.symbol_name or f"symbol#{int(row.id)}") + symbol_entity = await self.upsert_entity( + entity_type="symbol", + entity_key=f"symbol:{int(row.id)}", + display_name=qn[:255], + payload={ + "symbol_id": int(row.id), + "symbol_name": str(row.symbol_name or ""), + "qualified_name": str(row.qualified_name or ""), + "symbol_type": str(row.symbol_type or ""), + "language": str(row.language or ""), + "file_path": str(row.file_path or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + }, + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + ) + counters["symbols"] += 1 + symbol_entity_map[int(row.id)] = int(symbol_entity.id) + if str(row.symbol_name or "").strip(): + name_entity_map[str(row.symbol_name).strip()] = int(symbol_entity.id) + if str(row.qualified_name or "").strip(): + qn_entity_map[str(row.qualified_name).strip()] = int(symbol_entity.id) + + file_key = str(row.file_path or "").strip() + if not file_key: + continue + module_entity_id = module_entity_map.get(file_key) + if module_entity_id is None: + module_entity = await self.upsert_entity( + entity_type="module", + entity_key=f"module:{file_key}", + display_name=file_key[:255], + payload={"file_path": file_key}, + source_type="symbol_index", + source_ref=file_key[:255], + ) + module_entity_id = int(module_entity.id) + module_entity_map[file_key] = module_entity_id + counters["modules"] += 1 + rel = await self.upsert_relation( + relation_type="defined_in", + subject_entity_id=int(symbol_entity.id), + object_entity_id=int(module_entity_id), + source_type="symbol_index", + source_ref=file_key[:255], + payload={ + "symbol_id": int(row.id), + "symbol_type": str(row.symbol_type or ""), + "language": str(row.language or ""), + }, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="symbol_index", + content_excerpt=( + f"{str(row.symbol_name or '')} defined_in {file_key}:" + f"{int(row.start_line) if row.start_line is not None else 0}" + ), + relation_id=int(rel.id), + source_type="symbol_index", + source_ref=file_key[:255], + metadata={"symbol_id": int(row.id), "file_path": file_key}, + ) + counters["evidences"] += 1 + + refs = ( + await self.db.execute( + select(SymbolReference) + .where(SymbolReference.tenant_id == self.tenant_id) + .order_by(SymbolReference.updated_at.desc(), SymbolReference.id.desc()) + .limit(max_rows * 3) + ) + ).scalars().all() + for row in refs: + source_entity_id = None + if row.source_symbol_id is not None: + source_entity_id = symbol_entity_map.get(int(row.source_symbol_id)) + if source_entity_id is None: + continue + target_entity_id = None + target_qn = str(row.target_qualified_name or "").strip() + target_name = str(row.target_symbol_name or "").strip() + if target_qn: + target_entity_id = qn_entity_map.get(target_qn) + if target_entity_id is None and target_name: + target_entity_id = name_entity_map.get(target_name) + if target_entity_id is None: + continue + rel_type = str(row.relation_type or "references").strip() + rel = await self.upsert_relation( + relation_type=rel_type[:40], + subject_entity_id=int(source_entity_id), + object_entity_id=int(target_entity_id), + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + payload={ + "symbol_reference_id": int(row.id), + "target_symbol_name": target_name, + "target_qualified_name": target_qn, + }, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="symbol_reference", + content_excerpt=f"{int(source_entity_id)} {rel_type} {target_name or target_qn}", + relation_id=int(rel.id), + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + metadata={ + "symbol_reference_id": int(row.id), + "target_symbol_name": target_name, + "target_qualified_name": target_qn, + }, + ) + counters["evidences"] += 1 + return counters + + async def sync_feature_catalog_if_due( + self, + *, + interval_hours: int = 6, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="feature_catalog", offset_key="last_feature_catalog_sync_at") + last_sync_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_sync_at": last_sync_at.isoformat(), + "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), + } + + payload = await self._sync_feature_catalog_entities(limit=300) + await self.enqueue_ingest_event( + channel="feature_catalog", + event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="feature_catalog", + offset_key="last_feature_catalog_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def sync_feature_catalog_now(self) -> dict[str, Any]: + payload = await self._sync_feature_catalog_entities(limit=300) + await self.enqueue_ingest_event( + channel="feature_catalog", + event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="feature_catalog", + offset_key="last_feature_catalog_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return payload + + async def _sync_feature_catalog_entities(self, *, limit: int = 300) -> dict[str, Any]: + snapshot = await context_cache_service.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + platform = snapshot.get("platform_profile") if isinstance(snapshot.get("platform_profile"), dict) else {} + catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + rows = [row for row in catalog if isinstance(row, dict)][: max(1, min(int(limit), 1000))] + if not rows: + return {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} + + counters = {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} + route_entity_map: dict[str, int] = {} + page_entity_map: dict[str, int] = {} + api_entity_map: dict[str, int] = {} + + for row in rows: + name = str(row.get("name") or "").strip() or "unknown_feature" + route = str(row.get("route") or "").strip() + summary = str(row.get("summary") or "").strip() + page_file = str(row.get("page_file") or "").strip() + page_component = str(row.get("page_component") or "").strip() + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + aliases = row.get("aliases") if isinstance(row.get("aliases"), list) else [] + + feature_slug = (route or name).strip().lower()[:200] + feature_entity = await self.upsert_entity( + entity_type="feature", + entity_key=f"feature:{feature_slug}", + display_name=name[:255], + payload={ + "name": name, + "route": route, + "summary": summary, + "page_file": page_file, + "page_component": page_component, + "apis": [str(x) for x in apis[:20] if str(x).strip()], + "aliases": [str(x) for x in aliases[:20] if str(x).strip()], + }, + source_type="feature_catalog", + source_ref=(route or page_file or name)[:255], + ) + counters["features"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} | route={route or 'n/a'} | page={page_file or 'n/a'}", + entity_id=int(feature_entity.id), + source_type="feature_catalog", + source_ref=(route or page_file or name)[:255], + metadata={"feature_name": name, "route": route, "page_file": page_file}, + ) + counters["evidences"] += 1 + + route_entity_id: Optional[int] = None + if route: + if route not in route_entity_map: + route_entity = await self.upsert_entity( + entity_type="route", + entity_key=f"route:{route[:200]}", + display_name=route[:255], + payload={"route": route}, + source_type="feature_catalog", + source_ref=route[:255], + ) + route_entity_map[route] = int(route_entity.id) + counters["routes"] += 1 + route_entity_id = route_entity_map[route] + rel = await self.upsert_relation( + relation_type="feature_has_route", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(route_entity_id), + source_type="feature_catalog", + source_ref=route[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} has route {route}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=route[:255], + metadata={"feature_name": name, "route": route}, + ) + counters["evidences"] += 1 + + page_entity_id: Optional[int] = None + if page_file: + if page_file not in page_entity_map: + page_entity = await self.upsert_entity( + entity_type="page", + entity_key=f"page:{page_file[:220]}", + display_name=(page_component or page_file)[:255], + payload={"page_file": page_file, "page_component": page_component}, + source_type="feature_catalog", + source_ref=page_file[:255], + ) + page_entity_map[page_file] = int(page_entity.id) + counters["pages"] += 1 + page_entity_id = page_entity_map[page_file] + rel = await self.upsert_relation( + relation_type="feature_rendered_by", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(page_entity_id), + source_type="feature_catalog", + source_ref=page_file[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} rendered_by {page_file}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=page_file[:255], + metadata={"feature_name": name, "page_file": page_file}, + ) + counters["evidences"] += 1 + + if route_entity_id is not None and page_entity_id is not None: + rel = await self.upsert_relation( + relation_type="route_to_page", + subject_entity_id=int(route_entity_id), + object_entity_id=int(page_entity_id), + source_type="feature_catalog", + source_ref=(route or page_file)[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{route} maps_to {page_file}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=(route or page_file)[:255], + metadata={"route": route, "page_file": page_file}, + ) + counters["evidences"] += 1 + + for raw_api in apis[:20]: + api_path = str(raw_api or "").strip() + if not api_path: + continue + if api_path not in api_entity_map: + api_entity = await self.upsert_entity( + entity_type="api_endpoint", + entity_key=f"api:{api_path[:220]}", + display_name=api_path[:255], + payload={"path": api_path}, + source_type="feature_catalog", + source_ref=api_path[:255], + ) + api_entity_map[api_path] = int(api_entity.id) + counters["apis"] += 1 + rel = await self.upsert_relation( + relation_type="feature_calls_api", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(api_entity_map[api_path]), + source_type="feature_catalog", + source_ref=api_path[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} calls {api_path}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=api_path[:255], + metadata={"feature_name": name, "api_path": api_path}, + ) + counters["evidences"] += 1 + + return counters + + async def run_incremental_cycle( + self, + *, + delta_limit: int = 1000, + event_batch: int = 200, + code_scan_hours: int = 6, + feature_scan_hours: int = 6, + code_scan_base_dir: Optional[str] = None, + run_source: str = "system", + ) -> dict[str, Any]: + started = _utc_now_naive() + sync_run = await self._open_sync_run( + run_type="incremental", + trigger_source=run_source, + input_payload={ + "delta_limit": int(delta_limit), + "event_batch": int(event_batch), + "code_scan_hours": int(code_scan_hours), + "feature_scan_hours": int(feature_scan_hours), + "code_scan_base_dir": str(code_scan_base_dir or ""), + }, + ) + run_id = int(sync_run.id) + run_started_at = sync_run.started_at or started + stage_results: dict[str, Any] = {} + stage_errors: dict[str, str] = {} + success_count = 0 + + try: + try: + stage_results["structured_delta"] = await self.sync_structured_delta(limit=delta_limit) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["structured_delta"] = str(exc)[:500] + stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} + + try: + stage_results["feishu_enqueue"] = await self.enqueue_feishu_delta_events(limit=min(delta_limit, 1000)) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["feishu_enqueue"] = str(exc)[:500] + stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} + + try: + stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["event_processing"] = str(exc)[:500] + stage_results["event_processing"] = {"error": stage_errors["event_processing"]} + + try: + stage_results["code_scan"] = await self.sync_codebase_if_due( + base_dir=code_scan_base_dir, + interval_hours=code_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["code_scan"] = str(exc)[:500] + stage_results["code_scan"] = {"error": stage_errors["code_scan"]} + + try: + stage_results["symbol_index"] = await self.sync_symbol_index_if_due( + base_dir=code_scan_base_dir, + interval_hours=code_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["symbol_index"] = str(exc)[:500] + stage_results["symbol_index"] = {"error": stage_errors["symbol_index"]} + + try: + stage_results["feature_catalog"] = await self.sync_feature_catalog_if_due( + interval_hours=feature_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["feature_catalog"] = str(exc)[:500] + stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} + + ended = _utc_now_naive() + if success_count == 6: + final_status = "success" + elif success_count == 0: + final_status = "failed" + else: + final_status = "partial" + + payload = { + "tenant_id": self.tenant_id, + "started_at": started.isoformat(), + "ended_at": ended.isoformat(), + "run_id": run_id, + "run_status": final_status, + "success_stage_count": success_count, + "failed_stage_count": len(stage_errors), + "stage_errors": stage_errors, + **stage_results, + } + await self._close_sync_run( + row=sync_run, + status=final_status, + result_payload=payload, + error_message=("; ".join(f"{k}:{v}" for k, v in stage_errors.items()) if stage_errors else None), + started_at_hint=run_started_at, + ) + return payload + except Exception as exc: + await self.db.rollback() + ended = _utc_now_naive() + payload = { + "tenant_id": self.tenant_id, + "started_at": started.isoformat(), + "ended_at": ended.isoformat(), + "run_id": run_id, + "run_status": "failed", + "success_stage_count": success_count, + "failed_stage_count": max(1, len(stage_errors)), + "stage_errors": stage_errors, + **stage_results, + } + await self._close_sync_run( + row=sync_run, + status="failed", + result_payload=payload, + error_message=str(exc)[:1000], + started_at_hint=run_started_at, + ) + return payload + + async def sync_structured_entities(self, *, limit: int = 1000) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 5000)) + counters = { + "projects": 0, + "tasks": 0, + "signals": 0, + "users": 0, + "relations": 0, + "evidences": 0, + } + + project_map: dict[int, int] = {} + projects = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == self.tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for p in projects: + entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(entity.id) + counters["projects"] += 1 + + user_map: dict[int, int] = {} + users = ( + await self.db.execute( + select(User) + .where(User.tenant_id == self.tenant_id) + .order_by(User.updated_at.desc(), User.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for u in users: + entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{int(u.id)}", + display_name=str(u.name or f"User#{int(u.id)}"), + payload={ + "user_id": int(u.id), + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{int(u.id)}", + ) + user_map[int(u.id)] = int(entity.id) + counters["users"] += 1 + + tasks = ( + await self.db.execute( + select(Task) + .where(Task.tenant_id == self.tenant_id) + .order_by(Task.updated_at.desc(), Task.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for t in tasks: + task_entity = await self.upsert_entity( + entity_type="task", + entity_key=f"task:{int(t.id)}", + display_name=str(t.title or f"Task#{int(t.id)}"), + payload={ + "task_id": int(t.id), + "project_id": int(t.project_id) if t.project_id is not None else None, + "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, + "status": str(t.status or "unknown"), + }, + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["tasks"] += 1 + + if t.project_id is not None and int(t.project_id) in project_map: + rel = await self.upsert_relation( + relation_type="belongs_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(project_map[int(t.project_id)]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"task#{int(t.id)} belongs_to project#{int(t.project_id)}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + if t.assignee_id is not None and int(t.assignee_id) in user_map: + rel = await self.upsert_relation( + relation_type="assigned_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(user_map[int(t.assignee_id)]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"task#{int(t.id)} assigned_to user#{int(t.assignee_id)}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + signals = ( + await self.db.execute( + select(Signal) + .where(Signal.tenant_id == self.tenant_id) + .order_by(Signal.updated_at.desc(), Signal.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for s in signals: + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"signal:{int(s.id)}", + display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], + payload={ + "signal_id": int(s.id), + "priority": str(s.priority or ""), + "status": str(s.status or ""), + "matched_project_id": ( + int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + ), + }, + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["signals"] += 1 + + target_pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + if target_pid is not None and target_pid in project_map: + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_map[target_pid]), + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"signal#{int(s.id)} matches project#{target_pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"signals:{int(s.id)}", + metadata={"table": "signals"}, + ) + counters["evidences"] += 1 + + await self.enqueue_ingest_event( + channel="db_delta", + event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload={"limit": max_rows, "counters": counters}, + status="done", + ) + await self.set_ingest_offset( + channel="db_delta", + offset_key="last_structured_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload={"counters": counters}, + ) + + await self.db.commit() + return counters + + async def sync_codebase_snapshot(self, *, base_dir: Optional[str] = None) -> dict[str, Any]: + root = Path(base_dir or "/app/backend").resolve() + if not root.exists() or not root.is_dir(): + raise ValueError(f"codebase path not found: {root}") + + py_files = [p for p in root.rglob("*.py") if p.is_file()] + route_markers = ("@router.get(", "@router.post(", "@router.put(", "@router.patch(", "@router.delete(") + route_count = 0 + service_file_count = 0 + model_file_count = 0 + + for py_file in py_files: + rel = str(py_file.relative_to(root)) + if rel.startswith("app/services/"): + service_file_count += 1 + if rel.startswith("app/models/"): + model_file_count += 1 + try: + text = py_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + for marker in route_markers: + route_count += text.count(marker) + + payload = { + "root": str(root), + "python_files": len(py_files), + "service_files": service_file_count, + "model_files": model_file_count, + "api_route_markers": route_count, + "scanned_at": _utc_now_naive().isoformat(), + } + entity = await self.upsert_entity( + entity_type="service", + entity_key="service:backend_codebase", + display_name="Backend Codebase Snapshot", + payload=payload, + source_type="code_scan", + source_ref="filesystem", + ) + await self.add_evidence( + evidence_type="code_scan", + content_excerpt=f"python_files={len(py_files)}, routes={route_count}", + entity_id=int(entity.id), + source_type="code_scan", + source_ref=str(root), + metadata=payload, + ) + await self.enqueue_ingest_event( + channel="code_scan", + event_key=f"code_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="code_scan", + offset_key="last_code_scan_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return payload + + async def get_overview(self) -> dict[str, Any]: + entity_total = ( + await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id)) + ).scalar_one() + relation_total = ( + await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) + ).scalar_one() + evidence_total = ( + await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) + ).scalar_one() + pending_events = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + ) + ).scalar_one() + latest_run = ( + await self.db.execute( + select(KGSyncRun) + .where(KGSyncRun.tenant_id == self.tenant_id) + .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + entity_groups = ( + await self.db.execute( + select(KGEntity.entity_type, func.count(KGEntity.id)) + .where(KGEntity.tenant_id == self.tenant_id) + .group_by(KGEntity.entity_type) + ) + ).all() + relation_groups = ( + await self.db.execute( + select(KGRelation.relation_type, func.count(KGRelation.id)) + .where(KGRelation.tenant_id == self.tenant_id) + .group_by(KGRelation.relation_type) + ) + ).all() + + return { + "tenant_id": self.tenant_id, + "entity_total": int(entity_total or 0), + "relation_total": int(relation_total or 0), + "evidence_total": int(evidence_total or 0), + "pending_events": int(pending_events or 0), + "entities_by_type": {str(k): int(v or 0) for k, v in entity_groups}, + "relations_by_type": {str(k): int(v or 0) for k, v in relation_groups}, + "latest_sync_run": ( + { + "id": int(latest_run.id), + "run_type": str(latest_run.run_type or ""), + "trigger_source": str(latest_run.trigger_source or ""), + "status": str(latest_run.status or ""), + "started_at": latest_run.started_at.isoformat() if latest_run.started_at else None, + "ended_at": latest_run.ended_at.isoformat() if latest_run.ended_at else None, + "duration_ms": int(latest_run.duration_ms or 0), + } + if latest_run is not None + else None + ), + "snapshot_at": _utc_now_naive().isoformat(), + } diff --git a/backend/app/services/prompt_profile_service.py b/backend/app/services/prompt_profile_service.py new file mode 100644 index 00000000..73b6f15c --- /dev/null +++ b/backend/app/services/prompt_profile_service.py @@ -0,0 +1,241 @@ +"""AI 角色 Prompt 配置服务。""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import build_default_role_prompt_bundle +from ..models import Integration + +INTEGRATION_TYPE = "ai_prompt_profiles" +ROLE_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,31}$") +MAX_ROLES = 12 + + +def _safe_json(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except Exception: + return {} + return {} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class PromptProfileService: + """管理租户级 AI 角色 Prompt 配置。""" + + async def get_profiles(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + normalized = self._normalize_bundle(bundle) + if normalized != bundle: + integration.config = json.loads(json.dumps(normalized, ensure_ascii=False)) + integration.is_active = True + await db.commit() + return normalized + + async def update_profiles( + self, + db: AsyncSession, + *, + tenant_id: int, + roles: list[dict[str, Any]], + active_role_key: Optional[str] = None, + ) -> dict[str, Any]: + if not roles: + raise ValueError("至少需要保留一个角色") + if len(roles) > MAX_ROLES: + raise ValueError(f"角色数量不能超过 {MAX_ROLES} 个") + + dedup: dict[str, dict[str, Any]] = {} + for idx, raw in enumerate(roles): + key = str(raw.get("role_key") or "").strip().lower() + if not key: + raise ValueError(f"第 {idx + 1} 个角色缺少 role_key") + if not ROLE_KEY_RE.match(key): + raise ValueError(f"角色标识 {key} 格式不正确,仅支持小写字母/数字/_/-") + if key in dedup: + raise ValueError(f"角色标识重复: {key}") + + role_name = str(raw.get("role_name") or "").strip() + system_prompt = str(raw.get("system_prompt") or "").strip() + if not role_name: + raise ValueError(f"角色 {key} 缺少角色名称") + if len(role_name) > 32: + raise ValueError(f"角色 {key} 名称过长(最多 32 字)") + if len(system_prompt) < 20: + raise ValueError(f"角色 {key} 的提示词过短(至少 20 字)") + if len(system_prompt) > 8000: + raise ValueError(f"角色 {key} 的提示词过长(最多 8000 字)") + + role = { + "role_key": key, + "role_name": role_name, + "role_description": str(raw.get("role_description") or "").strip()[:300], + "system_prompt": system_prompt, + "enabled": bool(raw.get("enabled", True)), + "updated_at": str(raw.get("updated_at") or _now_iso()), + } + dedup[key] = role + + ordered_roles = list(dedup.values()) + enabled_keys = [str(r["role_key"]) for r in ordered_roles if bool(r.get("enabled"))] + if not enabled_keys: + raise ValueError("至少需要启用一个角色") + + requested_active = str(active_role_key or "").strip().lower() + if requested_active and requested_active in enabled_keys: + active = requested_active + else: + active = enabled_keys[0] + + bundle = { + "version": 1, + "active_role_key": active, + "roles": ordered_roles, + "updated_at": _now_iso(), + } + + integration, _ = await self._get_or_init_bundle(db, tenant_id=tenant_id) + integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) + integration.is_active = True + await db.commit() + return self._normalize_bundle(bundle) + + async def get_active_role(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + data = await self.get_profiles(db, tenant_id=tenant_id) + active_key = str(data.get("active_role_key") or "") + roles = data.get("roles", []) + if not isinstance(roles, list): + roles = [] + active_role = next((r for r in roles if str(r.get("role_key")) == active_key), None) + if active_role is None and roles: + active_role = roles[0] + return { + "active_role_key": active_key, + "role": active_role, + } + + async def _get_or_init_bundle( + self, + db: AsyncSession, + *, + tenant_id: int, + ) -> tuple[Integration, dict[str, Any]]: + result = await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_id), + Integration.type == INTEGRATION_TYPE, + ) + ) + ) + integration = result.scalar_one_or_none() + + if integration is None: + bundle = self._normalize_bundle(build_default_role_prompt_bundle()) + integration = Integration( + tenant_id=int(tenant_id), + type=INTEGRATION_TYPE, + config=json.loads(json.dumps(bundle, ensure_ascii=False)), + is_active=True, + ) + db.add(integration) + await db.commit() + return integration, bundle + + bundle = self._normalize_bundle(_safe_json(integration.config)) + return integration, bundle + + def _normalize_bundle(self, raw: dict[str, Any]) -> dict[str, Any]: + defaults = build_default_role_prompt_bundle() + default_roles_raw = defaults.get("roles", []) + default_roles: dict[str, dict[str, Any]] = {} + if isinstance(default_roles_raw, list): + for row in default_roles_raw: + if not isinstance(row, dict): + continue + key = str(row.get("role_key") or "").strip().lower() + if key: + default_roles[key] = { + "role_key": key, + "role_name": str(row.get("role_name") or key)[:32], + "role_description": str(row.get("role_description") or "")[:300], + "system_prompt": str(row.get("system_prompt") or "").strip(), + "enabled": bool(row.get("enabled", True)), + "updated_at": str(row.get("updated_at") or _now_iso()), + } + + incoming_roles = raw.get("roles") if isinstance(raw.get("roles"), list) else [] + roles: list[dict[str, Any]] = [] + seen: set[str] = set() + + for item in incoming_roles: + if not isinstance(item, dict): + continue + key = str(item.get("role_key") or "").strip().lower() + if not key or key in seen or not ROLE_KEY_RE.match(key): + continue + seen.add(key) + + fallback = default_roles.get(key, {}) + prompt_text = str(item.get("system_prompt") or fallback.get("system_prompt") or "").strip() + if len(prompt_text) < 20: + prompt_text = str(fallback.get("system_prompt") or "").strip() + if len(prompt_text) < 20: + continue + + roles.append( + { + "role_key": key, + "role_name": str(item.get("role_name") or fallback.get("role_name") or key).strip()[:32] or key, + "role_description": str(item.get("role_description") or fallback.get("role_description") or "").strip()[:300], + "system_prompt": prompt_text[:8000], + "enabled": bool(item.get("enabled", fallback.get("enabled", True))), + "updated_at": str(item.get("updated_at") or _now_iso()), + } + ) + if len(roles) >= MAX_ROLES: + break + + if not roles: + roles = list(default_roles.values())[:MAX_ROLES] + + enabled_keys = [str(r["role_key"]) for r in roles if bool(r.get("enabled"))] + if not enabled_keys and roles: + roles[0]["enabled"] = True + enabled_keys = [str(roles[0]["role_key"])] + + active_raw = str(raw.get("active_role_key") or "").strip().lower() + active_role_key = active_raw if active_raw in enabled_keys else (enabled_keys[0] if enabled_keys else str(roles[0]["role_key"])) + + return { + "version": 1, + "active_role_key": active_role_key, + "roles": roles, + "updated_at": str(raw.get("updated_at") or _now_iso()), + "tips": { + "active_role_key": "当前生效角色,影响 AI 回复语气与任务分派风格。", + "system_prompt": "建议写清角色职责、回复风格、输出边界;支持后续扩展多个角色。", + "role_key": "角色标识用于系统路由,保存后建议保持稳定。", + }, + } + + +prompt_profile_service = PromptProfileService() diff --git a/backend/app/services/recipe_service.py b/backend/app/services/recipe_service.py new file mode 100644 index 00000000..f437ba73 --- /dev/null +++ b/backend/app/services/recipe_service.py @@ -0,0 +1,266 @@ +"""食谱服务 - 包含热量识别与营养分析""" + +import base64 +import io +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Recipe +from ..schemas import RecipeCreate, RecipeUpdate, RecipeResponse +from .ai_service import AIService +from ..config.ai_prompts import PROMPT_ANALYZE_FOOD_CALORIES + + +class RecipeService: + """食谱服务""" + + def __init__(self, ai_service: AIService): + self.ai_service = ai_service + + async def create_recipe( + self, + db: AsyncSession, + recipe_data: RecipeCreate, + tenant_id: int, + ) -> Recipe: + """创建食谱""" + recipe = Recipe( + **recipe_data.model_dump(), + tenant_id=tenant_id, + status="draft", + ) + db.add(recipe) + await db.commit() + await db.refresh(recipe) + return recipe + + async def get_recipe( + self, + db: AsyncSession, + recipe_id: int, + tenant_id: int, + ) -> Optional[Recipe]: + """获取单个食谱""" + result = await db.execute( + select(Recipe).where( + Recipe.id == recipe_id, + Recipe.tenant_id == tenant_id, + ) + ) + return result.scalar_one_or_none() + + async def list_recipes( + self, + db: AsyncSession, + tenant_id: int, + skip: int = 0, + limit: int = 20, + category: Optional[str] = None, + cuisine: Optional[str] = None, + status: Optional[str] = None, + is_favorite: Optional[bool] = None, + ) -> list[Recipe]: + """获取食谱列表""" + query = select(Recipe).where(Recipe.tenant_id == tenant_id) + + if category: + query = query.where(Recipe.category == category) + if cuisine: + query = query.where(Recipe.cuisine == cuisine) + if status: + query = query.where(Recipe.status == status) + if is_favorite is not None: + query = query.where(Recipe.is_favorite == is_favorite) + + query = query.order_by(Recipe.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + + async def update_recipe( + self, + db: AsyncSession, + recipe_id: int, + recipe_data: RecipeUpdate, + tenant_id: int, + ) -> Optional[Recipe]: + """更新食谱""" + recipe = await self.get_recipe(db, recipe_id, tenant_id) + if not recipe: + return None + + update_data = recipe_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(recipe, field, value) + + await db.commit() + await db.refresh(recipe) + return recipe + + async def delete_recipe( + self, + db: AsyncSession, + recipe_id: int, + tenant_id: int, + ) -> bool: + """删除食谱""" + recipe = await self.get_recipe(db, recipe_id, tenant_id) + if not recipe: + return False + + await db.delete(recipe) + await db.commit() + return True + + async def analyze_food_from_description( + self, + db: AsyncSession, + description: str, + tenant_id: int, + ) -> dict: + """ + 基于食物描述分析热量和营养成分 + + 注意:此方法使用纯文本分析。对于图片识别, + 建议先使用图像识别服务将图片转为描述文本,再调用此方法。 + """ + tid = str(tenant_id) + prompt = PROMPT_ANALYZE_FOOD_CALORIES.format(food_description=description) + + try: + response_text = await self.ai_service._invoke_with_retry( + prompt, + db=db, + tenant_id=tid, + function_name="analyze_food_calories", + use_cache=True, + ) + result = self.ai_service._parse_json(response_text) + return { + "success": True, + "data": result, + "message": "分析成功", + } + except Exception as e: + return { + "success": False, + "data": None, + "message": f"分析失败: {str(e)}", + } + + async def create_recipe_with_ai_analysis( + self, + db: AsyncSession, + recipe_data: RecipeCreate, + food_description: str, + tenant_id: int, + ) -> tuple[Recipe, dict]: + """ + 创建食谱并进行 AI 热量分析 + + Args: + db: 数据库会话 + recipe_data: 食谱基础数据 + food_description: 食物描述(可以是图片识别后的描述) + tenant_id: 租户ID + + Returns: + (Recipe, analysis_result): 创建的食谱对象和分析结果 + """ + # 先创建食谱 + recipe = await self.create_recipe(db, recipe_data, tenant_id) + + # 进行 AI 分析 + analysis = await self.analyze_food_from_description( + db, food_description, tenant_id + ) + + if analysis["success"] and analysis["data"]: + # 更新食谱的营养信息 + data = analysis["data"] + recipe.calories = data.get("calories") + recipe.protein = data.get("protein") + recipe.fat = data.get("fat") + recipe.carbs = data.get("carbs") + recipe.fiber = data.get("fiber") + recipe.ai_recognition_confidence = data.get("confidence") + recipe.ai_analysis_result = data + recipe.category = data.get("category") or recipe_data.category + recipe.cuisine = data.get("cuisine") or recipe_data.cuisine + + await db.commit() + await db.refresh(recipe) + + return recipe, analysis + + def decode_base64_image(self, image_base64: str) -> tuple[bytes, str]: + """ + 解码 Base64 图片数据 + + Args: + image_base64: Base64 编码的图片数据(可能包含 data:image/xxx;base64, 前缀) + + Returns: + (image_bytes, mime_type): 图片字节数据和 MIME 类型 + """ + # 移除可能的数据 URI 前缀 + if "," in image_base64: + header, image_base64 = image_base64.split(",", 1) + # 提取 MIME 类型 + mime_type = header.split(";")[0].split(":")[-1] if ":" in header else "image/jpeg" + else: + mime_type = "image/jpeg" + + # 解码 Base64 + image_bytes = base64.b64decode(image_base64) + return image_bytes, mime_type + + async def upload_image( + self, + image_base64: str, + filename: Optional[str] = None, + ) -> str: + """ + 上传图片并返回 URL + + 注意:这是一个示例实现,实际项目中应该集成: + - AWS S3 + - 阿里云 OSS + - 腾讯云 COS + - MinIO + 等对象存储服务 + + Args: + image_base64: Base64 编码的图片数据 + filename: 原始文件名 + + Returns: + 图片 URL + """ + # TODO: 集成实际的文件存储服务 + # 这里只是示例,实际应该上传到对象存储 + image_bytes, mime_type = self.decode_base64_image(image_base64) + + # 示例:生成一个模拟 URL + # 实际应该上传后返回真实的 URL + import hashlib + import time + + file_hash = hashlib.md5(image_bytes).hexdigest() + timestamp = int(time.time()) + mock_url = f"https://example.com/recipe-images/{file_hash}_{timestamp}.jpg" + + return mock_url + + +# 单例 +_recipe_service: Optional[RecipeService] = None + + +def get_recipe_service() -> RecipeService: + """获取食谱服务单例""" + global _recipe_service + if _recipe_service is None: + from .ai_service import get_ai_service + _recipe_service = RecipeService(ai_service=get_ai_service()) + return _recipe_service diff --git a/backend/app/services/scheduler_center.py b/backend/app/services/scheduler_center.py new file mode 100644 index 00000000..ff4ea6bd --- /dev/null +++ b/backend/app/services/scheduler_center.py @@ -0,0 +1,448 @@ +"""轻量级调度中心。""" + +from __future__ import annotations + +import asyncio +import os +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from ..core.logging import get_logger +from ..models import KnowledgeBaseDoc, PortfolioSnapshot, Project +from .adaptive_rag_service import AdaptiveRAGService +from .ai_pmo_cadence_service import AIPMOCadenceService +from .project_knowledge_graph import ProjectKnowledgeGraphService +from . import workload_service +from . import workload_alert_service + +logger = get_logger(__name__) + + +class WorkloadRecalcScheduler: + """每日 00:00 重算 workload_ratio。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: Optional[asyncio.Task] = None + self._stopped = asyncio.Event() + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="workload-recalc-daily") + logger.info("workload_scheduler_started") + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("workload_scheduler_stopped") + + async def run_once(self) -> dict: + """手动执行一次,便于调试与测试。""" + return await self._run_recalc() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + seconds = self._seconds_until_next_midnight() + try: + await asyncio.wait_for(self._stopped.wait(), timeout=seconds) + break + except TimeoutError: + pass + + try: + await self._run_recalc() + except Exception as exc: # pragma: no cover + logger.warning("workload_scheduler_recalc_failed", error=str(exc)) + + @staticmethod + def _seconds_until_next_midnight() -> float: + now = datetime.now() + tomorrow = (now + timedelta(days=1)).date() + next_midnight = datetime.combine(tomorrow, datetime.min.time()) + return max((next_midnight - now).total_seconds(), 1.0) + + async def _run_recalc(self) -> dict: + async with self._session_factory() as db: + tenant_ids = await workload_service.list_tenant_ids(db) + total_users = 0 + total_suggestions = 0 + for tenant_id in tenant_ids or [1]: + total_users += await workload_service.recalculate_all_users_workload_ratio( + db, + tenant_id=tenant_id, + ) + summary = await workload_alert_service.process_tenant_unresolved_overloads( + db, + tenant_id=tenant_id, + ) + total_suggestions += int(summary.get("suggestion_count", 0)) + logger.info( + "workload_scheduler_recalc_done", + tenant_count=len(tenant_ids or [1]), + user_count=total_users, + suggestion_count=total_suggestions, + ) + return { + "tenant_count": len(tenant_ids or [1]), + "user_count": total_users, + "suggestion_count": total_suggestions, + } + + +class KnowledgeUrlRefreshScheduler: + """按周期回抓 URL 知识源(默认每 168 小时)。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: Optional[asyncio.Task] = None + self._stopped = asyncio.Event() + try: + hours = int(os.getenv("KB_URL_REFRESH_HOURS", "168")) + except Exception: + hours = 168 + self._interval_seconds = max(hours, 1) * 3600 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="knowledge-url-refresh") + logger.info("knowledge_url_refresh_scheduler_started", interval_seconds=self._interval_seconds) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("knowledge_url_refresh_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_refresh() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_refresh() + except Exception as exc: # pragma: no cover + logger.warning("knowledge_url_refresh_scheduler_failed", error=str(exc)) + + async def _run_refresh(self) -> dict: + async with self._session_factory() as db: + now = datetime.now() + cutoff = now - timedelta(days=7) + docs = ( + await db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.source_type == "url", + or_( + KnowledgeBaseDoc.last_fetched_at.is_(None), + KnowledgeBaseDoc.last_fetched_at <= cutoff, + ), + ) + ) + ) + ).scalars().all() + + refreshed = 0 + failed = 0 + for doc in docs: + tenant_id = int(getattr(doc, "tenant_id", 1) or 1) + service = AdaptiveRAGService(db, tenant_id=tenant_id) + try: + await service.refresh_url_doc(doc_id=int(doc.id), project_id=None) + refreshed += 1 + except Exception as exc: # pragma: no cover + failed += 1 + logger.warning( + "knowledge_url_refresh_doc_failed", + doc_id=int(doc.id), + tenant_id=tenant_id, + error=str(exc), + ) + + logger.info( + "knowledge_url_refresh_done", + candidate_count=len(docs), + refreshed=refreshed, + failed=failed, + ) + return { + "candidate_count": len(docs), + "refreshed": refreshed, + "failed": failed, + } + + +class KnowledgeGraphIncrementalScheduler: + """按周期执行知识图谱增量学习流水线。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: Optional[asyncio.Task] = None + self._stopped = asyncio.Event() + try: + minutes = int(os.getenv("KG_INCREMENTAL_INTERVAL_MINUTES", "5")) + except Exception: + minutes = 5 + self._interval_seconds = max(minutes, 1) * 60 + + try: + self._delta_limit = max(100, int(os.getenv("KG_INCREMENTAL_DELTA_LIMIT", "1000"))) + except Exception: + self._delta_limit = 1000 + + try: + self._event_batch = max(10, int(os.getenv("KG_INCREMENTAL_EVENT_BATCH", "200"))) + except Exception: + self._event_batch = 200 + + try: + self._code_scan_hours = max(1, int(os.getenv("KG_CODE_SCAN_INTERVAL_HOURS", "6"))) + except Exception: + self._code_scan_hours = 6 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="knowledge-graph-incremental") + logger.info( + "knowledge_graph_incremental_scheduler_started", + interval_seconds=self._interval_seconds, + delta_limit=self._delta_limit, + event_batch=self._event_batch, + code_scan_hours=self._code_scan_hours, + ) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("knowledge_graph_incremental_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_cycle() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_cycle() + except Exception as exc: # pragma: no cover + logger.warning("knowledge_graph_incremental_scheduler_failed", error=str(exc)) + + async def _run_cycle(self) -> dict: + async with self._session_factory() as db: + tenant_ids = await workload_service.list_tenant_ids(db) + effective_tenants = tenant_ids or [1] + tenant_results: list[dict] = [] + for tenant_id in effective_tenants: + service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) + result = await service.run_incremental_cycle( + delta_limit=self._delta_limit, + event_batch=self._event_batch, + code_scan_hours=self._code_scan_hours, + run_source="scheduler", + ) + tenant_results.append(result) + + logger.info( + "knowledge_graph_incremental_scheduler_done", + tenant_count=len(effective_tenants), + results=tenant_results, + ) + return { + "tenant_count": len(effective_tenants), + "results": tenant_results, + } + + +class AIPMOCadenceScheduler: + """AI PMO cadence 自动调度。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: Optional[asyncio.Task] = None + self._stopped = asyncio.Event() + try: + minutes = int(os.getenv("AI_PMO_CADENCE_INTERVAL_MINUTES", "60")) + except Exception: + minutes = 60 + self._interval_seconds = max(minutes, 5) * 60 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="ai-pmo-cadence") + logger.info("ai_pmo_cadence_scheduler_started", interval_seconds=self._interval_seconds) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("ai_pmo_cadence_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_cycle() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_cycle() + except Exception as exc: # pragma: no cover + logger.warning("ai_pmo_cadence_scheduler_failed", error=str(exc)) + + async def _run_cycle(self) -> dict: + async with self._session_factory() as db: + now = datetime.now() + tenant_ids = await workload_service.list_tenant_ids(db) + effective_tenants = tenant_ids or [1] + emitted = 0 + for tenant_id in effective_tenants: + service = AIPMOCadenceService(db, tenant_id=int(tenant_id)) + project_ids = ( + await db.execute( + select(Project.id).where( + and_( + Project.tenant_id == int(tenant_id), + Project.status.in_(["active", "planning", "doing", "todo"]), + ) + ) + ) + ).scalars().all() + + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="risk_review", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(minutes=50), + ): + await service.generate_snapshot(cadence_type="risk_review", project_id=None) + emitted += 1 + + for project_id in project_ids: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="risk_review", + scope_ref=f"project:{int(project_id)}", + freshness=timedelta(minutes=50), + ): + await service.generate_snapshot(cadence_type="risk_review", project_id=int(project_id)) + emitted += 1 + + if 8 <= now.hour <= 10: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="daily_standup", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="daily_standup", project_id=None) + emitted += 1 + for project_id in project_ids: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="daily_standup", + scope_ref=f"project:{int(project_id)}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id)) + emitted += 1 + + if 18 <= now.hour <= 20: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="milestone_check", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="milestone_check", project_id=None) + emitted += 1 + + if now.weekday() == 0 and 8 <= now.hour <= 11: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="weekly_review", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(days=6), + ): + await service.generate_snapshot(cadence_type="weekly_review", project_id=None) + emitted += 1 + + await db.commit() + logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted) + return {"tenant_count": len(effective_tenants), "snapshot_count": emitted} + + @staticmethod + async def _snapshot_due( + *, + db, + tenant_id: int, + scope_type: str, + scope_ref: str, + freshness: timedelta, + ) -> bool: + row = ( + await db.execute( + select(PortfolioSnapshot.created_at).where( + and_( + PortfolioSnapshot.tenant_id == int(tenant_id), + PortfolioSnapshot.scope_type == scope_type, + PortfolioSnapshot.scope_ref == scope_ref[:64], + ) + ).order_by(PortfolioSnapshot.id.desc()).limit(1) + ) + ).scalars().first() + if row is None: + return True + return row <= (datetime.now() - freshness) diff --git a/backend/app/services/signal_capture.py b/backend/app/services/signal_capture.py new file mode 100644 index 00000000..b53e7db5 --- /dev/null +++ b/backend/app/services/signal_capture.py @@ -0,0 +1,2278 @@ +""" +Proactive Signal Capture Service - 主动式信号捕获服务 + +核心功能: +1. 飞书群聊监控 - 自动捕获群内讨论的产品/技术相关内容 +2. 浏览器标签监控 - 检测用户访问的技术文档、竞品页面 +3. GitHub 仓库监控 - 跟踪上游仓库更新、竞品开源项目 +4. HackerNews 自动抓取 - 定时抓取技术前沿信息 + +每个信号都会自动: +- 评估战略重要性 +- 提取关键技术栈 +- 生成任务建议 +- 推送给相关角色 +""" +import asyncio +import inspect +import json +import re +from uuid import uuid4 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import and_, text + +from ..core.logging import get_logger +from ..database import AsyncSessionLocal +from ..models import ( + Signal, Project, ProjectDomain, User, + ProjectSignalLink, Task, TruthNode, + SkillTag, UserProject, UserWorkload, UserSkillBinding, +) +from ..config.ai_prompts import ( + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, + PROMPT_FEISHU_TASK_ORCHESTRATION, +) +from .llm import LLMService +from .notification_service import NotificationService +from . import workload_service +from .adaptive_rag_service import AdaptiveRAGService +from .context_cache import context_cache_service +from .signal_source_orchestration_service import SignalSourceOrchestrationService +from .ai_pmo_kernel_service import AIPMOKernelService +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence + + +EMOJI_UNICODE_RE = re.compile( + r"[\U0001F300-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF" + r"\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF" + r"\U00002600-\U000026FF\U00002700-\U000027BF]+" +) +ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE) +MENTION_TAG_RE = re.compile(r"]*>", re.IGNORECASE) +CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+") +NON_REQUIREMENT_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "okay", + "yes", + "no", + "1", + "2", + "3", + "thanks", + "thx", + "赞", + "辛苦", + "你是谁", + "你是誰", + "你是谁?", + "你是誰?", + "说话", + "在吗", + "在不在", + "今天什么日期", + "今天几号", + "今天星期几", + "现在几点", + "当前日期", +} +NON_REQUIREMENT_CONTAINS = [ + "你是谁", + "什么身份", + "身份确认", + "今天什么日期", + "当前日期", + "日期疑问", + "询问日期", + "现在几点", + "几点了", + "今天周几", + "今天星期几", + "说话", + "在吗", + "在不在", +] +REQUIREMENT_HINTS = [ + "需求", + "优化", + "改一下", + "调整", + "修复", + "bug", + "故障", + "报错", + "项目", + "页面", + "接口", + "功能", + "排版", + "样式", + "任务", + "立项", + "上线", + "配置", +] + +logger = get_logger(__name__) +AI_THINK_STAGE_ZH = { + "capture_start": "开始处理飞书消息", + "capture_skip_empty": "消息为空,忽略", + "capture_skip_noise": "识别为噪音消息,忽略", + "capture_done_ai_pmo": "AI PMO kernel 已完成决策闭环", + "capture_kernel_failed": "AI PMO kernel 执行异常", + "capture_kernel_failed_rollback_failed": "kernel 异常后回滚失败", + "classification_done": "语义分类完成", + "classification_parse_failed": "语义分类结果解析失败", + "capture_skip_not_requirement": "非需求消息,忽略", + "capture_skip_non_requirement": "判定为非需求,忽略", + "signal_created": "信号已创建", + "project_link_done": "项目关联完成", + "project_link_failed": "项目关联失败", + "task_orchestrate_failed": "任务编排失败", + "capture_done": "飞书消息处理完成", + "pipeline_post_orchestrate_failed": "分流后置任务编排失败", + "pipeline_post_update_signal_failed": "分流后置信号结果回写失败", + "pipeline_post_source_onboard_failed": "分流后置信号源接入编排失败", + "task_plan_llm_done": "任务编排 LLM 返回成功", + "task_plan_llm_failed": "任务编排 LLM 调用失败", + "task_created": "任务已创建", + "charter_auto_created": "自动创建宪章节点", + "hn_story_captured": "HackerNews 信号已捕获", + "hn_monitor_error": "HackerNews 监控异常", + "new_project_created_from_signal": "已根据信号自动创建项目", + "draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底", +} + + +def _build_feishu_rag_conversation_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], +) -> str: + chat = str(source_chat_id or "").strip() + if not chat: + return "" + actor = str(source_sender_open_id or "").strip() + if not actor: + actor = str(sender or "").strip().lower() + actor = re.sub(r"\s+", "", actor) + actor = actor[:96] if actor else "unknown" + return f"feishu:{chat}:{actor}" + + +class ProactiveSignalCaptureService: + """主动式信号捕获服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def capture_from_feishu( + self, + group_chat_id: str, + message_content: str, + sender: str, + tenant_id: int = 1, + proposer_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + mentioned_bot_names: Optional[List[str]] = None, + feishu_message_id: Optional[str] = None, + chat_type: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 从飞书群聊捕获信号 + + 触发条件: + - 提到产品、竞品、技术关键词 + - 讨论架构、技术选型 + - 分享技术文章 + """ + trace_id = f"feishu-{uuid4().hex[:12]}" + captured_dt = captured_at or datetime.now(timezone.utc) + if captured_dt.tzinfo is None: + captured_dt = captured_dt.replace(tzinfo=timezone.utc) + + proposer_name = self._normalize_single_line_text(sender or "未知用户", max_len=80) or "未知用户" + proposer_open_id = self._normalize_single_line_text(proposer_open_id or "", max_len=128) or None + self._emit_ai_think( + "capture_start", + trace_id=trace_id, + tenant_id=tenant_id, + group_chat_id=group_chat_id, + sender=proposer_name, + ) + + cleaned_message = self._sanitize_feishu_message(message_content) + if not cleaned_message: + self._emit_ai_think("capture_skip_empty", trace_id=trace_id) + return {"status": "ignored", "reason": "empty_or_emoji_only"} + if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): + self._emit_ai_think( + "capture_skip_noise", + trace_id=trace_id, + message_preview=cleaned_message[:120], + ) + return {"status": "ignored", "reason": "non_requirement_noise"} + + kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) + try: + pipeline_result = await kernel.process_feishu_message( + group_chat_id=group_chat_id, + message_content=cleaned_message, + sender=proposer_name, + source_message_id=feishu_message_id, + source_sender_open_id=proposer_open_id, + captured_at=captured_dt, + chat_type=chat_type, + ) + if not bool(pipeline_result.get("handled")): + self._emit_ai_think( + "capture_kernel_failed", + trace_id=trace_id, + reason=pipeline_result.get("reason"), + error=pipeline_result.get("error"), + ) + return { + "status": "error", + "reason": str(pipeline_result.get("reason") or "kernel_not_handled"), + "classification": "error", + "project_scope": "error", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "direct_reply_required": True, + "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + self._emit_ai_think( + "capture_done_ai_pmo", + trace_id=trace_id, + classification=pipeline_result.get("classification"), + linked_project_ids=pipeline_result.get("linked_project_ids", []), + work_item_id=pipeline_result.get("work_item_id"), + ) + pipeline_result = await self._apply_pipeline_post_actions( + trace_id=trace_id, + tenant_id=tenant_id, + cleaned_message=cleaned_message, + proposer_name=proposer_name, + captured_at=captured_dt, + pipeline_result=pipeline_result, + ) + try: + await self._sync_feishu_recent_cache( + tenant_id=tenant_id, + group_chat_id=group_chat_id, + sender=proposer_name, + sender_open_id=proposer_open_id, + message=cleaned_message, + pipeline_result=pipeline_result, + ) + except Exception as cache_exc: + self._emit_ai_think( + "capture_recent_cache_sync_failed", + trace_id=trace_id, + error=str(cache_exc), + ) + try: + asyncio.create_task( + AdaptiveRAGService.ingest_feishu_message_async( + tenant_id=tenant_id, + content=cleaned_message, + sender=proposer_name, + chat_id=group_chat_id, + message_id=feishu_message_id, + captured_at=captured_dt, + project_id=( + int(pipeline_result["linked_project_ids"][0]) + if isinstance(pipeline_result.get("linked_project_ids"), list) + and pipeline_result.get("linked_project_ids") + else None + ), + ) + ) + except Exception: + pass + return pipeline_result + except Exception as exc: + self._emit_ai_think("capture_kernel_failed", trace_id=trace_id, error=str(exc)) + try: + await self.db.rollback() + except Exception as rollback_exc: + self._emit_ai_think( + "capture_kernel_failed_rollback_failed", + trace_id=trace_id, + error=str(rollback_exc), + ) + return { + "status": "error", + "reason": "kernel_failed", + "classification": "error", + "project_scope": "error", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "direct_reply_required": True, + "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + + projects_context = await self._load_active_projects_context( + tenant_id=tenant_id, + ) + classification = await self._analyze_chat_message( + message=cleaned_message, + projects_context=projects_context, + tenant_id=tenant_id, + ) + self._emit_ai_think( + "classification_done", + trace_id=trace_id, + classification=classification.get("classification"), + confidence=classification.get("confidence"), + matched_project_ids=classification.get("matched_project_ids", []), + reason=classification.get("reason"), + ) + if not classification.get("is_requirement", False): + self._emit_ai_think( + "capture_skip_not_requirement", + trace_id=trace_id, + reason=classification.get("reason", "non_requirement"), + ) + return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} + + project_scope = str(classification.get("classification") or "pending_classification") + if project_scope == "non_requirement": + self._emit_ai_think( + "capture_skip_non_requirement", + trace_id=trace_id, + reason=classification.get("reason", "non_requirement"), + ) + return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} + + # 创建信号 + await ensure_signal_id_sequence(self.db) + strategic_payload = { + "captured_at": captured_dt.isoformat(), + "source_context": { + "group_id": group_chat_id, + "sender": proposer_name, + "sender_open_id": proposer_open_id, + "mentioned_bot_names": [ + self._normalize_single_line_text(n, max_len=80) + for n in (mentioned_bot_names or []) + if self._normalize_single_line_text(n, max_len=80) + ], + }, + "original_message": (message_content or "")[:500], + "cleaned_message": cleaned_message[:500], + "ai_analysis": { + "requirement_project_scope": project_scope, + "reason": classification.get("reason", ""), + "confidence": classification.get("confidence", 0.0), + "matched_project_ids": classification.get("matched_project_ids", []), + "matched_project_titles": classification.get("matched_project_titles", []), + "summary": classification.get("summary", ""), + }, + } + + signal = Signal( + source="Feishu", + content=f"[{proposer_name}]: {self._normalize_single_line_text(cleaned_message, max_len=200)}", + impact_score=float(classification.get("impact_score") or 0.7), + priority=(classification.get("priority") or "medium"), + processed=False, + strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), + tasks_generated=json.dumps([], ensure_ascii=False), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id) + + # 自动关联到相关项目(失败不阻断后续通知) + linked_project_ids: List[int] = [] + try: + if project_scope == "new_project": + new_project_id = await self._auto_create_project_from_new_signal( + signal_id=int(signal.id), + message=cleaned_message, + sender=proposer_name, + tenant_id=tenant_id, + requested_at=captured_dt, + ) + linked_project_ids = [new_project_id] if new_project_id else [] + else: + linked_project_ids = await self._auto_link_to_projects( + signal.id, + classification, + tenant_id, + message_content=cleaned_message, + ) + self._emit_ai_think( + "project_link_done", + trace_id=trace_id, + signal_id=signal.id, + linked_project_ids=linked_project_ids, + ) + except Exception as exc: + self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(exc)) + + auto_tasks: List[Dict[str, Any]] = [] + ai_execution: Dict[str, Any] = { + "trace_id": trace_id, + "status": "skipped", + "reason": "no_linked_project", + "project_id": None, + "summary": "", + "tasks_created": 0, + } + if linked_project_ids: + primary_project_id = int(linked_project_ids[0]) + try: + auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( + trace_id=trace_id, + tenant_id=tenant_id, + project_id=primary_project_id, + signal_id=int(signal.id), + cleaned_message=cleaned_message, + classification=classification, + proposer_name=proposer_name, + captured_at=captured_dt, + ) + except Exception as exc: + self._emit_ai_think( + "task_orchestrate_failed", + trace_id=trace_id, + project_id=primary_project_id, + error=str(exc), + ) + ai_execution = { + "trace_id": trace_id, + "status": "failed", + "reason": str(exc), + "project_id": primary_project_id, + "summary": "", + "tasks_created": 0, + } + + strategic_payload["ai_execution"] = ai_execution + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.processed = bool(linked_project_ids or auto_tasks) + await self.db.commit() + + # 站内通知:右上角消息提醒 + target_url = "/ai-steward" + if linked_project_ids: + target_url = f"/projects/{linked_project_ids[0]}/board" + try: + notification_service = NotificationService(self.db) + await notification_service.notify_signal_created( + tenant_id=tenant_id, + signal_id=int(signal.id), + signal_content=cleaned_message, + source="feishu", + target_url=target_url, + proposer=proposer_name, + occurred_at=captured_dt, + ) + except Exception as exc: + self._emit_ai_think("notify_failed", trace_id=trace_id, error=str(exc)) + + self._emit_ai_think( + "capture_done", + trace_id=trace_id, + signal_id=signal.id, + linked_project_ids=linked_project_ids, + auto_tasks=len(auto_tasks), + ) + return { + "signal_id": str(signal.id), + "status": "captured", + "impact_score": signal.impact_score, + "auto_tasks": auto_tasks, + "project_scope": project_scope, + "classification": project_scope, + "linked_project_ids": linked_project_ids, + "created_project_id": linked_project_ids[0] if project_scope == "new_project" and linked_project_ids else None, + "proposer": proposer_name, + "ai_trace_id": trace_id, + } + + async def _sync_feishu_recent_cache( + self, + *, + tenant_id: int, + group_chat_id: str, + sender: str, + sender_open_id: Optional[str], + message: str, + pipeline_result: Dict[str, Any], + ) -> None: + if not isinstance(pipeline_result, dict): + return + + conversation_key = _build_feishu_rag_conversation_key( + source_chat_id=group_chat_id, + source_sender_open_id=sender_open_id, + sender=sender, + ) + external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) + if external_session_id is None: + return + + answer_text = str(pipeline_result.get("direct_reply_text") or "").strip() + if not answer_text: + answer_text = self._build_recent_cache_ack_text(pipeline_result) + if not answer_text: + return + + recent_messages = await context_cache_service.get_session_messages( + tenant_id=int(tenant_id), + session_id=int(external_session_id), + ) + recent_turns = context_cache_service.messages_to_turns(recent_messages) + if recent_turns: + latest_turn = recent_turns[-1] + latest_question = str(latest_turn.get("question") or "").strip() + latest_answer = str(latest_turn.get("answer") or "").strip() + if latest_question == str(message or "").strip() and latest_answer == answer_text: + return + + await context_cache_service.append_session_messages( + tenant_id=int(tenant_id), + session_id=int(external_session_id), + question=str(message or "")[:3000], + answer=answer_text[:4000], + ) + + @staticmethod + def _build_recent_cache_ack_text(pipeline_result: Dict[str, Any]) -> str: + status = str(pipeline_result.get("status") or "").strip().lower() + if status == "captured": + return "已收到并记录该需求,已进入信号处理队列。" + if status == "ignored": + return "已收到该消息,本次未进入需求处理队列。" + if status == "error": + return str(pipeline_result.get("direct_reply_text") or "系统处理中出现异常,请稍后重试。").strip() + return "已收到该消息。" + + async def _apply_pipeline_post_actions( + self, + *, + trace_id: str, + tenant_id: int, + cleaned_message: str, + proposer_name: str, + captured_at: datetime, + pipeline_result: Dict[str, Any], + ) -> Dict[str, Any]: + if not isinstance(pipeline_result, dict): + return pipeline_result + if str(pipeline_result.get("status") or "").lower() != "captured": + return pipeline_result + + existing_tasks = pipeline_result.get("auto_tasks") + if isinstance(existing_tasks, list) and existing_tasks: + return pipeline_result + + linked_project_ids = pipeline_result.get("linked_project_ids") + if not isinstance(linked_project_ids, list) or not linked_project_ids: + return pipeline_result + + signal_id = self._to_int(pipeline_result.get("signal_id")) + project_id = self._to_int(linked_project_ids[0]) + if signal_id is None or project_id is None: + return pipeline_result + + signal = ( + await self.db.execute( + select(Signal).where( + and_( + Signal.id == signal_id, + Signal.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if signal is None: + return pipeline_result + + if self._is_question_like(cleaned_message): + return pipeline_result + + source_orchestration_result: Optional[Dict[str, Any]] = None + if SignalSourceOrchestrationService.looks_like_signal_source_request(cleaned_message): + try: + source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + initiating_text=cleaned_message, + requested_via="feishu", + operator_user_id=None, + dry_run=False, + ) + except Exception as exc: + self._emit_ai_think( + "pipeline_post_source_onboard_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + else: + orchestration_status = str(source_orchestration_result.get("status") or "").lower() + if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: + auto_tasks = self._build_openclaw_auto_tasks( + source_orchestration_result.get("openclaw"), + ) + try: + strategic_payload: Dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + parsed = json.loads(raw_strategic) + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["signal_source_orchestration"] = source_orchestration_result + strategic_payload["ai_execution"] = source_orchestration_result + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.processed = orchestration_status in {"completed", "partial"} + await self.db.commit() + except Exception as exc: + self._emit_ai_think( + "pipeline_post_update_signal_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + pipeline_result["auto_tasks"] = auto_tasks + pipeline_result["ai_execution"] = source_orchestration_result + pipeline_result["signal_source_orchestration"] = source_orchestration_result + return pipeline_result + + signal_type = str(signal.signal_type or "").strip() + recommended_action = str(signal.recommended_action or "").strip() + should_orchestrate = ( + recommended_action in {"create_charter", "trigger_alert"} + or signal_type == "新需求" + ) + if not should_orchestrate: + return pipeline_result + + classification_payload: Dict[str, Any] = { + "classification": str(pipeline_result.get("classification") or "signal"), + "impact_score": float(signal.impact_score or 0.0), + "priority": str(signal.priority or "medium"), + "confidence": float(signal.confidence or 0.0), + "reason": str(signal.summary or "")[:300], + "matched_project_ids": [project_id], + "matched_project_titles": [], + } + + try: + auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( + trace_id=f"{trace_id}-pipeline", + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + cleaned_message=cleaned_message, + classification=classification_payload, + proposer_name=proposer_name, + captured_at=captured_at, + ) + except Exception as exc: + self._emit_ai_think( + "pipeline_post_orchestrate_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + return pipeline_result + + try: + strategic_payload: Dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + parsed = json.loads(raw_strategic) + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["ai_execution"] = ai_execution + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.processed = bool(signal.processed or auto_tasks) + await self.db.commit() + except Exception as exc: + self._emit_ai_think( + "pipeline_post_update_signal_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + + pipeline_result["auto_tasks"] = auto_tasks + pipeline_result["ai_execution"] = ai_execution + return pipeline_result + + @staticmethod + def _build_openclaw_auto_tasks(openclaw_payload: Any) -> List[Dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + items: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + status = str(row.get("status") or "").strip() + if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: + continue + if status not in {"applied", "planned", "skipped"}: + continue + items.append( + { + "operation_code": operation_code, + "status": status, + "entity_id": row.get("entity_id"), + "task_id": payload.get("task_id"), + "title": payload.get("title"), + "summary": str(row.get("message") or "").strip(), + } + ) + return items + + async def capture_from_browser( + self, + user_id: int, + url: str, + title: str, + content_preview: str, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 从浏览器标签页捕获信号 + + 触发条件: + - 访问技术文档、API 文档 + - 浏览竞品网站、产品页面 + - 查看 GitHub issues/PRs + - 阅读技术博客 + """ + # AI 分析页面价值 + analysis = await self._analyze_webpage( + url, + title, + content_preview, + tenant_id=tenant_id, + ) + + if not analysis['is_valuable']: + return {'status': 'ignored', 'reason': analysis['reason']} + + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='Browser', + content=f"[浏览] {title}: {content_preview[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'url': url, + 'captured_at': datetime.now(timezone.utc).isoformat(), + 'user_id': user_id, + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps([]), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + + # 自动关联到相关项目 + await self._auto_link_to_projects(signal.id, analysis, tenant_id) + + return { + 'signal_id': str(signal.id), + 'status': 'captured', + 'impact_score': signal.impact_score + } + + async def capture_from_github( + self, + repo_url: str, + event_type: str, + event_data: Dict[str, Any], + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 从 GitHub 事件捕获信号 + + 监控事件: + - 竞品仓库更新 + - 上游依赖更新 + - 技术趋势项目发布 + """ + # AI 分析 GitHub 事件 + analysis = await self._analyze_github_event( + repo_url, + event_type, + event_data, + tenant_id=tenant_id, + ) + + if not analysis['is_valuable']: + return {'status': 'ignored', 'reason': analysis['reason']} + + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='GitHub', + content=f"[{event_type}] {repo_url}: {event_data.get('title', event_data.get('message', ''))[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'repo_url': repo_url, + 'event_type': event_type, + 'event_data': event_data, + 'captured_at': datetime.now(timezone.utc).isoformat(), + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps([]), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + + return { + 'signal_id': str(signal.id), + 'status': 'captured', + 'impact_score': signal.impact_score + } + + async def start_hackernews_monitor( + self, + interval_minutes: int = 30, + tenant_id: int = 1 + ): + """ + 启动 HackerNews 定时监控 + + 定期抓取 HackerNews 首页,分析技术趋势 + """ + while True: + try: + # 抓取 HackerNews 首页 + from .fetcher import fetch_hn_stories + # 兼容三种实现: + # 1) async def -> 直接 await + # 2) sync def -> to_thread 防阻塞 + # 3) sync def 返回 coroutine -> 对返回值再 await + if inspect.iscoroutinefunction(fetch_hn_stories): + stories = await fetch_hn_stories(limit=30) + else: + result = await asyncio.to_thread(fetch_hn_stories, 30) + stories = await result if inspect.isawaitable(result) else result + + for story in stories[:10]: # 只分析前10条 + # AI 分析每条故事 + analysis = await self._analyze_hackernews_story( + story, + tenant_id=tenant_id, + ) + + if analysis.get('is_valuable'): + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='HackerNews', + content=f"HN: {story.get('title', '')[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'hn_url': story.get('url'), + 'hn_points': story.get('points'), + 'captured_at': datetime.now(timezone.utc).isoformat(), + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps(analysis.get('suggested_tasks', [])), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + + self._emit_ai_think( + "hn_story_captured", + title=str(story.get("title") or "")[:50], + ) + + # 等待下次抓取 + await asyncio.sleep(interval_minutes * 60) + + except Exception as e: + self._emit_ai_think("hn_monitor_error", error=str(e)) + await asyncio.sleep(interval_minutes * 60) + + async def _load_active_projects_context(self, tenant_id: int) -> List[Dict[str, Any]]: + result = await self.db.execute( + select(Project).where( + Project.status == "active", + Project.tenant_id == tenant_id, + ) + ) + projects = result.scalars().all() + rows: List[Dict[str, Any]] = [] + for project in projects: + rows.append( + { + "id": int(project.id), + "title": (project.title or project.name or "").strip(), + "description": (project.description or "").strip()[:300], + "status": project.status, + } + ) + return rows + + def _emit_ai_think(self, stage: str, **payload: Any) -> None: + safe_payload: Dict[str, Any] = {} + for key, value in payload.items(): + safe_payload[key] = self._safe_log_value(value) + logger.info("signal_capture_ai_think", stage=stage, **safe_payload) + try: + stage_zh = AI_THINK_STAGE_ZH.get(stage, stage) + now_text = self._format_cn_time(datetime.now(timezone.utc)) + print( + f"[{now_text}][AI思考][SignalCapture] {stage_zh} " + + json.dumps({"stage": stage, **safe_payload}, ensure_ascii=False) + ) + except Exception: + pass + + @staticmethod + def _safe_log_value(value: Any, max_len: int = 600) -> Any: + if isinstance(value, (str, int, float, bool)) or value is None: + text = str(value) if value is not None else None + if text is None: + return None + return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" + try: + text = json.dumps(value, ensure_ascii=False, default=str) + except Exception: + text = str(value) + return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" + + async def _auto_orchestrate_project_tasks( + self, + *, + trace_id: str, + tenant_id: int, + project_id: int, + signal_id: int, + cleaned_message: str, + classification: Dict[str, Any], + proposer_name: str, + captured_at: datetime, + ) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.id == project_id, + Project.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + return [], { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_not_found", + "project_id": project_id, + "summary": "", + "tasks_created": 0, + } + + charter_id = await self._ensure_project_charter_node( + tenant_id=tenant_id, + project_id=project_id, + proposer_name=proposer_name, + cleaned_message=cleaned_message, + captured_at=captured_at, + ) + + team_members = await self._load_project_team_context( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if not team_members: + return [], { + "trace_id": trace_id, + "status": "skipped", + "reason": "no_active_members", + "project_id": project_id, + "summary": "", + "tasks_created": 0, + } + + project_payload = { + "id": int(project.id), + "title": (project.title or project.name or "").strip(), + "description": (project.description or "").strip()[:600], + "assigned_role": (project.assigned_role or "").strip(), + } + signal_payload = { + "signal_id": signal_id, + "message": cleaned_message[:1200], + "classification": classification, + "proposer": proposer_name, + "captured_at": captured_at.isoformat(), + } + prompt = PROMPT_FEISHU_TASK_ORCHESTRATION.format( + project_json=json.dumps(project_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + members_json=json.dumps(team_members, ensure_ascii=False), + ) + + llm_status = "success" + llm_error = "" + parsed: Dict[str, Any] = {} + try: + llm_raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_task_orchestration", + ) + parsed = self._parse_llm_json_response(llm_raw) + self._emit_ai_think( + "task_plan_llm_done", + trace_id=trace_id, + project_id=project_id, + llm_summary=parsed.get("overall_summary", ""), + ) + except Exception as exc: + llm_status = "failed" + llm_error = str(exc) + self._emit_ai_think( + "task_plan_llm_failed", + trace_id=trace_id, + project_id=project_id, + error=llm_error, + ) + + tasks_payload = parsed.get("tasks") if isinstance(parsed.get("tasks"), list) else [] + if not tasks_payload: + tasks_payload = self._build_task_orchestration_fallback( + cleaned_message=cleaned_message, + classification=classification, + ) + + created_items: List[Dict[str, Any]] = [] + touched_user_ids: set[int] = set() + created_count = 0 + for idx, raw_task in enumerate(tasks_payload[:8]): + if not isinstance(raw_task, dict): + continue + title = self._normalize_single_line_text( + str(raw_task.get("title") or raw_task.get("task_name") or ""), + max_len=64, + ) + if not title: + continue + description = self._normalize_multiline_text( + str(raw_task.get("description") or cleaned_message), + max_len=800, + ) + priority = self._normalize_priority(raw_task.get("priority")) + estimated_hours = self._normalize_estimated_hours(raw_task.get("estimated_hours")) + required_skill_names = self._normalize_skill_name_list(raw_task.get("required_skill_names")) + required_skill_tag_ids = await self._resolve_skill_tag_ids( + tenant_id=tenant_id, + skill_names=required_skill_names, + ) + ai_recommended_assignee_id = self._to_int(raw_task.get("recommended_assignee_id")) + assignee_id, decision = self._pick_task_assignee( + team_members=team_members, + required_skill_tag_ids=required_skill_tag_ids, + ai_recommended_assignee_id=ai_recommended_assignee_id, + ) + + task = Task( + tenant_id=tenant_id, + truth_node_id=charter_id, + project_id=project_id, + assignee_id=assignee_id, + title=title, + description=description, + required_skill_tag_ids=required_skill_tag_ids, + status="todo", + priority=priority, + estimated_hours=estimated_hours, + ai_recommended_assignees={ + "trace_id": trace_id, + "ai_recommended_assignee_id": ai_recommended_assignee_id, + "final_assignee_id": assignee_id, + "assignee_reason": self._normalize_single_line_text( + str(raw_task.get("assignee_reason") or ""), + max_len=300, + ), + "required_skill_names": required_skill_names, + "decision": decision, + "confidence": float(raw_task.get("confidence") or 0.0), + }, + ) + self.db.add(task) + await self.db.flush() + created_count += 1 + + assignee_name = "" + if assignee_id: + touched_user_ids.add(int(assignee_id)) + match_member = next((m for m in team_members if int(m.get("user_id", 0)) == int(assignee_id)), None) + if isinstance(match_member, dict): + assignee_name = str(match_member.get("name") or "") + + item = { + "task_id": int(task.id), + "task_name": title, + "priority": priority, + "estimated_hours": float(task.estimated_hours or 0), + "required_skill_names": required_skill_names, + "required_skill_tag_ids": required_skill_tag_ids, + "assignee_id": int(assignee_id) if assignee_id else None, + "assignee_name": assignee_name, + "reason": decision.get("picked_reason", ""), + } + created_items.append(item) + self._emit_ai_think( + "task_created", + trace_id=trace_id, + project_id=project_id, + rank=idx + 1, + task_id=task.id, + task_name=title, + assignee_id=item["assignee_id"], + decision=item["reason"], + ) + + await self.db.commit() + for uid in sorted(touched_user_ids): + await workload_service.refresh_user_assigned_hours_from_tasks( + self.db, + tenant_id=tenant_id, + user_id=uid, + ) + + summary = self._normalize_single_line_text( + str(parsed.get("overall_summary") or ""), + max_len=200, + ) + if not summary: + summary = "AI 自动拆解并分配飞书需求任务" + + execution_meta = { + "trace_id": trace_id, + "status": "success" if created_count > 0 else "skipped", + "reason": "ok" if created_count > 0 else "no_valid_tasks", + "project_id": project_id, + "charter_id": charter_id, + "summary": summary, + "tasks_created": created_count, + "llm_status": llm_status, + "llm_error": llm_error[:300] if llm_error else "", + } + return created_items, execution_meta + + async def _ensure_project_charter_node( + self, + *, + tenant_id: int, + project_id: int, + proposer_name: str, + cleaned_message: str, + captured_at: datetime, + ) -> int: + existing = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + TruthNode.node_type == "charter", + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if existing: + return int(existing.id) + + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.id == project_id, + Project.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + raise ValueError("project_not_found_for_charter") + + creator_id = await self._pick_truth_node_creator( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + node = TruthNode( + tenant_id=tenant_id, + node_type="charter", + project_id=project_id, + created_by=creator_id, + title=self._normalize_single_line_text( + f"{project.title or project.name or '项目'} 自动宪章", + max_len=120, + ), + content={ + "rationale": "由飞书信号自动触发的初版执行宪章", + "source": "feishu_auto_orchestration", + "proposer": proposer_name, + "captured_at": captured_at.isoformat(), + "seed_message": cleaned_message[:500], + "success_criteria": ["建立可执行任务链", "明确负责人", "控制团队负载"], + "constraints": ["后续可由管理员更新细化"], + }, + version=1, + status="active", + ) + self.db.add(node) + await self.db.flush() + self._emit_ai_think( + "charter_auto_created", + project_id=project_id, + charter_id=node.id, + created_by=creator_id, + ) + return int(node.id) + + async def _pick_truth_node_creator( + self, + *, + tenant_id: int, + project_id: int, + owner_id: Optional[int], + ) -> int: + if owner_id: + owner = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.id == owner_id, + User.status == "active", + ) + ).limit(1) + ) + ).scalars().first() + if owner: + return int(owner) + + project_member = ( + await self.db.execute( + select(UserProject.user_id) + .join( + User, + and_( + User.id == UserProject.user_id, + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ), + ) + .where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + .order_by(UserProject.user_id.asc()) + .limit(1) + ) + ).scalars().first() + if project_member: + return int(project_member) + + admin_user = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalars().first() + if admin_user: + return int(admin_user) + + any_active = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalars().first() + if any_active: + return int(any_active) + raise ValueError("no_active_user_for_charter_creator") + + async def _load_project_team_context( + self, + *, + tenant_id: int, + project_id: int, + owner_id: Optional[int], + ) -> List[Dict[str, Any]]: + member_ids_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + ) + member_ids = {int(uid) for uid in member_ids_result.scalars().all()} + if owner_id: + member_ids.add(int(owner_id)) + if not member_ids: + manager_result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).limit(5) + ) + member_ids = {int(uid) for uid in manager_result.scalars().all()} + if not member_ids: + return [] + + users = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id.in_(sorted(member_ids)), + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + ).scalars().all() + if not users: + return [] + + user_ids = [int(u.id) for u in users] + workload_rows = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).scalars().all() + workload_map = {int(item.user_id): item for item in workload_rows} + + skill_rows = ( + await self.db.execute( + select( + UserSkillBinding.user_id, + SkillTag.id, + SkillTag.name, + SkillTag.slug, + UserSkillBinding.proficiency, + ) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id.in_(user_ids), + SkillTag.tenant_id == tenant_id, + ) + ) + ) + ).all() + + skill_map: Dict[int, List[Dict[str, Any]]] = {} + for uid, sid, name, slug, proficiency in skill_rows: + skill_map.setdefault(int(uid), []).append( + { + "skill_id": int(sid), + "name": str(name or "").strip(), + "slug": str(slug or "").strip(), + "proficiency": int(proficiency or 0), + } + ) + + members: List[Dict[str, Any]] = [] + for user in users: + uid = int(user.id) + workload_row = workload_map.get(uid) + member_skills = skill_map.get(uid, []) + members.append( + { + "user_id": uid, + "name": str(user.name or user.username or uid), + "role": str(user.role or ""), + "workload_ratio": round( + float( + workload_row.workload_ratio + if workload_row is not None + else float(user.workload or 0.0) + ), + 2, + ), + "current_assigned_hours": round( + float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, + 2, + ), + "max_available_hours_per_week": round( + float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, + 2, + ), + "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], + "skill_names": [str(item["name"]) for item in member_skills], + "skills": member_skills, + } + ) + members.sort(key=lambda x: (float(x.get("workload_ratio") or 0.0), int(x.get("user_id") or 0))) + return members + + async def _resolve_skill_tag_ids( + self, + *, + tenant_id: int, + skill_names: List[str], + ) -> List[int]: + if not skill_names: + return [] + result = await self.db.execute( + select(SkillTag).where(SkillTag.tenant_id == tenant_id) + ) + tags = result.scalars().all() + if not tags: + return [] + + exact: Dict[str, int] = {} + fuzzy: List[tuple[str, int]] = [] + for tag in tags: + name = str(tag.name or "").strip().lower() + slug = str(tag.slug or "").strip().lower() + if name: + exact[name] = int(tag.id) + fuzzy.append((name, int(tag.id))) + if slug: + exact[slug] = int(tag.id) + fuzzy.append((slug, int(tag.id))) + + resolved_ids: List[int] = [] + for raw in skill_names: + key = str(raw or "").strip().lower() + if not key: + continue + if key in exact: + resolved_ids.append(exact[key]) + continue + matched = None + for candidate, sid in fuzzy: + if key in candidate or candidate in key: + matched = sid + break + if matched: + resolved_ids.append(int(matched)) + return sorted(set(resolved_ids)) + + def _pick_task_assignee( + self, + *, + team_members: List[Dict[str, Any]], + required_skill_tag_ids: List[int], + ai_recommended_assignee_id: Optional[int], + ) -> tuple[Optional[int], Dict[str, Any]]: + if not team_members: + return None, {"picked_reason": "no_team_members", "candidate_scores": []} + + candidates: List[Dict[str, Any]] = [] + for member in team_members: + uid = self._to_int(member.get("user_id")) + if not uid: + continue + skill_ids = {int(v) for v in (member.get("skill_tag_ids") or []) if self._to_int(v)} + workload_ratio = float(member.get("workload_ratio") or 0.0) + if required_skill_tag_ids: + matched = sum(1 for sid in required_skill_tag_ids if sid in skill_ids) + skill_score = matched / len(required_skill_tag_ids) + else: + skill_score = 0.4 + workload_score = max(0.0, 140.0 - workload_ratio) / 140.0 + ai_bonus = 0.15 if ai_recommended_assignee_id and uid == int(ai_recommended_assignee_id) else 0.0 + overload_penalty = 0.2 if workload_ratio >= 150 else 0.0 + total_score = (skill_score * 0.7) + (workload_score * 0.3) + ai_bonus - overload_penalty + candidates.append( + { + "user_id": uid, + "name": member.get("name"), + "skill_score": round(skill_score, 4), + "workload_ratio": round(workload_ratio, 2), + "total_score": round(total_score, 4), + } + ) + + candidates.sort(key=lambda x: (-x["total_score"], x["workload_ratio"], x["user_id"])) + if not candidates: + return None, {"picked_reason": "no_valid_candidate", "candidate_scores": []} + + top = candidates[0] + if float(top.get("workload_ratio") or 0.0) > 160 and float(top.get("skill_score") or 0.0) < 0.5: + return None, { + "picked_reason": "all_candidates_overloaded", + "candidate_scores": candidates[:3], + } + picked_uid = int(top["user_id"]) + return picked_uid, { + "picked_reason": ( + f"score={top['total_score']},skill={top['skill_score']}," + f"workload={top['workload_ratio']}" + ), + "candidate_scores": candidates[:3], + } + + def _build_task_orchestration_fallback( + self, + *, + cleaned_message: str, + classification: Dict[str, Any], + ) -> List[Dict[str, Any]]: + summary = self._normalize_single_line_text( + str(classification.get("summary") or cleaned_message), + max_len=120, + ) + return [ + { + "title": "需求澄清与范围确认", + "description": f"基于飞书信号补全需求边界、验收口径与优先级。上下文:{summary}", + "priority": "high", + "estimated_hours": 4, + "required_skill_names": ["产品管理", "项目管理"], + "recommended_assignee_id": None, + "assignee_reason": "fallback_without_llm", + "confidence": 0.0, + }, + { + "title": "核心方案落地", + "description": f"围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:{summary}", + "priority": "medium", + "estimated_hours": 12, + "required_skill_names": ["FastAPI", "React", "LangChain"], + "recommended_assignee_id": None, + "assignee_reason": "fallback_without_llm", + "confidence": 0.0, + }, + ] + + @staticmethod + def _normalize_priority(value: Any) -> str: + priority = str(value or "medium").strip().lower() + if priority in {"p0", "critical", "high"}: + return "high" + if priority in {"p2", "low"}: + return "low" + return "medium" + + @staticmethod + def _normalize_estimated_hours(value: Any) -> Optional[float]: + try: + hours = float(value) + except Exception: + return 8.0 + if hours <= 0: + return 1.0 + return round(min(hours, 80.0), 2) + + def _normalize_skill_name_list(self, value: Any) -> List[str]: + if isinstance(value, list): + names = [self._normalize_single_line_text(str(item or ""), max_len=60) for item in value] + return [name for name in names if name] + if isinstance(value, str): + split = re.split(r"[,,/|]", value) + names = [self._normalize_single_line_text(part, max_len=60) for part in split] + return [name for name in names if name] + return [] + + @staticmethod + def _to_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except Exception: + return None + + @staticmethod + def _parse_llm_json_response(raw_response: str) -> Dict[str, Any]: + content = (raw_response or "").strip() + if not content: + raise ValueError("empty llm response") + if content.startswith("```"): + parts = content.split("```") + if len(parts) >= 2: + content = parts[1].strip() + if content.startswith("json"): + content = content[4:].strip() + try: + parsed = json.loads(content.strip()) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + + # 兼容模型返回“解释文本 + JSON”场景:抽取首个 JSON 对象再解析 + obj_text = ProactiveSignalCaptureService._extract_first_json_object(content) + if not obj_text: + raise ValueError("empty llm response") + parsed = json.loads(obj_text) + if isinstance(parsed, dict): + return parsed + raise ValueError("llm json is not object") + + @staticmethod + def _extract_first_json_object(text: str) -> Optional[str]: + raw = str(text or "") + start = raw.find("{") + if start < 0: + return None + depth = 0 + in_str = False + escaped = False + for i in range(start, len(raw)): + ch = raw[i] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return raw[start : i + 1] + return None + + async def _analyze_chat_message( + self, + *, + message: str, + projects_context: List[Dict[str, Any]], + tenant_id: int, + ) -> Dict[str, Any]: + """AI 语义分类:existing/new/pending/non_requirement。""" + prompt = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION.format( + projects_json=json.dumps(projects_context, ensure_ascii=False), + message=message[:800], + ) + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_project_signal_classification", + ) + try: + parsed = self._parse_llm_json_response(response) + except Exception as parse_exc: + self._emit_ai_think( + "classification_parse_failed", + error=str(parse_exc), + response_preview=(response or "")[:500], + ) + raise + classification = str(parsed.get("classification") or "pending_classification").lower() + if classification not in { + "existing_project", + "new_project", + "pending_classification", + "non_requirement", + }: + classification = "pending_classification" + + matched_project_ids: List[int] = [] + for item in parsed.get("matched_project_ids", []) or []: + try: + matched_project_ids.append(int(item)) + except Exception: + continue + + matched_titles = [str(x) for x in (parsed.get("matched_project_titles") or []) if str(x).strip()] + if classification == "existing_project" and not matched_project_ids: + classification = "pending_classification" + # 对“明确新建项目意图”进行升级,避免模型过度保守地给 pending。 + if classification == "pending_classification" and self._has_explicit_new_project_intent(message): + classification = "new_project" + raw_is_requirement = parsed.get("is_requirement") + if isinstance(raw_is_requirement, bool): + is_requirement = raw_is_requirement + elif isinstance(raw_is_requirement, str): + is_requirement = raw_is_requirement.strip().lower() in {"1", "true", "yes", "y"} + else: + is_requirement = classification != "non_requirement" + if classification in {"existing_project", "new_project", "pending_classification"}: + is_requirement = True + if classification == "non_requirement": + is_requirement = False + return { + "is_requirement": is_requirement, + "reason": str(parsed.get("reason", "") or "AI 判定"), + "confidence": float(parsed.get("confidence") or 0.0), + "classification": classification, + "matched_project_ids": matched_project_ids, + "matched_project_titles": matched_titles, + "impact_score": float(parsed.get("impact_score") or 0.7), + "priority": str(parsed.get("priority", "medium") or "medium"), + "summary": str(parsed.get("summary") or "")[:200], + } + except Exception as exc: + self._emit_ai_think("classification_failed", error=str(exc), message_preview=message[:120]) + fallback_new_project = self._has_explicit_new_project_intent(message) + return { + "is_requirement": bool(fallback_new_project), + "reason": "AI 分析失败,启用保守策略", + "confidence": 0.0, + "classification": "new_project" if fallback_new_project else "non_requirement", + "matched_project_ids": [], + "matched_project_titles": [], + "impact_score": 0.6, + "priority": "medium", + "summary": "", + } + + @staticmethod + def _sanitize_feishu_message(message: str) -> str: + text = ProactiveSignalCaptureService._normalize_single_line_text(message or "", max_len=2000) + if not text: + return "" + + text = MENTION_TAG_RE.sub(" ", text) + text = text.replace("@所有人", " ").replace("@everyone", " ") + text = re.sub(r"@[_a-zA-Z0-9\-]+", " ", text) + text = EMOJI_UNICODE_RE.sub(" ", text) + text = re.sub(r"\s+", " ", text).strip() + + if not text or ONLY_PUNCT_OR_SPACE_RE.match(text): + return "" + return text + + @staticmethod + def _normalize_single_line_text(text: str, *, max_len: int = 500) -> str: + content = str(text or "") + if not content: + return "" + content = content.replace("\ufffd", "") + content = CONTROL_CHAR_RE.sub(" ", content) + content = re.sub(r"\s+", " ", content).strip() + if max_len > 0: + content = content[:max_len] + return content + + @staticmethod + def _normalize_multiline_text(text: str, *, max_len: int = 2000) -> str: + content = str(text or "") + if not content: + return "" + content = content.replace("\ufffd", "") + content = content.replace("\r\n", "\n").replace("\r", "\n") + content = CONTROL_CHAR_RE.sub(" ", content) + content = re.sub(r"[ \t]+", " ", content) + content = re.sub(r"\n{3,}", "\n\n", content).strip() + if max_len > 0: + content = content[:max_len] + return content + + @staticmethod + def _format_cn_time(dt: datetime) -> str: + safe_dt = dt + if safe_dt.tzinfo is None: + safe_dt = safe_dt.replace(tzinfo=timezone.utc) + cn_tz = timezone(timedelta(hours=8)) + return safe_dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _is_non_requirement_message(message: str) -> bool: + content = (message or "").strip().lower() + if not content: + return True + normalized = re.sub(r"\s+", "", content) + if content in NON_REQUIREMENT_EXACT: + return True + if normalized in NON_REQUIREMENT_EXACT: + return True + if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any( + hint in normalized for hint in REQUIREMENT_HINTS + ): + return True + if len(content) <= 2 and content.isdigit(): + return True + non_requirement_keywords = [ + "收到", + "辛苦了", + "赞", + "哈哈", + "good", + "nice", + "ok", + ] + if len(content) <= 8 and any(k in content for k in non_requirement_keywords): + return True + if ONLY_PUNCT_OR_SPACE_RE.match(content): + return True + return False + + @staticmethod + def _has_explicit_new_project_intent(message: str) -> bool: + content = (message or "").strip().lower() + if not content: + return False + explicit_phrases = [ + "新建项目", + "新增项目", + "新开项目", + "独立立项", + "重新立项", + "单独立项", + "开个新项目", + "做个新项目", + "从0到1", + ] + if any(p in content for p in explicit_phrases): + return True + # 兼容常见中文表达:“新增个需求,关于 xxx 的项目” + if ("新增" in content or "新增个需求" in content) and "项目" in content: + return True + return False + + @staticmethod + def _is_question_like(message: str) -> bool: + text = str(message or "").strip().lower() + if not text: + return False + if "?" in text or "?" in text: + return True + hints = ("吗", "怎么", "如何", "为什么", "谁", "多少", "是否", "有没有", "几号", "几点", "周几") + return any(k in text for k in hints) + + def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: + _ = message + return None + + async def _analyze_webpage(self, url: str, title: str, content: str, tenant_id: int) -> Dict[str, Any]: + """AI 分析网页价值""" + prompt = f"""分析以下网页内容是否有价值: + +标题:{title} +URL: {url} +内容摘要:{content[:500]} + +请判断: +1. 是否是技术文档、竞品信息、行业趋势? +2. 是否值得团队关注? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_valuable": boolean, + "reason": "原因", + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是有价值内容,is_valuable 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _analyze_github_event(self, repo_url: str, event_type: str, event_data: Dict, tenant_id: int) -> Dict[str, Any]: + """AI 分析 GitHub 事件""" + description = f"{event_type}: {event_data.get('title', event_data.get('message', ''))}" + + prompt = f"""分析以下 GitHub 事件是否有价值: + +仓库:{repo_url} +事件:{description} + +请判断: +1. 是否是技术栈更新、安全修复、重要功能? +2. 是否影响我们的项目? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_valuable": boolean, + "reason": "原因", + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是有价值内容,is_valuable 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _analyze_hackernews_story(self, story: Dict, tenant_id: int) -> Dict[str, Any]: + """AI 分析 HackerNews 故事""" + prompt = f"""分析以下 HackerNews 故事是否是重要技术趋势: + +标题:{story.get('title', '')} +链接:{story.get('url', '')} +得分:{story.get('points', 0)} + +请判断: +1. 是否是行业趋势、技术突破? +2. 是否值得我们关注? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_strategic": boolean, + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是战略性内容,is_strategic 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + result = json.loads(response.strip()) + # 重命名字段以匹配其他方法 + return { + 'is_valuable': result.get('is_strategic', False), + 'reason': result.get('reason', ''), + 'impact_score': result.get('impact_score', 0.5), + 'priority': result.get('priority', 'low'), + 'analysis': result.get('analysis', {}), + 'suggested_tasks': result.get('suggested_tasks', []) + } + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _auto_link_to_projects( + self, + signal_id: int, + analysis: Dict[str, Any], + tenant_id: int, + message_content: Optional[str] = None, + ) -> List[int]: + """自动关联信号到相关项目""" + _ = message_content + classification = str(analysis.get("classification") or "").lower() + if classification in {"new_project", "pending_classification", "non_requirement"}: + return [] + + if classification == "existing_project": + matched_ids: List[int] = [] + for pid in analysis.get("matched_project_ids", []) or []: + try: + matched_ids.append(int(pid)) + except Exception: + continue + matched_ids = sorted(set(matched_ids)) + if not matched_ids: + return [] + + result = await self.db.execute( + select(Project.id).where( + and_( + Project.id.in_(matched_ids), + Project.status == "active", + Project.tenant_id == tenant_id, + ) + ) + ) + valid_ids = {int(v) for v in result.scalars().all()} + if not valid_ids: + return [] + confidence = float(analysis.get("confidence") or 0.0) + relevance = max(0.5, min(0.98, 0.55 + confidence * 0.4)) + link_type = "ai_existing_project_signal" + else: + result = await self.db.execute( + select(Project.id).where( + and_(Project.status == "active", Project.tenant_id == tenant_id) + ) + ) + valid_ids = {int(v) for v in result.scalars().all()} + if not valid_ids: + return [] + relevance = 0.7 + link_type = "auto_captured" + + linked_project_ids: List[int] = [] + + for project_id in sorted(valid_ids): + existing = await self.db.execute( + text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), + {"project_id": project_id, "signal_id": signal_id}, + ) + if existing.first() is None: + from ..models import ProjectSignalLink + link = ProjectSignalLink( + project_id=project_id, + signal_id=signal_id, + link_type=link_type, + relevance_score=relevance, + tenant_id=tenant_id, + ) + self.db.add(link) + linked_project_ids.append(project_id) + + await self.db.commit() + return linked_project_ids + + async def _auto_create_project_from_new_signal( + self, + *, + signal_id: int, + message: str, + sender: str, + tenant_id: int, + requested_at: Optional[datetime] = None, + ) -> Optional[int]: + """当分类为 new_project 时,自动创建项目并关联当前信号。""" + requested_dt = requested_at or datetime.now(timezone.utc) + if requested_dt.tzinfo is None: + requested_dt = requested_dt.replace(tzinfo=timezone.utc) + project_brief = await self._draft_new_project_brief( + message=message, + tenant_id=tenant_id, + sender=sender, + requested_at=requested_dt, + ) + title = self._normalize_single_line_text(project_brief.get("title") or "", max_len=120) + if not title: + title = self._normalize_single_line_text(message or "飞书新需求立项", max_len=40) or "飞书新需求立项" + raw_desc = self._normalize_multiline_text(project_brief.get("description") or message or "", max_len=1200) + requested_at_text = self._format_cn_time(requested_dt) + description = self._normalize_multiline_text( + f"需求提出人:{sender}\n" + f"提出时间:{requested_at_text}\n" + f"需求来源:飞书群聊\n\n" + f"需求说明:{raw_desc or self._normalize_multiline_text(message, max_len=500)}", + max_len=1800, + ) + assigned_role = str(project_brief.get("primary_role") or "ai_engineer").strip()[:50] or "ai_engineer" + + owner_id: Optional[int] = None + owner_result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + owner_id = owner_result.scalar_one_or_none() + + await ensure_project_id_sequence(self.db) + project = Project( + name=title[:80] or "新项目", + title=title, + description=description, + owner_id=owner_id, + status="active", + assigned_role=assigned_role, + signal_count=1, + tenant_id=tenant_id, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + + existing = await self.db.execute( + text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), + {"project_id": int(project.id), "signal_id": int(signal_id)}, + ) + if existing.first() is None: + link = ProjectSignalLink( + project_id=int(project.id), + signal_id=int(signal_id), + link_type="ai_new_project_signal", + relevance_score=0.98, + tenant_id=tenant_id, + ) + self.db.add(link) + + await self.db.execute( + text("UPDATE signals SET processed = :processed WHERE id = :signal_id"), + {"processed": True, "signal_id": int(signal_id)}, + ) + await self.db.commit() + + self._emit_ai_think( + "new_project_created_from_signal", + signal_id=signal_id, + project_id=int(project.id), + sender=sender, + ) + return int(project.id) + + async def _draft_new_project_brief( + self, + *, + message: str, + tenant_id: int, + sender: str, + requested_at: datetime, + ) -> Dict[str, Any]: + requested_at_text = self._format_cn_time(requested_at) + prompt = f"""你是 PMFlow 的立项助手。基于下面需求消息,提炼新项目基础信息。 + +需求提出人:{sender} +提出时间:{requested_at_text} + +需求消息: +{message[:1000]} + +输出 JSON: +{{ + "title": "项目标题(15-30字)", + "description": "项目简介(80-200字)", + "primary_role": "ai_engineer|backend|frontend|devops" +}} + +只输出 JSON,不要 markdown,不要解释,不要乱码。""" + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_new_project_brief", + ) + parsed = self._parse_llm_json_response(raw) + role = str(parsed.get("primary_role") or "ai_engineer").strip().lower() + if role not in {"ai_engineer", "backend", "frontend", "devops"}: + role = "ai_engineer" + return { + "title": self._normalize_single_line_text(str(parsed.get("title") or ""), max_len=120), + "description": self._normalize_multiline_text(str(parsed.get("description") or ""), max_len=1200), + "primary_role": role, + } + except Exception as exc: + self._emit_ai_think("draft_new_project_brief_failed", error=str(exc)) + return { + "title": self._normalize_single_line_text(message or "飞书新需求立项", max_len=40), + "description": self._normalize_multiline_text(message, max_len=300), + "primary_role": "ai_engineer", + } + + +# 全局服务实例 +_signal_capture_service: Optional[ProactiveSignalCaptureService] = None + + +def get_signal_capture_service() -> ProactiveSignalCaptureService: + """获取信号捕获服务实例(需要在 async 上下文中使用)""" + return ProactiveSignalCaptureService(None) # Placeholder diff --git a/backend/app/services/signal_source_orchestration_service.py b/backend/app/services/signal_source_orchestration_service.py new file mode 100644 index 00000000..5b926d2d --- /dev/null +++ b/backend/app/services/signal_source_orchestration_service.py @@ -0,0 +1,1927 @@ +"""信号源接入编排服务。""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +import time +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import ( + PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, + PROMPT_SIGNAL_SOURCE_ONBOARDING, +) +from ..models import ( + AIWorkerExecution, + Project, + Signal, + SignalSourceRegistry, + User, +) +from ..database import AsyncSessionLocal +from .ai_orchestration_run_service import AIOrchestrationRunService +from .prompt_profile_service import prompt_profile_service +from .llm import llm_service + +SOURCE_ONBOARDING_ACTION_RE = re.compile( + r"(新增|增加|添加|接入|配置|注册|建立).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)", + re.IGNORECASE, +) +SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("feishu", ("飞书", "lark", "群聊", "chat")), + ("github", ("github", "repo", "仓库", "pull request", "issue")), + ("webhook", ("webhook", "回调", "回传")), + ("url", ("url", "链接", "网页", "站点", "rss")), + ("browser", ("浏览器", "标签页", "页面")), + ("pmflow", ("pmflow", "内置ai", "ai管家", "steward")), +) +DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach") +MAX_SOURCE_NAME_LEN = 120 +MAX_SOURCE_IDENTIFIER_LEN = 180 +WORKER_MAX_CONCURRENCY = 3 +COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0 + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class SignalSourceOrchestrationService: + """把“新增信号源”转换为 OpenClaw 指挥与下级 AI worker 执行链。""" + + session_factory = AsyncSessionLocal + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + @classmethod + def looks_like_signal_source_request(cls, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")) + if len(normalized) < 6: + return False + if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")): + return True + return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized)) + + async def orchestrate_from_signal( + self, + *, + tenant_id: int, + project_id: int, + signal_id: Optional[int], + initiating_text: str, + requested_via: str, + operator_user_id: Optional[int] = None, + dry_run: bool = False, + response_mode: str = "fast", + ) -> dict[str, Any]: + response_mode = "complete" if str(response_mode or "").strip().lower() == "complete" else "fast" + trace_id = f"source-onboard-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}" + project = await self._load_project(tenant_id=tenant_id, project_id=project_id) + signal = await self._load_signal(tenant_id=tenant_id, signal_id=signal_id) if signal_id is not None else None + run_service = AIOrchestrationRunService(self.db) if self.db is not None else None + if project is None: + return { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_or_signal_not_found", + } + if signal is None: + if dry_run: + signal = self._build_ephemeral_signal(initiating_text=initiating_text) + else: + return { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_or_signal_not_found", + } + project_ref = SimpleNamespace( + id=int(project.id), + title=str(project.title or "").strip(), + name=str(project.name or "").strip(), + description=str(project.description or "").strip(), + owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, + ) + signal_ref = SimpleNamespace( + id=int(signal.id) if getattr(signal, "id", None) is not None else None, + title=str(signal.title or "").strip(), + summary=str(signal.summary or "").strip(), + signal_type=str(signal.signal_type or "").strip(), + recommended_action=str(signal.recommended_action or "").strip(), + content=str(signal.content or "").strip(), + ) + real_signal_id = signal_ref.id + persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None + + if not self.looks_like_signal_source_request(initiating_text): + result = { + "trace_id": trace_id, + "status": "skipped", + "run_status": "completed", + "response_mode": response_mode, + "planner_status": "skipped", + "planner_diagnostics": None, + "summary": "当前输入不属于信号源接入请求。", + "pending_workers": [], + "worker_executions": [], + "openclaw": {"status": "skipped", "reason": "not_signal_source_request"}, + "reason": "not_signal_source_request", + } + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status="skipped", + openclaw_status="skipped", + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=None, + result_payload=dict(result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return result + + plan, planner_status, planner_diagnostics = await self._plan_onboarding( + tenant_id=tenant_id, + project=project_ref, + signal=signal_ref, + initiating_text=initiating_text, + requested_via=requested_via, + response_mode=response_mode, + ) + if not bool(plan.get("should_onboard")): + result = { + "trace_id": trace_id, + "status": "skipped", + "run_status": "completed", + "response_mode": response_mode, + "reason": "planner_rejected", + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "summary": str(plan.get("summary") or "").strip(), + "pending_workers": [], + "worker_executions": [], + "openclaw": {"status": "skipped", "reason": "planner_rejected"}, + } + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status=planner_status, + openclaw_status="skipped", + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return result + + source_payload = plan.get("source") if isinstance(plan.get("source"), dict) else {} + worker_role_keys = self._normalize_worker_roles(plan.get("worker_roles")) + registry_result = await self._create_or_reuse_registry( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + operator_user_id=operator_user_id, + requested_via=requested_via, + source_payload=source_payload, + initiating_text=initiating_text, + dry_run=dry_run, + ) + + operator_user = await self._resolve_operator_user( + tenant_id=tenant_id, + project=project_ref, + operator_user_id=operator_user_id, + ) + operator_ref = self._build_user_snapshot(operator_user) + openclaw_result = await self._dispatch_openclaw( + tenant_id=tenant_id, + operator_user=operator_ref, + project=project_ref, + instruction=str(plan.get("openclaw_instruction") or "").strip(), + dry_run=dry_run, + response_mode=response_mode, + ) + openclaw_status = str(openclaw_result.get("status") or "").strip().lower() + base_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result=registry_result, + source_payload=source_payload, + openclaw_result=openclaw_result, + worker_results=[], + pending_workers=list(worker_role_keys), + status="planned" if dry_run else "partial", + run_status="completed" if dry_run or not worker_role_keys else "running", + ) + + if dry_run: + planned_results = self._build_planned_worker_results(worker_role_keys) + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result=registry_result, + source_payload=source_payload, + openclaw_result=openclaw_result, + worker_results=planned_results, + pending_workers=[], + status="planned", + run_status="completed", + ) + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=len(worker_role_keys), + worker_completed=len(planned_results), + worker_failed=0, + summary=str(final_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(final_result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return final_result + + if not worker_role_keys: + registry_status, onboarding_status = self._resolve_registry_activation( + source_payload=source_payload, + openclaw_result=openclaw_result, + worker_results=[], + ) + final_status = self._resolve_orchestration_status(openclaw_status=openclaw_status, worker_results=[]) + if final_status == "completed" and onboarding_status != "completed": + final_status = "pending_manual" + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result={ + "registry_id": registry_result.get("registry_id"), + "status": registry_status, + "onboarding_status": onboarding_status, + }, + source_payload=source_payload, + openclaw_result=openclaw_result, + worker_results=[], + pending_workers=[], + status=final_status, + run_status="completed", + ) + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status=final_status, + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(final_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(final_result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + if registry_result.get("registry_id") is not None: + registry_row = await self._load_signal_source_registry( + tenant_id=tenant_id, + registry_id=int(registry_result["registry_id"]), + ) + if registry_row is not None: + registry_row.last_signal_at = _utc_now_naive() + registry_row.last_worker_run_at = _utc_now_naive() + registry_row.status = registry_status + registry_row.onboarding_status = onboarding_status + await self.db.commit() + if real_signal_id: + persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(real_signal_id)) + if persisted_signal is not None: + await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) + return final_result + + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="running", + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=len(worker_role_keys), + worker_completed=0, + worker_failed=0, + summary=str(base_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(base_result), + created_by_user_id=operator_user_id, + ) + await self.db.commit() + + task = asyncio.create_task( + type(self)._background_finalize_with_guard( + tenant_id=tenant_id, + trace_id=trace_id, + project_payload=self._serialize_snapshot(project_ref), + signal_payload=self._serialize_snapshot(signal_ref), + registry_result=dict(registry_result), + openclaw_result=dict(openclaw_result), + worker_role_keys=list(worker_role_keys), + operator_user_payload=self._serialize_snapshot(operator_ref), + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + signal_id=persisted_signal_id, + ), + name=f"signal-source-workers-{trace_id}", + ) + if response_mode == "fast": + return base_result + try: + return await asyncio.wait_for(asyncio.shield(task), timeout=COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + if run_service is None: + return base_result + run_row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + if run_row is None: + return base_result + snapshot = AIOrchestrationRunService.serialize_run(run_row) + payload = dict(run_row.result_payload or base_result) + payload["run_status"] = str(snapshot.get("run_status") or "running") + payload["status"] = str(payload.get("status") or "partial") + payload["pending_workers"] = snapshot.get("pending_workers") or payload.get("pending_workers") or [] + return payload + + async def list_project_sources( + self, + *, + tenant_id: int, + project_id: int, + limit: int = 20, + ) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.project_id == project_id, + ) + ).order_by(SignalSourceRegistry.updated_at.desc()).limit(max(1, min(limit, 100))) + ) + ).scalars().all() + data: list[dict[str, Any]] = [] + for row in rows: + executions = ( + await self.db.execute( + select(AIWorkerExecution).where( + and_( + AIWorkerExecution.tenant_id == tenant_id, + AIWorkerExecution.signal_source_registry_id == int(row.id), + ) + ).order_by(AIWorkerExecution.created_at.desc()).limit(6) + ) + ).scalars().all() + data.append( + { + "id": int(row.id), + "project_id": int(row.project_id), + "signal_id": int(row.signal_id) if row.signal_id else None, + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "source_identifier": str(row.source_identifier or ""), + "requested_via": str(row.requested_via or ""), + "status": str(row.status or ""), + "onboarding_status": str(row.onboarding_status or ""), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "worker_executions": [ + { + "id": int(item.id), + "worker_role_key": str(item.worker_role_key or ""), + "worker_role_name": str(item.worker_role_name or ""), + "status": str(item.status or ""), + "summary": str(item.summary or ""), + "completed_at": item.completed_at.isoformat() if item.completed_at else None, + } + for item in executions + ], + } + ) + return data + + async def _plan_onboarding( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + initiating_text: str, + requested_via: str, + response_mode: str, + ) -> tuple[dict[str, Any], str, dict[str, Any]]: + request_payload = { + "requested_via": requested_via, + "initiating_text": initiating_text[:600], + } + project_payload = { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:400], + } + signal_payload = { + "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, + "title": str(signal.title or "").strip(), + "summary": str(signal.summary or "").strip()[:400], + "signal_type": str(signal.signal_type or "").strip(), + "recommended_action": str(signal.recommended_action or "").strip(), + } + prompt = PROMPT_SIGNAL_SOURCE_ONBOARDING.format( + request_json=json.dumps(request_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + ) + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="signal_source_onboarding_plan", + use_prompt_profile=False, + max_tokens=420, + response_mode=response_mode, + ) + diagnostics = self._build_planner_diagnostics(extraction) + if extraction.payload is None: + return ( + self._heuristic_onboarding_plan( + initiating_text=initiating_text, + requested_via=requested_via, + error=str(extraction.error_code or "structured_unknown"), + ), + "fallback", + diagnostics, + ) + plan = extraction.payload + normalized = self._normalize_onboarding_plan( + plan=plan, + initiating_text=initiating_text, + requested_via=requested_via, + ) + return normalized, "planned", diagnostics + except Exception as exc: + return ( + self._heuristic_onboarding_plan( + initiating_text=initiating_text, + requested_via=requested_via, + error=str(exc), + ), + "fallback", + { + "used_fallback": True, + "error_code": "structured_empty", + "source_field": None, + "provider": "", + "model": "", + }, + ) + + def _normalize_onboarding_plan( + self, + *, + plan: dict[str, Any], + initiating_text: str, + requested_via: str, + ) -> dict[str, Any]: + should_onboard = bool(plan.get("should_onboard")) + summary = str(plan.get("summary") or "").strip()[:300] + source = plan.get("source") if isinstance(plan.get("source"), dict) else {} + worker_roles = self._normalize_worker_roles(plan.get("worker_roles")) + if not should_onboard: + return { + "should_onboard": False, + "summary": summary or "未识别为新增信号源请求。", + "source": None, + "worker_roles": [], + "openclaw_instruction": "", + } + normalized_source = self._normalize_source_payload( + source_payload=source, + fallback_text=initiating_text, + requested_via=requested_via, + ) + return { + "should_onboard": True, + "summary": summary or f"准备接入信号源 {normalized_source['source_name']}。", + "source": normalized_source, + "worker_roles": worker_roles, + "openclaw_instruction": ( + str(plan.get("openclaw_instruction") or "").strip() + or self._build_openclaw_instruction(normalized_source, initiating_text) + )[:600], + } + + def _heuristic_onboarding_plan( + self, + *, + initiating_text: str, + requested_via: str, + error: str, + ) -> dict[str, Any]: + source = self._normalize_source_payload( + source_payload={}, + fallback_text=initiating_text, + requested_via=requested_via, + ) + return { + "should_onboard": True, + "summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。", + "source": source, + "worker_roles": list(DEFAULT_WORKER_ROLE_KEYS), + "openclaw_instruction": self._build_openclaw_instruction(source, initiating_text), + } + + def _normalize_source_payload( + self, + *, + source_payload: dict[str, Any], + fallback_text: str, + requested_via: str, + ) -> dict[str, Any]: + source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via) + source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip()) + if not source_name: + source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源") + source_name = source_name[:MAX_SOURCE_NAME_LEN] or f"{source_kind}信号源" + source_identifier = str(source_payload.get("source_identifier") or "").strip() + if source_identifier: + existing_kind = source_identifier.split(":", 1)[0].strip().lower() + if existing_kind and existing_kind != source_kind: + source_identifier = "" + if not source_identifier: + source_identifier = self._build_source_identifier( + source_kind=source_kind, + source_name=source_name, + text=fallback_text, + source_payload=source_payload, + ) + description = str(source_payload.get("description") or "").strip()[:1000] or fallback_text[:300] + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + return { + "source_kind": source_kind, + "source_name": source_name, + "source_identifier": source_identifier[:MAX_SOURCE_IDENTIFIER_LEN], + "description": description, + "config": config, + } + + @staticmethod + def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str: + cleaned = str(raw_kind or "").strip().lower() + for kind, hints in SOURCE_KIND_HINTS: + if cleaned == kind: + return kind + if any(token in cleaned for token in hints): + return kind + text_lower = str(text or "").lower() + for kind, hints in SOURCE_KIND_HINTS: + if any(token in text_lower for token in hints): + return kind + via = str(requested_via or "").strip().lower() + if via in {"feishu", "pmflow", "github", "browser"}: + return via + return "custom" + + @classmethod + def _extract_source_name(cls, text: str, *, default: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return default + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) + if repo_match: + return repo_match.group(1)[:MAX_SOURCE_NAME_LEN] + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + candidate = cls._clean_source_name_candidate(quoted_match.group(1)) + if candidate: + return candidate[:MAX_SOURCE_NAME_LEN] + for pattern in ( + r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)", + r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if match: + value = cls._clean_source_name_candidate(match.group(1)) + if value: + return value[:MAX_SOURCE_NAME_LEN] + return cls._clean_source_name_candidate(cleaned)[:MAX_SOURCE_NAME_LEN] or default + + @classmethod + def _build_source_identifier( + cls, + *, + source_kind: str, + source_name: str, + text: str, + source_payload: dict[str, Any], + ) -> str: + normalized_text = re.sub(r"\s+", " ", str(text or "")).strip() + explicit_locator = cls._extract_explicit_source_locator(source_payload=source_payload, text=text) + if explicit_locator: + basis = re.sub(r"[^a-zA-Z0-9:/._-]+", "-", explicit_locator).strip("-").lower() + return f"{source_kind}:{basis[:120]}" + if cls._source_name_is_generic(source_name, source_kind=source_kind): + digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] + return f"{source_kind}:req-{digest}" + basis = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", source_name).strip("-").lower() + if basis: + return f"{source_kind}:{basis[:120]}" + digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] + return f"{source_kind}:{digest}" + + @staticmethod + def _clean_source_name_candidate(value: str) -> str: + cleaned = re.sub(r"\s+", " ", str(value or "")).strip() + cleaned = re.sub(r"^(?:给)?项目id[::]?\s*\d+\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"[,。,.;;::].*$", "", cleaned) + cleaned = cleaned.strip(" []【】()()::\"'“”") + cleaned = re.sub(r"^(?:请|帮我|帮忙|把|将|需要|想要|用于|用来)\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub( + r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", + "", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", cleaned, flags=re.IGNORECASE).strip() + if cleaned.lower() == "github": + return "GitHub" + if cleaned.lower() == "feishu": + return "Feishu" + normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", cleaned).lower() + if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}: + return "" + return cleaned + + @classmethod + def _extract_explicit_source_locator(cls, *, source_payload: dict[str, Any], text: str) -> str: + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + direct_candidates = [ + source_payload.get("repo"), + source_payload.get("repository"), + source_payload.get("url"), + source_payload.get("webhook_url"), + source_payload.get("channel_id"), + source_payload.get("chat_id"), + config.get("repo"), + config.get("repository"), + config.get("url"), + config.get("webhook_url"), + config.get("channel_id"), + config.get("chat_id"), + ] + for candidate in direct_candidates: + normalized = cls._normalize_locator_fragment(candidate) + if normalized: + return normalized + + url_match = re.search(r"https?://[^\s,。,;;))】\\]]+", str(text or ""), re.IGNORECASE) + if url_match: + return cls._normalize_locator_fragment(url_match.group(0)) + + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", str(text or "")) + if repo_match: + return f"repo:{repo_match.group(1).lower()}" + + chat_match = re.search( + r"\b(?:chat_id|channel_id|群聊id|频道id)[::=\s]+([A-Za-z0-9_-]{6,})\b", + str(text or ""), + re.IGNORECASE, + ) + if chat_match: + return f"chat:{chat_match.group(1).lower()}" + return "" + + @staticmethod + def _normalize_locator_fragment(value: Any) -> str: + text = str(value or "").strip().strip("\"'“”") + if not text: + return "" + text = re.sub(r"\?.*$", "", text).rstrip("/") + return text[:140].lower() + + @classmethod + def _source_name_is_generic(cls, source_name: str, *, source_kind: str) -> bool: + normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(source_name or "")).lower() + if not normalized: + return True + generic_tokens = { + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", source_kind).lower(), + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", f"{source_kind}信号源").lower(), + "项目id", + "给项目id", + "复合", + "复合信号源", + } + for kind, hints in SOURCE_KIND_HINTS: + if kind != source_kind: + continue + generic_tokens.update( + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower() + for token in hints + ) + break + return normalized in generic_tokens + + @staticmethod + def _normalize_worker_roles(raw_roles: Any) -> list[str]: + roles: list[str] = [] + if isinstance(raw_roles, list): + for item in raw_roles: + key = str(item or "").strip() + if key in DEFAULT_WORKER_ROLE_KEYS and key not in roles: + roles.append(key) + if roles: + return roles[: len(DEFAULT_WORKER_ROLE_KEYS)] + return list(DEFAULT_WORKER_ROLE_KEYS) + + @staticmethod + def _build_openclaw_instruction(source: dict[str, Any], initiating_text: str) -> str: + return ( + f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。" + "请创建必要任务、分配合适负责人,并通知相关成员。" + f"补充上下文:{str(initiating_text or '').strip()[:240]}" + ) + + async def _create_or_reuse_registry( + self, + *, + tenant_id: int, + project_id: int, + signal_id: Optional[int], + operator_user_id: Optional[int], + requested_via: str, + source_payload: dict[str, Any], + initiating_text: str, + dry_run: bool, + ) -> dict[str, Any]: + normalized_source = self._normalize_source_payload( + source_payload=source_payload, + fallback_text=initiating_text, + requested_via=requested_via, + ) + if dry_run: + return { + "status": "planned", + "registry_id": None, + "source_kind": normalized_source.get("source_kind"), + "source_name": normalized_source.get("source_name"), + "source_identifier": normalized_source.get("source_identifier"), + "description": str(normalized_source.get("description") or "").strip(), + } + + row = SignalSourceRegistry( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + created_by_user_id=operator_user_id, + source_kind=str(normalized_source["source_kind"]), + source_name=str(normalized_source["source_name"]), + source_identifier=str(normalized_source["source_identifier"]), + description=str(normalized_source.get("description") or "").strip() or None, + requested_via=str(requested_via or "system").strip()[:40] or "system", + status="pending", + onboarding_status="pending", + config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {}, + raw_context={ + "initiating_text": str(initiating_text or "")[:2000], + "requested_via": requested_via, + }, + last_signal_at=_utc_now_naive(), + ) + self.db.add(row) + try: + await self.db.flush() + await self.db.commit() + await self.db.refresh(row) + return { + "status": "created", + "registry_id": int(row.id), + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "source_identifier": str(row.source_identifier or ""), + "description": str(row.description or ""), + } + except IntegrityError: + await self.db.rollback() + existing = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.project_id == project_id, + SignalSourceRegistry.source_kind == str(normalized_source["source_kind"]), + SignalSourceRegistry.source_identifier == str(normalized_source["source_identifier"]), + ) + ) + ) + ).scalar_one_or_none() + if existing is None: + raise + existing.signal_id = signal_id + if str(normalized_source.get("source_name") or "").strip(): + existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN] + existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None + existing.config = normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else existing.config + existing.raw_context = { + "initiating_text": str(initiating_text or "")[:2000], + "requested_via": requested_via, + } + if not isinstance(existing.config, dict) or not existing.config: + existing.status = "pending" + existing.onboarding_status = "pending" + existing.last_signal_at = _utc_now_naive() + await self.db.commit() + return { + "status": "reused", + "registry_id": int(existing.id), + "source_kind": str(existing.source_kind or ""), + "source_name": str(existing.source_name or ""), + "source_identifier": str(existing.source_identifier or ""), + "description": str(existing.description or ""), + } + + async def _dispatch_openclaw( + self, + *, + tenant_id: int, + operator_user: Optional[User], + project: Project, + instruction: str, + dry_run: bool, + response_mode: str, + ) -> dict[str, Any]: + if not instruction: + return { + "status": "skipped", + "reason": "empty_instruction", + } + if operator_user is None: + return { + "status": "skipped", + "reason": "no_manager_operator", + } + try: + from .openclaw_commander_service import OpenClawCommanderService + + result = await OpenClawCommanderService(self.db).dispatch_command( + tenant_id=tenant_id, + operator_user=operator_user, + project_id=int(project.id), + instruction=instruction, + dry_run=dry_run, + max_operations=6, + response_mode=response_mode, + persist_run=False, + ) + return { + "status": "planned" if dry_run else "completed", + "result": result, + } + except Exception as exc: + return { + "status": "failed", + "reason": str(exc), + } + + async def _execute_worker_roles( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + trace_id: str, + operator_user: Optional[User], + dry_run: bool, + response_mode: str, + ) -> list[dict[str, Any]]: + bundle = await prompt_profile_service.get_profiles(self.db, tenant_id=tenant_id) + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + role_map = { + str(item.get("role_key") or "").strip(): item + for item in roles + if isinstance(item, dict) and str(item.get("role_key") or "").strip() + } + + if dry_run: + return self._build_planned_worker_results(worker_role_keys, role_map=role_map) + + semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) + + async def _run_role(role_key: str) -> dict[str, Any]: + role = role_map.get(role_key) + if not isinstance(role, dict): + return { + "worker_role_key": role_key, + "worker_role_name": role_key, + "status": "failed", + "reason": "role_not_configured", + } + try: + async with semaphore: + return await type(self)._execute_single_worker_role_isolated( + tenant_id=tenant_id, + project_payload=self._serialize_snapshot(project), + signal_payload=self._serialize_snapshot(signal), + registry_result=dict(registry_result), + openclaw_result=dict(openclaw_result), + role=dict(role), + trace_id=trace_id, + operator_user_payload=self._serialize_snapshot(operator_user), + response_mode=response_mode, + ) + except Exception as exc: + return { + "worker_role_key": role_key, + "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, + "status": "failed", + "reason": str(exc)[:300], + } + + tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] + return list(await asyncio.gather(*tasks, return_exceptions=False)) + + async def _execute_single_worker_role( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + role: dict[str, Any], + trace_id: str, + operator_user: Optional[User], + dry_run: bool, + response_mode: str, + ) -> dict[str, Any]: + role_key = str(role.get("role_key") or "").strip() + role_name = str(role.get("role_name") or role_key).strip()[:64] or role_key + registry_id = registry_result.get("registry_id") + source_payload = { + "registry_id": int(registry_id) if registry_id is not None else None, + "source_kind": str(registry_result.get("source_kind") or ""), + "source_name": str(registry_result.get("source_name") or ""), + "source_identifier": str(registry_result.get("source_identifier") or ""), + "description": str(registry_result.get("description") or ""), + } + + execution_row: Optional[AIWorkerExecution] = None + if not dry_run: + execution_row = AIWorkerExecution( + tenant_id=tenant_id, + project_id=int(project.id), + signal_id=int(signal.id) if getattr(signal, "id", None) is not None else None, + signal_source_registry_id=int(registry_id) if registry_id is not None else None, + operator_user_id=int(operator_user.id) if operator_user is not None else None, + trace_id=trace_id, + operation_trace_id=( + str((openclaw_result.get("result") or {}).get("trace_id") or "").strip() + if isinstance(openclaw_result.get("result"), dict) + else None + ), + execution_type="signal_source_onboarding", + worker_role_key=role_key, + worker_role_name=role_name, + status="running", + input_payload=source_payload, + started_at=_utc_now_naive(), + ) + self.db.add(execution_row) + await self.db.flush() + # 先提交 running 状态,避免 SQLite 事务跨 LLM 调用长期持有写锁。 + await self.db.commit() + + prompt = self._build_worker_prompt( + role=role, + project=project, + signal=signal, + source_payload=source_payload, + openclaw_result=openclaw_result, + ) + if dry_run: + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "planned", + } + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="signal_source_ai_worker_execution", + use_prompt_profile=False, + max_tokens=420, + response_mode="complete" if response_mode == "fast" else response_mode, + ) + if extraction.payload is None: + raise ValueError(str(extraction.error_code or "structured_empty")) + parsed = self._normalize_worker_output( + extraction.payload, + role_name=role_name, + source_name=source_payload.get("source_name"), + ) + if execution_row is not None: + execution_row.status = "completed" + execution_row.summary = str(parsed.get("summary") or "").strip()[:1000] + execution_row.output_payload = parsed + execution_row.completed_at = _utc_now_naive() + await self.db.commit() + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "completed", + "summary": str(parsed.get("summary") or "").strip(), + "execution_id": int(execution_row.id) if execution_row is not None else None, + "used_fallback": False, + } + except Exception as exc: + fallback = self._fallback_worker_output( + role_name=role_name, + source_name=str(source_payload.get("source_name") or "信号源"), + ) + if execution_row is not None: + execution_row.status = "completed" + execution_row.summary = str(fallback.get("summary") or "").strip()[:1000] + execution_row.output_payload = fallback + execution_row.error_message = str(exc)[:1000] + execution_row.completed_at = _utc_now_naive() + await self.db.commit() + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "completed", + "reason": str(exc), + "summary": str(fallback.get("summary") or "").strip(), + "used_fallback": True, + "execution_id": int(execution_row.id) if execution_row is not None else None, + } + + @classmethod + async def _execute_single_worker_role_isolated( + cls, + *, + tenant_id: int, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + role: dict[str, Any], + trace_id: str, + operator_user_payload: Optional[dict[str, Any]], + response_mode: str, + ) -> dict[str, Any]: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + return await service._execute_single_worker_role( + tenant_id=tenant_id, + project=SimpleNamespace(**project_payload), + signal=SimpleNamespace(**signal_payload), + registry_result=registry_result, + openclaw_result=openclaw_result, + role=role, + trace_id=trace_id, + operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, + dry_run=False, + response_mode=response_mode, + ) + + @classmethod + async def _background_finalize_with_guard( + cls, + *, + tenant_id: int, + trace_id: str, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + operator_user_payload: Optional[dict[str, Any]], + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + signal_id: Optional[int], + ) -> dict[str, Any]: + try: + return await cls._execute_workers_and_finalize( + tenant_id=tenant_id, + trace_id=trace_id, + project_payload=project_payload, + signal_payload=signal_payload, + registry_result=registry_result, + openclaw_result=openclaw_result, + worker_role_keys=worker_role_keys, + operator_user_payload=operator_user_payload, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + signal_id=signal_id, + ) + except Exception as exc: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + run_service = AIOrchestrationRunService(db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + response_mode = str(row.response_mode or "fast") if row is not None else "fast" + existing_payload = dict(row.result_payload or {}) if row is not None and isinstance(row.result_payload, dict) else {} + source_payload = existing_payload.get("signal_source") if isinstance(existing_payload.get("signal_source"), dict) else {} + worker_results = ( + existing_payload.get("worker_executions") + if isinstance(existing_payload.get("worker_executions"), list) + else [] + ) + completed_keys = { + str(item.get("worker_role_key") or "").strip() + for item in worker_results + if isinstance(item, dict) and str(item.get("worker_role_key") or "").strip() + } + pending_workers = [key for key in worker_role_keys if key not in completed_keys] or list(worker_role_keys) + failed_result = service._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": source_payload.get("registry_id"), + "status": source_payload.get("status"), + }, + source_payload={ + "source_kind": source_payload.get("source_kind"), + "source_name": source_payload.get("source_name"), + "source_identifier": source_payload.get("source_identifier"), + }, + openclaw_result=openclaw_result, + worker_results=worker_results, + pending_workers=pending_workers, + status="failed", + run_status="failed", + ) + failed_result["background_error"] = str(exc)[:500] + if row is not None: + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status="failed", + planner_status=planner_status, + openclaw_status=str(openclaw_result.get("status") or row.openclaw_status or ""), + worker_total=max(len(worker_role_keys), int(row.worker_total or 0)), + worker_completed=int(row.worker_completed or 0), + worker_failed=int(row.worker_failed or 0), + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=failed_result, + completed_at=_utc_now_naive(), + ) + await db.commit() + return failed_result + + @classmethod + async def _execute_workers_and_finalize( + cls, + *, + tenant_id: int, + trace_id: str, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + operator_user_payload: Optional[dict[str, Any]], + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + signal_id: Optional[int], + ) -> dict[str, Any]: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + bundle = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id) + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + role_map = { + str(item.get("role_key") or "").strip(): item + for item in roles + if isinstance(item, dict) and str(item.get("role_key") or "").strip() + } + semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) + + async def _run_role(role_key: str) -> dict[str, Any]: + role = role_map.get(role_key) + if not isinstance(role, dict): + return { + "worker_role_key": role_key, + "worker_role_name": role_key, + "status": "failed", + "reason": "role_not_configured", + } + try: + async with semaphore: + return await cls._execute_single_worker_role_isolated( + tenant_id=tenant_id, + project_payload=project_payload, + signal_payload=signal_payload, + registry_result=registry_result, + openclaw_result=openclaw_result, + role=dict(role), + trace_id=trace_id, + operator_user_payload=operator_user_payload, + response_mode="complete", + ) + except Exception as exc: + return { + "worker_role_key": role_key, + "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, + "status": "failed", + "reason": str(exc)[:300], + } + + tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] + worker_results: list[dict[str, Any]] = [] + for task in asyncio.as_completed(tasks): + result = await task + worker_results.append(result) + await service._update_run_after_worker( + tenant_id=tenant_id, + trace_id=trace_id, + summary=summary, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + registry_result=registry_result, + openclaw_result=openclaw_result, + worker_role_keys=worker_role_keys, + worker_results=worker_results, + ) + + final_result = await service._finalize_orchestration_run( + tenant_id=tenant_id, + trace_id=trace_id, + signal_id=signal_id, + summary=summary, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + registry_result=registry_result, + openclaw_result=openclaw_result, + worker_role_keys=worker_role_keys, + worker_results=worker_results, + ) + return final_result + + @staticmethod + def _serialize_snapshot(value: Any) -> Optional[dict[str, Any]]: + if value is None: + return None + if isinstance(value, dict): + return dict(value) + data = getattr(value, "__dict__", None) + if isinstance(data, dict): + return {str(key): data[key] for key in data} + return None + + @staticmethod + def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: + return { + "used_fallback": extraction.error_code is not None, + "error_code": extraction.error_code, + "source_field": extraction.source_field or None, + "provider": str(extraction.provider or ""), + "model": str(extraction.model or ""), + } + + def _build_planned_worker_results( + self, + worker_role_keys: list[str], + *, + role_map: Optional[dict[str, dict[str, Any]]] = None, + ) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for role_key in worker_role_keys: + role = role_map.get(role_key) if isinstance(role_map, dict) else None + role_name = str((role or {}).get("role_name") or role_key).strip()[:64] or role_key + results.append( + { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "planned", + } + ) + return results + + @staticmethod + def _build_signal_source_snapshot(registry_result: dict[str, Any], source_payload: dict[str, Any]) -> dict[str, Any]: + return { + "registry_id": registry_result.get("registry_id"), + "status": registry_result.get("status"), + "onboarding_status": registry_result.get("onboarding_status"), + "source_kind": source_payload.get("source_kind"), + "source_name": source_payload.get("source_name"), + "source_identifier": source_payload.get("source_identifier"), + "description": source_payload.get("description"), + "config": source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}, + } + + def _build_orchestration_result( + self, + *, + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + registry_result: dict[str, Any], + source_payload: dict[str, Any], + openclaw_result: dict[str, Any], + worker_results: list[dict[str, Any]], + pending_workers: list[str], + status: str, + run_status: str, + ) -> dict[str, Any]: + return { + "trace_id": trace_id, + "status": status, + "run_status": run_status, + "response_mode": response_mode, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "summary": summary, + "pending_workers": list(pending_workers), + "signal_source": self._build_signal_source_snapshot(registry_result, source_payload), + "openclaw": openclaw_result, + "worker_executions": list(worker_results), + } + + @classmethod + def _has_actionable_source_config(cls, *, source_payload: dict[str, Any]) -> bool: + if not isinstance(source_payload, dict): + return False + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + if config: + return True + locator = cls._extract_explicit_source_locator( + source_payload=source_payload, + text=str(source_payload.get("description") or ""), + ) + if locator: + return True + source_kind = str(source_payload.get("source_kind") or "").strip().lower() + source_identifier = str(source_payload.get("source_identifier") or "").strip().lower() + if source_kind in {"github", "feishu", "webhook", "url"} and source_identifier and ":req-" not in source_identifier: + return True + if source_kind in {"pmflow", "custom", "browser"} and ( + not source_identifier or source_identifier.startswith(f"{source_kind}:req-") + ): + return False + return False + + @staticmethod + def _has_non_fallback_worker_result(worker_results: list[dict[str, Any]]) -> bool: + for item in worker_results: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").strip().lower() + if status != "completed": + continue + if bool(item.get("used_fallback")): + continue + return True + return False + + @staticmethod + def _extract_openclaw_execution_rows(openclaw_result: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(openclaw_result, dict): + return [] + result = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + return [row for row in rows if isinstance(row, dict)] + + @classmethod + def _has_material_openclaw_execution(cls, openclaw_result: dict[str, Any]) -> bool: + for row in cls._extract_openclaw_execution_rows(openclaw_result): + status = str(row.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + continue + operation_code = str(row.get("operation_code") or "").strip().lower() + if operation_code == "notification.create": + continue + return True + return False + + @classmethod + def _resolve_registry_activation( + cls, + *, + source_payload: dict[str, Any], + openclaw_result: dict[str, Any], + worker_results: list[dict[str, Any]], + ) -> tuple[str, str]: + actionable_config = cls._has_actionable_source_config(source_payload=source_payload) + has_real_worker = cls._has_non_fallback_worker_result(worker_results) + has_material_openclaw = cls._has_material_openclaw_execution(openclaw_result) + if actionable_config and has_real_worker and has_material_openclaw: + return "active", "completed" + return "pending", "pending_manual" + + @staticmethod + def _resolve_orchestration_status(*, openclaw_status: str, worker_results: list[dict[str, Any]]) -> str: + normalized_openclaw = str(openclaw_status or "").strip().lower() + worker_statuses = [str(item.get("status") or "").strip().lower() for item in worker_results] + has_worker_completed = any(status == "completed" for status in worker_statuses) + has_worker_failed = any(status == "failed" for status in worker_statuses) + if normalized_openclaw == "failed" and not has_worker_completed: + return "failed" + if has_worker_failed or normalized_openclaw == "failed": + return "partial" + if has_worker_completed or normalized_openclaw == "completed": + return "completed" + return "failed" + + @staticmethod + def _worker_counts(worker_results: list[dict[str, Any]]) -> tuple[int, int]: + completed = 0 + failed = 0 + for item in worker_results: + status = str(item.get("status") or "").strip().lower() + if status == "failed": + failed += 1 + continue + if status in {"completed", "planned", "skipped"}: + completed += 1 + return completed, failed + + async def _update_run_after_worker( + self, + *, + tenant_id: int, + trace_id: str, + summary: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + worker_results: list[dict[str, Any]], + ) -> None: + run_service = AIOrchestrationRunService(self.db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + if row is None: + return + source_payload = row.result_payload.get("signal_source") if isinstance(row.result_payload, dict) else {} + completed_count, failed_count = self._worker_counts(worker_results) + completed_keys = { + str(item.get("worker_role_key") or "").strip() + for item in worker_results + if str(item.get("worker_role_key") or "").strip() + } + pending_workers = [key for key in worker_role_keys if key not in completed_keys] + status = "partial" if pending_workers else self._resolve_orchestration_status( + openclaw_status=str(openclaw_result.get("status") or ""), + worker_results=worker_results, + ) + result_payload = self._build_orchestration_result( + trace_id=trace_id, + response_mode=str(row.response_mode or "fast"), + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": (source_payload or {}).get("registry_id"), + "status": (source_payload or {}).get("status"), + }, + source_payload={ + "source_kind": (source_payload or {}).get("source_kind"), + "source_name": (source_payload or {}).get("source_name"), + "source_identifier": (source_payload or {}).get("source_identifier"), + }, + openclaw_result=openclaw_result, + worker_results=worker_results, + pending_workers=pending_workers, + status=status, + run_status="running" if pending_workers else status, + ) + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status="running" if pending_workers else status, + planner_status=planner_status, + openclaw_status=str(openclaw_result.get("status") or ""), + worker_total=len(worker_role_keys), + worker_completed=completed_count, + worker_failed=failed_count, + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=result_payload, + completed_at=None if pending_workers else _utc_now_naive(), + ) + await self.db.commit() + + async def _finalize_orchestration_run( + self, + *, + tenant_id: int, + trace_id: str, + signal_id: Optional[int], + summary: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + worker_results: list[dict[str, Any]], + ) -> dict[str, Any]: + run_service = AIOrchestrationRunService(self.db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + response_mode = str(row.response_mode or "fast") if row is not None else "fast" + source_payload = row.result_payload.get("signal_source") if row is not None and isinstance(row.result_payload, dict) else {} + final_status = self._resolve_orchestration_status( + openclaw_status=str(openclaw_result.get("status") or ""), + worker_results=worker_results, + ) + registry_status, onboarding_status = self._resolve_registry_activation( + source_payload=source_payload if isinstance(source_payload, dict) else {}, + openclaw_result=openclaw_result, + worker_results=worker_results, + ) + if final_status == "completed" and onboarding_status != "completed": + final_status = "pending_manual" + completed_count, failed_count = self._worker_counts(worker_results) + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": (source_payload or {}).get("registry_id"), + "status": registry_status, + "onboarding_status": onboarding_status, + }, + source_payload={ + "source_kind": (source_payload or {}).get("source_kind"), + "source_name": (source_payload or {}).get("source_name"), + "source_identifier": (source_payload or {}).get("source_identifier"), + }, + openclaw_result=openclaw_result, + worker_results=worker_results, + pending_workers=[], + status=final_status, + run_status=final_status, + ) + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status=final_status, + planner_status=planner_status, + openclaw_status=str(openclaw_result.get("status") or ""), + worker_total=len(worker_role_keys), + worker_completed=completed_count, + worker_failed=failed_count, + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=final_result, + completed_at=_utc_now_naive(), + ) + if registry_result.get("registry_id") is not None: + registry_row = await self._load_signal_source_registry( + tenant_id=tenant_id, + registry_id=int(registry_result["registry_id"]), + ) + if registry_row is not None: + registry_row.last_signal_at = _utc_now_naive() + registry_row.last_worker_run_at = _utc_now_naive() + registry_row.status = registry_status + registry_row.onboarding_status = onboarding_status + if signal_id: + persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(signal_id)) + if persisted_signal is not None: + await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) + await self.db.commit() + return final_result + + @staticmethod + def _build_worker_prompt( + *, + role: dict[str, Any], + project: Project, + signal: Signal, + source_payload: dict[str, Any], + openclaw_result: dict[str, Any], + ) -> str: + role_payload = { + "role_key": str(role.get("role_key") or "").strip(), + "role_name": str(role.get("role_name") or "").strip(), + "role_description": str(role.get("role_description") or "").strip(), + "system_prompt": str(role.get("system_prompt") or "").strip(), + } + project_payload = { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:1000], + } + signal_payload = { + "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, + "title": str(signal.title or "").strip(), + "summary": str(signal.summary or signal.content or "").strip()[:1200], + } + commander_payload = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + embedded_prompt = PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION.format( + role_json=json.dumps(role_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + source_json=json.dumps(source_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + commander_json=json.dumps(commander_payload or {}, ensure_ascii=False), + ) + system_prompt = str(role.get("system_prompt") or "").strip() + if system_prompt: + return f"[Role System Prompt]\n{system_prompt}\n\n{embedded_prompt}" + return embedded_prompt + + @staticmethod + def _normalize_worker_output(parsed: dict[str, Any], *, role_name: str, source_name: Any) -> dict[str, Any]: + deliverables = parsed.get("deliverables") if isinstance(parsed.get("deliverables"), list) else [] + risks = parsed.get("risks") if isinstance(parsed.get("risks"), list) else [] + next_actions = parsed.get("next_actions") if isinstance(parsed.get("next_actions"), list) else [] + return { + "summary": str(parsed.get("summary") or f"{role_name} 已完成 {source_name} 的接入分析。").strip()[:1000], + "deliverables": [str(item).strip()[:200] for item in deliverables if str(item).strip()][:4], + "risks": [str(item).strip()[:200] for item in risks if str(item).strip()][:4], + "next_actions": [str(item).strip()[:200] for item in next_actions if str(item).strip()][:4], + "used_fallback": False, + } + + @staticmethod + def _fallback_worker_output(*, role_name: str, source_name: str) -> dict[str, Any]: + return { + "summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。", + "deliverables": [f"梳理 {source_name} 的接入目标与边界"], + "risks": [f"{source_name} 的标识与接入细节仍需补充确认"], + "next_actions": ["等待 OpenClaw 生成任务并推进执行"], + "used_fallback": True, + } + + async def _persist_signal_orchestration_result( + self, + *, + signal: Signal, + orchestration_result: dict[str, Any], + ) -> None: + strategic_payload: dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + try: + parsed = json.loads(raw_strategic) + except Exception: + parsed = {} + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["signal_source_orchestration"] = orchestration_result + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + orchestration_status = str(orchestration_result.get("status") or "").lower() + signal.processed = orchestration_status in {"completed", "partial"} + signal.status = self._map_orchestration_status_to_signal_status(orchestration_status) + signal.tasks_generated = json.dumps( + self._extract_task_operations(orchestration_result.get("openclaw")), + ensure_ascii=False, + ) + await self.db.commit() + + @staticmethod + def _extract_task_operations(openclaw_payload: Any) -> list[dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + tasks: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: + continue + tasks.append( + { + "operation_code": operation_code, + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "task_id": payload.get("task_id"), + "title": payload.get("title"), + "summary": str(row.get("message") or "").strip(), + } + ) + return tasks + + @staticmethod + def _map_orchestration_status_to_signal_status(status: str) -> str: + normalized = str(status or "").strip().lower() + if normalized == "completed": + return "已编排" + if normalized == "pending_manual": + return "待确认接入" + if normalized == "partial": + return "部分编排" + if normalized == "failed": + return "编排失败" + if normalized == "planned": + return "已规划" + if normalized == "skipped": + return "已跳过" + return "待处理" + + async def _resolve_operator_user( + self, + *, + tenant_id: int, + project: Project, + operator_user_id: Optional[int], + ) -> Optional[User]: + preferred_ids: list[int] = [] + if operator_user_id is not None: + preferred_ids.append(int(operator_user_id)) + if project.owner_id: + preferred_ids.append(int(project.owner_id)) + + for user_id in preferred_ids: + row = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + User.is_active.is_(True), + ) + ) + ) + ).scalar_one_or_none() + if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}: + return row + + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + + @staticmethod + def _build_user_snapshot(user: Optional[User]) -> Optional[SimpleNamespace]: + if user is None: + return None + user_id = getattr(user, "id", None) + if user_id is None: + return None + return SimpleNamespace( + id=int(user_id), + name=str(getattr(user, "name", "") or "").strip(), + username=str(getattr(user, "username", "") or "").strip(), + role=str(getattr(user, "role", "") or "").strip(), + ) + + async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]: + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + + async def _load_signal(self, *, tenant_id: int, signal_id: int) -> Optional[Signal]: + return ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + + async def _load_signal_source_registry( + self, + *, + tenant_id: int, + registry_id: int, + ) -> Optional[SignalSourceRegistry]: + return ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.id == registry_id, + ) + ) + ) + ).scalar_one_or_none() + + @staticmethod + def _build_ephemeral_signal(*, initiating_text: str) -> Signal: + signal = Signal( + tenant_id=1, + source="PMFlow", + content=initiating_text[:2000], + impact_score=0.7, + priority="medium", + processed=False, + title=initiating_text[:80] or "新增信号源", + signal_type="新需求", + summary=initiating_text[:300], + recommended_action="enter_only", + status="待处理", + ) + signal.id = 0 # type: ignore[assignment] + return signal + + @staticmethod + def _parse_json_object(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if "```" in text: + match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) + if match: + text = match.group(1) + if not text.startswith("{"): + match = re.search(r"(\{.*\})", text, re.S) + if match: + text = match.group(1) + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise ValueError("json_object_required") + return parsed diff --git a/backend/app/services/skill_score_service.py b/backend/app/services/skill_score_service.py new file mode 100644 index 00000000..d2a9fe06 --- /dev/null +++ b/backend/app/services/skill_score_service.py @@ -0,0 +1,221 @@ +"""任务完成后的技能分更新服务。""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_SKILL_SCORE_UPDATE +from ..database import AsyncSessionLocal +from ..models import AICallLog, Task, UserSkillBinding +from .llm import llm_service + +PRIORITY_WEIGHT = { + "critical": 1.5, + "high": 1.2, + "medium": 1.0, + "low": 0.8, +} + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +def _normalize_status(status: Optional[str]) -> str: + return (status or "").strip().lower() + + +def _parse_json(text: str) -> dict: + body = (text or "").strip() + if body.startswith("```json"): + body = body[7:].strip() + if body.startswith("```"): + body = body[3:].strip() + if body.endswith("```"): + body = body[:-3].strip() + return json.loads(body) + + +def _heuristic_deltas( + task: Task, + required_skill_ids: list[int], +) -> list[dict]: + quality_score = float(task.quality_score or 75.0) + estimated = max(float(task.estimated_hours or 0.0), 0.0) + actual = max(float(task.actual_hours or estimated or 1.0), 1.0) + efficiency_ratio = estimated / actual if estimated > 0 else 1.0 + + quality_delta = _clamp((quality_score - 75.0) / 10.0, -3.0, 3.0) + efficiency_delta = _clamp((efficiency_ratio - 1.0) * 4.0, -3.0, 3.0) + return [ + { + "skill_tag_id": skill_id, + "quality_delta": quality_delta, + "efficiency_delta": efficiency_delta, + } + for skill_id in required_skill_ids + ] + + +async def _apply_skill_deltas( + db: AsyncSession, + *, + tenant_id: int, + user_id: int, + priority: str, + deltas: list[dict], +) -> int: + binding_result = await db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + bindings = list(binding_result.scalars().all()) + binding_map = {int(row.skill_tag_id): row for row in bindings} + weight = PRIORITY_WEIGHT.get(priority, 1.0) + + touched = 0 + for item in deltas: + try: + skill_tag_id = int(item.get("skill_tag_id")) + except (TypeError, ValueError): + continue + row = binding_map.get(skill_tag_id) + if not row: + row = UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_tag_id, + proficiency_level="beginner", + level="beginner", + proficiency=0, + quality_score=0.0, + efficiency_score=0.0, + ) + db.add(row) + binding_map[skill_tag_id] = row + + quality_delta = float(item.get("quality_delta", 0.0)) * weight + efficiency_delta = float(item.get("efficiency_delta", 0.0)) * weight + row.quality_score = round( + _clamp(float(row.quality_score or 0.0) + quality_delta, 0.0, 100.0), + 1, + ) + row.efficiency_score = round( + _clamp(float(row.efficiency_score or 0.0) + efficiency_delta, 0.0, 100.0), + 1, + ) + combined = (float(row.quality_score or 0.0) * 0.5) + ( + float(row.efficiency_score or 0.0) * 0.5 + ) + row.proficiency = int(_clamp(round(combined), 0, 100)) + row.last_used_at = datetime.utcnow() + row.updated_at = datetime.utcnow() + touched += 1 + + return touched + + +async def process_task_completion( + *, + task_id: int, + tenant_id: int, + operator_user_id: Optional[int] = None, +) -> dict: + """任务完成后更新 assignee 的技能质量/效率分。""" + async with AsyncSessionLocal() as db: + task = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.id == task_id, + ) + ) + ) + ).scalar_one_or_none() + if not task: + return {"updated_count": 0, "reason": "task_not_found"} + if _normalize_status(task.status) not in {"done", "completed", "closed"}: + return {"updated_count": 0, "reason": "task_not_done"} + if not task.assignee_id: + return {"updated_count": 0, "reason": "task_no_assignee"} + + required_skill_ids = [ + int(v) for v in (task.required_skill_tag_ids or []) if v is not None + ] + if not required_skill_ids: + return {"updated_count": 0, "reason": "task_no_required_skills"} + + prompt = PROMPT_SKILL_SCORE_UPDATE.format( + task_id=int(task.id), + priority=str(task.priority or "medium"), + quality_score=float(task.quality_score or 75.0), + estimated_hours=float(task.estimated_hours or 0.0), + actual_hours=float(task.actual_hours or float(task.estimated_hours or 1.0) or 1.0), + required_skill_tag_ids=required_skill_ids, + ) + + response_text = "" + status = "success" + deltas: list[dict] + try: + response_text = await llm_service.generate( + prompt, + function_name="skill_score_update", + tenant_id=tenant_id, + ) + payload = _parse_json(response_text) + parsed = payload.get("deltas", []) if isinstance(payload, dict) else [] + deltas = [] + for row in parsed: + if not isinstance(row, dict): + continue + try: + deltas.append( + { + "skill_tag_id": int(row.get("skill_tag_id")), + "quality_delta": float(row.get("quality_delta", 0.0)), + "efficiency_delta": float(row.get("efficiency_delta", 0.0)), + } + ) + except (TypeError, ValueError): + continue + if not deltas: + deltas = _heuristic_deltas(task, required_skill_ids) + status = "fallback" + except Exception as exc: + response_text = str(exc) + deltas = _heuristic_deltas(task, required_skill_ids) + status = "fallback" + + updated = await _apply_skill_deltas( + db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + priority=str(task.priority or "medium").lower(), + deltas=deltas, + ) + db.add( + AICallLog( + tenant_id=tenant_id, + user_id=operator_user_id or int(task.assignee_id), + function_name="skill_score_update", + prompt=prompt, + response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:8000], + tokens_used=max(len(prompt) // 4, 1) + max(len(response_text) // 4, 0), + cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000), + status=status, + ) + ) + await db.commit() + return {"updated_count": updated, "status": status} + diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py new file mode 100644 index 00000000..c6d0eed7 --- /dev/null +++ b/backend/app/services/skill_service.py @@ -0,0 +1,623 @@ +""" +PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务 + +- 系统预设 AI 开发技能标签初始化 +- SkillTag CRUD(系统标签禁止删改) +- UserSkillBinding 批量更新与查询 +- UserWorkload 自动更新 +""" + +import uuid as uuid_module +from datetime import datetime, timedelta +from typing import List, Optional + +from sqlalchemy import and_, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task +from ..database import _is_sqlite + + +# 系统预设 AI 开发专属技能标签 +SYSTEM_SKILL_TAGS = [ + {"name": "Python", "slug": "python", "category": "language", "sort_order": 1}, + {"name": "TypeScript", "slug": "typescript", "category": "language", "sort_order": 2}, + {"name": "FastAPI", "slug": "fastapi", "category": "framework", "sort_order": 3}, + {"name": "React", "slug": "react", "category": "framework", "sort_order": 4}, + {"name": "SQLAlchemy", "slug": "sqlalchemy", "category": "framework", "sort_order": 5}, + {"name": "LangChain", "slug": "langchain", "category": "framework", "sort_order": 6}, + {"name": "LLM 调优", "slug": "llm-tuning", "category": "skill", "sort_order": 7}, + {"name": "RAG 构建", "slug": "rag", "category": "skill", "sort_order": 8}, + {"name": "向量数据库", "slug": "vector-db", "category": "tool", "sort_order": 9}, + {"name": "Ollama", "slug": "ollama", "category": "tool", "sort_order": 10}, + {"name": "Docker", "slug": "docker", "category": "tool", "sort_order": 11}, + {"name": "Git", "slug": "git", "category": "tool", "sort_order": 12}, +] + + +async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int: + """ + 初始化系统预设 AI 开发技能标签。 + 项目启动时自动执行,已存在的标签不会重复创建。 + SQLite 下用 raw INSERT 显式指定 id,避免 NOT NULL constraint failed: skill_tags.id。 + """ + result = await db.execute( + select(SkillTag).where( + and_(SkillTag.tenant_id == tenant_id, SkillTag.is_system == True) + ) + ) + existing = result.scalars().all() + existing_slugs = {t.slug for t in existing} + + to_create = [item for item in SYSTEM_SKILL_TAGS if item["slug"] not in existing_slugs] + if not to_create: + return 0 + + if _is_sqlite(): + # SQLite 下部分表 id 无自增或为 UUID,用 raw SQL 显式指定 id + r = await db.execute(text("SELECT MAX(id) FROM skill_tags")) + max_id = r.scalar() + try: + next_id = int(max_id or 0) + 1 + use_int_id = True + except (TypeError, ValueError): + use_int_id = False + for item in to_create: + if use_int_id: + tag_id = next_id + next_id += 1 + else: + tag_id = str(uuid_module.uuid4()).replace("-", "")[:32] + await db.execute( + text(""" + INSERT INTO skill_tags (id, name, slug, category, description, is_system, sort_order, tenant_id) + VALUES (:id, :name, :slug, :category, :description, :is_system, :sort_order, :tenant_id) + """), + { + "id": tag_id, + "name": item["name"], + "slug": item["slug"], + "category": item.get("category") or "tool", + "description": None, + "is_system": 1, + "sort_order": item.get("sort_order", 0), + "tenant_id": tenant_id, + }, + ) + await db.commit() + return len(to_create) + + created = 0 + for item in to_create: + tag = SkillTag( + tenant_id=tenant_id, + name=item["name"], + slug=item["slug"], + category=item.get("category"), + is_system=True, + sort_order=item.get("sort_order", 0), + ) + db.add(tag) + created += 1 + await db.commit() + return created + + +# -------------------------------------------------------------------------- +# SkillTag CRUD +# -------------------------------------------------------------------------- + + +async def create_skill_tag( + db: AsyncSession, + tenant_id: int, + name: str, + slug: str, + category: Optional[str] = None, + description: Optional[str] = None, + sort_order: int = 0, +) -> SkillTag: + """创建技能标签(仅自定义标签)""" + existing = await db.execute( + select(SkillTag).where( + and_(SkillTag.tenant_id == tenant_id, SkillTag.slug == slug) + ) + ) + if existing.scalar_one_or_none(): + raise ValueError(f"技能标签 slug 已存在: {slug}") + + tag = SkillTag( + tenant_id=tenant_id, + name=name, + slug=slug, + category=category, + description=description, + is_system=False, + sort_order=sort_order, + ) + db.add(tag) + await db.commit() + await db.refresh(tag) + return tag + + +async def list_skill_tags( + db: AsyncSession, + tenant_id: int, + category: Optional[str] = None, + include_system: bool = True, +) -> List[SkillTag]: + """列出技能标签""" + query = select(SkillTag).where(SkillTag.tenant_id == tenant_id) + if category: + query = query.where(SkillTag.category == category) + if not include_system: + query = query.where(SkillTag.is_system == False) + query = query.order_by(SkillTag.sort_order, SkillTag.name) + result = await db.execute(query) + return list(result.scalars().all()) + + +async def get_skill_tag( + db: AsyncSession, tag_id: int, tenant_id: int +) -> Optional[SkillTag]: + """获取单个技能标签""" + result = await db.execute( + select(SkillTag).where( + and_(SkillTag.id == tag_id, SkillTag.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + +async def update_skill_tag( + db: AsyncSession, + tag_id: int, + tenant_id: int, + name: Optional[str] = None, + category: Optional[str] = None, + description: Optional[str] = None, + sort_order: Optional[int] = None, +) -> Optional[SkillTag]: + """更新技能标签(系统标签禁止修改)""" + tag = await get_skill_tag(db, tag_id, tenant_id) + if not tag: + return None + if tag.is_system: + raise ValueError("系统预设技能标签不可修改") + + if name is not None: + tag.name = name + if category is not None: + tag.category = category + if description is not None: + tag.description = description + if sort_order is not None: + tag.sort_order = sort_order + tag.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(tag) + return tag + + +async def delete_skill_tag( + db: AsyncSession, tag_id: int, tenant_id: int +) -> bool: + """删除技能标签(系统标签禁止删除)""" + tag = await get_skill_tag(db, tag_id, tenant_id) + if not tag: + return False + if tag.is_system: + raise ValueError("系统预设技能标签不可删除") + + await db.delete(tag) + await db.commit() + return True + + +# -------------------------------------------------------------------------- +# UserSkillBinding +# -------------------------------------------------------------------------- + + +async def init_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + include_system_only: bool = True, + commit: bool = False, +) -> int: + """ + 初始化用户技能绑定(空基线)。 + + 为用户补齐未绑定的标签,默认 `beginner + proficiency=0`。 + """ + tags_query = select(SkillTag.id).where(SkillTag.tenant_id == tenant_id) + if include_system_only: + tags_query = tags_query.where(SkillTag.is_system == True) + tag_rows = await db.execute(tags_query) + tag_ids = [int(v) for v in tag_rows.scalars().all()] + if not tag_ids: + return 0 + + existing_rows = await db.execute( + select(UserSkillBinding.skill_tag_id).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id.in_(tag_ids), + ) + ) + ) + existing_ids = {int(v) for v in existing_rows.scalars().all()} + + created = 0 + for skill_tag_id in tag_ids: + if skill_tag_id in existing_ids: + continue + db.add( + UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_tag_id, + proficiency_level="beginner", + level="beginner", + proficiency=0, + quality_score=0.0, + efficiency_score=0.0, + ) + ) + created += 1 + + if created: + await db.flush() + if commit: + await db.commit() + return created + + +async def batch_update_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, + bindings: List[dict], +) -> List[UserSkillBinding]: + """ + 批量更新用户技能绑定。 + 若 skill_tag_id 已存在则更新,否则创建。 + """ + result_bindings = [] + + for item in bindings: + skill_tag_id = item.get("skill_tag_id") + if skill_tag_id is None: + continue + if isinstance(skill_tag_id, str): + try: + skill_tag_id = int(skill_tag_id) + except ValueError: + continue + + proficiency = item.get("proficiency", 50) + level = item.get("level", "intermediate") + years = item.get("years_experience") + + existing = await db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id == skill_tag_id, + ) + ) + ) + binding = existing.scalar_one_or_none() + + if binding: + binding.proficiency = proficiency + binding.level = level + binding.years_experience = years + binding.proficiency_level = level + else: + binding = UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_tag_id, + proficiency_level=level, + proficiency=proficiency, + level=level, + years_experience=years, + ) + db.add(binding) + + result_bindings.append(binding) + + await db.commit() + for b in result_bindings: + await db.refresh(b) + return result_bindings + + +async def get_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> List[UserSkillBinding]: + """按用户查询技能绑定""" + result = await db.execute( + select(UserSkillBinding) + .options(selectinload(UserSkillBinding.skill_tag)) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + .order_by(UserSkillBinding.proficiency.desc()) + ) + return list(result.scalars().all()) + + +async def get_skill_tag_user_bindings( + db: AsyncSession, + tenant_id: int, + skill_tag_id: int, +) -> List[UserSkillBinding]: + """按技能标签查询用户绑定""" + result = await db.execute( + select(UserSkillBinding) + .options( + selectinload(UserSkillBinding.user), + selectinload(UserSkillBinding.skill_tag), + ) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.skill_tag_id == skill_tag_id, + ) + ) + .order_by(UserSkillBinding.proficiency.desc()) + ) + return list(result.scalars().all()) + + +async def get_user_skill_task_history( + db: AsyncSession, + tenant_id: int, + user_id: int, + skill_tag_id: int, + limit: int = 50, +) -> List[dict]: + """ + 查询用户某技能下的历史任务。 + + 口径: + - assignee_id == user_id + - required_skill_tag_ids 包含 skill_tag_id + """ + if _is_sqlite(): + # SQLite 下 ARRAY 以 JSON 文本存储,先拉取后在 Python 侧做包含判断。 + result = await db.execute( + select(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + ) + ) + .order_by(Task.updated_at.desc()) + .limit(max(limit * 5, 100)) + ) + tasks = result.scalars().all() + filtered = [ + t + for t in tasks + if isinstance(t.required_skill_tag_ids, list) + and skill_tag_id in t.required_skill_tag_ids + ][:limit] + else: + result = await db.execute( + select(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + Task.required_skill_tag_ids.contains([skill_tag_id]), + ) + ) + .order_by(Task.updated_at.desc()) + .limit(limit) + ) + filtered = list(result.scalars().all()) + + return [ + { + "id": str(t.id), + "title": t.title, + "description": t.description, + "status": t.status, + "priority": t.priority, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours is not None else None, + "project_id": str(t.project_id) if t.project_id is not None else None, + "truth_node_id": str(t.truth_node_id) if t.truth_node_id is not None else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } + for t in filtered + ] + + +# -------------------------------------------------------------------------- +# UserWorkload 自动更新 +# -------------------------------------------------------------------------- + + +async def update_user_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, + total_tasks_delta: int = 0, + completed_tasks_delta: int = 0, + in_progress_delta: int = 0, + estimated_hours_delta: float = 0, + completed_hours_delta: float = 0, +) -> Optional[UserWorkload]: + """ + 更新用户工作量。 + 新 schema: current_assigned_hours, workload_ratio + """ + result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = result.scalar_one_or_none() + + if not workload: + workload = UserWorkload( + tenant_id=tenant_id, + user_id=user_id, + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + db.add(workload) + await db.flush() + + delta = estimated_hours_delta + completed_hours_delta + workload.current_assigned_hours = max( + 0, float(workload.current_assigned_hours or 0) + delta + ) + max_hours = float(workload.max_available_hours_per_week or 40) + workload.workload_ratio = min( + 100.0, (workload.current_assigned_hours / max_hours) * 100 + ) + + await db.commit() + await db.refresh(workload) + + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if user: + user.workload = int(workload.workload_ratio) + await db.commit() + + return workload + + +# -------------------------------------------------------------------------- +# 人员推荐 +# -------------------------------------------------------------------------- + + +async def recommend_personnel( + db: AsyncSession, + tenant_id: int, + required_skills: List[str], + task_description: Optional[str] = None, + task_id: Optional[str] = None, + limit: int = 5, +) -> dict: + """ + 根据所需技能推荐匹配人员。 + 返回匹配度评分、推荐理由等。 + """ + if not required_skills: + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": [], + "recommendations": [], + "total_count": 0, + } + + # 通过 slug 或 name 匹配技能标签 + skill_tags_result = await db.execute( + select(SkillTag).where( + and_( + SkillTag.tenant_id == tenant_id, + (SkillTag.slug.in_(required_skills) | SkillTag.name.in_(required_skills)), + ) + ) + ) + tags = skill_tags_result.scalars().all() + tag_ids = [t.id for t in tags] + + if not tag_ids: + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": required_skills, + "recommendations": [], + "total_count": 0, + } + + # 查询拥有这些技能的用户及熟练度 + bindings_result = await db.execute( + select(UserSkillBinding, SkillTag, User) + .join(SkillTag, UserSkillBinding.skill_tag_id == SkillTag.id) + .join(User, UserSkillBinding.user_id == User.id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.skill_tag_id.in_(tag_ids), + User.status == UserStatus.ACTIVE, + ) + ) + ) + rows = bindings_result.all() + + # 按用户聚合 + user_scores: dict = {} + for b, st, u in rows: + uid = str(u.id) + if uid not in user_scores: + user_scores[uid] = { + "user": u, + "skills": [], + "proficiencies": [], + "match_count": 0, + } + user_scores[uid]["skills"].append(st.name) + user_scores[uid]["proficiencies"].append(b.proficiency) + user_scores[uid]["match_count"] = len(user_scores[uid]["skills"]) + + # 计算匹配度并排序 + recommendations = [] + for uid, data in user_scores.items(): + u = data["user"] + avg_prof = ( + sum(data["proficiencies"]) / len(data["proficiencies"]) + if data["proficiencies"] + else 0 + ) + match_ratio = len(data["skills"]) / len(required_skills) if required_skills else 0 + match_score = min(100, (match_ratio * 60 + avg_prof * 0.4)) + reason = f"匹配技能: {', '.join(data['skills'])}" + recommendations.append( + { + "user_id": uid, + "user_name": u.name, + "match_score": round(match_score, 1), + "reason": reason, + "skill_match_count": data["match_count"], + "avg_proficiency": round(avg_prof, 1), + "workload_rate": float(u.workload) if u.workload else 0, + "recommended_role": u.role.value if hasattr(u.role, "value") else str(u.role), + } + ) + + recommendations.sort(key=lambda x: (-x["match_score"], -x["skill_match_count"])) + recommendations = recommendations[:limit] + + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": required_skills, + "recommendations": recommendations, + "total_count": len(recommendations), + } diff --git a/backend/app/services/sstc_service.py b/backend/app/services/sstc_service.py new file mode 100644 index 00000000..cacab6df --- /dev/null +++ b/backend/app/services/sstc_service.py @@ -0,0 +1,594 @@ +"""SSTC 看板聚合服务""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Iterable + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ( + AlignmentRecord, + CognitiveConflict, + Project, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserProject, + UserWorkload, +) +from ..utils.project_members import ensure_project_default_members + +DONE_STATUSES = {"done", "completed"} +ACTIVE_STATUSES = {"in_progress", "review"} +BLOCKED_STATUSES = {"blocked"} +ALIGNED_STATUSES = {"aligned", "confirmed", "已对齐"} +ALIGNING_STATUSES = {"aligning", "alignment_in_progress", "对齐中"} +ARCHIVED_STATUSES = {"archived", "已归档"} +RELEASE_VERIFIED_STATUSES = {"verified", "已验证"} +RELEASE_LIVE_STATUSES = {"online", "deployed", "已上线"} +RELEASE_RELEASING_STATUSES = {"releasing", "发布中"} + + +def _to_json(value: Any, fallback: dict | None = None) -> dict: + if fallback is None: + fallback = {} + if isinstance(value, dict): + return value + if isinstance(value, str): + import json + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return fallback + + +def _avg(values: Iterable[float]) -> float: + values = list(values) + if not values: + return 0.0 + return float(sum(values) / len(values)) + + +class SSTCService: + def __init__(self, db: AsyncSession, tenant_id: int): + self.db = db + self.tenant_id = tenant_id + + async def get_project(self, project_id: int) -> Project | None: + result = await self.db.execute( + select(Project).where( + and_(Project.id == project_id, Project.tenant_id == self.tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def get_signals(self, project_id: int) -> list[Signal]: + linked_result = await self.db.execute( + select(Signal) + .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) + .where( + and_( + ProjectSignalLink.project_id == project_id, + Signal.tenant_id == self.tenant_id, + ) + ) + .order_by(Signal.created_at.desc()) + ) + linked_rows = list(linked_result.scalars().all()) + merged: dict[int, Signal] = { + int(row.id): row + for row in linked_rows + if getattr(row, "id", None) is not None + } + + fallback_result = await self.db.execute( + select(Signal) + .where( + and_( + Signal.matched_project_id == project_id, + Signal.tenant_id == self.tenant_id, + ) + ) + .order_by(Signal.created_at.desc()) + ) + for row in fallback_result.scalars().all(): + if getattr(row, "id", None) is None: + continue + merged.setdefault(int(row.id), row) + + return sorted( + merged.values(), + key=lambda row: row.created_at or datetime.min, + reverse=True, + ) + + async def get_charters(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_tasks(self, project_id: int) -> list[Task]: + result = await self.db.execute( + select(Task).where( + and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) + ) + ) + return list(result.scalars().all()) + + async def get_releases(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "release", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_reviews(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "review", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_alignment_stats(self, charter_ids: list[int]) -> dict[str, Any]: + if not charter_ids: + return {"score": 0.0, "pending_nodes": 0, "unresolved_conflicts": 0} + + ar_result = await self.db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id.in_(charter_ids), + AlignmentRecord.tenant_id == self.tenant_id, + ) + ) + ) + records = list(ar_result.scalars().all()) + score = _avg([float(r.alignment_score or 0) for r in records]) + + node_confirmed: dict[int, set[int]] = {} + for r in records: + if r.is_confirmed: + node_confirmed.setdefault(int(r.truth_node_id), set()).add(int(r.user_id)) + pending_nodes = len([nid for nid in charter_ids if not node_confirmed.get(nid)]) + + conflict_result = await self.db.execute( + select(func.count(CognitiveConflict.id)).where( + and_( + CognitiveConflict.truth_node_id.in_(charter_ids), + CognitiveConflict.tenant_id == self.tenant_id, + CognitiveConflict.status != "resolved", + ) + ) + ) + unresolved_conflicts = int(conflict_result.scalar() or 0) + return { + "score": round(score, 2), + "pending_nodes": pending_nodes, + "unresolved_conflicts": unresolved_conflicts, + } + + async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]: + if not assignee_ids: + return { + "avg_ratio": 0.0, + "overloaded_count": 0, + "idle_count": 0, + "member_count": 0, + } + + result = await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(assignee_ids), + ) + ) + ) + workloads = list(result.scalars().all()) + ratios = [] + for row in workloads: + ratio = float(row.workload_ratio or 0) + if ratio > 1.5: + ratio = ratio / 100.0 + ratios.append(ratio) + avg_ratio = _avg(ratios) + overloaded_count = len([x for x in ratios if x > 1.0]) + idle_count = len([x for x in ratios if x < 0.3]) + return { + "avg_ratio": round(avg_ratio, 2), + "overloaded_count": overloaded_count, + "idle_count": idle_count, + "member_count": len(assignee_ids), + } + + @staticmethod + def _task_metrics(tasks: list[Task]) -> dict[str, Any]: + total = len(tasks) + done = len([t for t in tasks if (t.status or "").lower() in DONE_STATUSES]) + blocked = len([t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES]) + in_progress = len([t for t in tasks if (t.status or "").lower() in ACTIVE_STATUSES]) + today_done = len( + [ + t + for t in tasks + if (t.status or "").lower() in DONE_STATUSES + and t.completed_at + and t.completed_at.date() == datetime.now().date() + ] + ) + overdue = len( + [ + t + for t in tasks + if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES + ] + ) + return { + "total": total, + "done": done, + "blocked": blocked, + "in_progress": in_progress, + "today_done": today_done, + "overdue": overdue, + "progress": round((done / total) * 100, 2) if total else 0, + } + + async def build_overview(self, project_id: int) -> dict[str, Any]: + project = await self.get_project(project_id) + if not project: + return {} + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + charters_ids = [int(c.id) for c in charters] + alignment = await self.get_alignment_stats(charters_ids) + metrics = self._task_metrics(tasks) + member_ids = await self.get_project_member_ids(project_id) + workload = await self.get_workload_health(member_ids) + chain_id = str(charters[0].id) if charters else None + return { + "project": { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + "status": project.status, + "owner_id": str(project.owner_id) if project.owner_id else None, + "created_at": project.created_at.isoformat() if project.created_at else None, + "chain_id": chain_id, + "signal_count": len(signals), + "charter_count": len(charters), + "task_count": len(tasks), + }, + "chain_id": chain_id, + "alignment_score": alignment["score"], + "pending_alignment_nodes": alignment["pending_nodes"], + "unresolved_conflicts": alignment["unresolved_conflicts"], + "task_progress": metrics, + "workload_health": workload, + } + + async def build_stages(self, project_id: int) -> list[dict[str, Any]]: + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + releases = await self.get_releases(project_id) + reviews = await self.get_reviews(project_id) + + has_p0 = any((s.priority or "").lower() == "p0" for s in signals) + has_pending_signal = any(not bool(s.processed) for s in signals) + signal_status = "已同步" + if has_p0 or has_pending_signal: + signal_status = "待处理" + + latest_charter = charters[0] if charters else None + charter_status_raw = (latest_charter.status if latest_charter else "").lower() + if not latest_charter: + charter_status = "草稿" + elif charter_status_raw in ALIGNING_STATUSES: + charter_status = "对齐中" + elif charter_status_raw in ARCHIVED_STATUSES: + charter_status = "已归档" + elif charter_status_raw in ALIGNED_STATUSES: + charter_status = "已对齐" + else: + charter_status = "草稿" + charter_aligned = charter_status == "已对齐" + + if not charter_aligned: + task_status = "未解锁" + elif not tasks: + task_status = "待确认" + elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): + task_status = "已确认" + else: + task_status = "待确认" + + if not tasks: + execution_status = "未开始" + elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): + execution_status = "已完成" + elif any((t.status or "").lower() in (ACTIVE_STATUSES | BLOCKED_STATUSES) for t in tasks): + execution_status = "进行中" + else: + execution_status = "未开始" + metrics = self._task_metrics(tasks) + execution_done = execution_status == "已完成" + release_pre_unlocked = charter_aligned and metrics["done"] > 0 + + latest_release = releases[0] if releases else None + release_raw = (latest_release.status if latest_release else "").lower() + if not release_pre_unlocked: + release_status = "未解锁" + elif not execution_done and not latest_release: + release_status = "可预发布" + elif not latest_release: + release_status = "待发布" + elif release_raw in RELEASE_VERIFIED_STATUSES: + release_status = "已验证" + elif release_raw in RELEASE_RELEASING_STATUSES: + release_status = "发布中" + elif release_raw in RELEASE_LIVE_STATUSES: + release_status = "已上线" + else: + release_status = "可预发布" if not execution_done else "待发布" + release_verified = release_status == "已验证" + review_pre_unlocked = release_status in {"已上线", "已验证", "发布中"} + + latest_review = reviews[0] if reviews else None + review_raw = (latest_review.status if latest_review else "").lower() + if not review_pre_unlocked: + review_status = "未解锁" + elif not release_verified and not latest_review: + review_status = "可阶段复盘" + elif not latest_review: + review_status = "草稿" + elif review_raw in ARCHIVED_STATUSES: + review_status = "已归档" + else: + review_status = "草稿" + + return [ + { + "key": "signal", + "label": "信号源", + "status": signal_status, + "summary": f"{len(signals)} 条有效信号", + "locked": False, + "editable": True, + "risk_count": int(has_p0), + }, + { + "key": "charter", + "label": "需求宪章", + "status": charter_status, + "summary": f"{len(charters)} 份宪章", + "locked": False, + "editable": True, + "risk_count": 1 if charter_status == "对齐中" else 0, + }, + { + "key": "task", + "label": "任务拆解", + "status": task_status, + "summary": f"{len(tasks)} 个任务", + "locked": not charter_aligned, + "editable": charter_aligned, + "risk_count": metrics["blocked"], + }, + { + "key": "execution", + "label": "执行落地", + "status": execution_status, + "summary": f"完成率 {metrics['progress']}%", + "locked": not charter_aligned, + "editable": charter_aligned, + "risk_count": metrics["blocked"] + metrics["overdue"], + }, + { + "key": "release", + "label": "发布验证", + "status": release_status, + "summary": f"{len(releases)} 次发布", + "locked": not release_pre_unlocked, + "editable": release_pre_unlocked, + "risk_count": metrics["overdue"], + }, + { + "key": "review", + "label": "复盘沉淀", + "status": review_status, + "summary": f"{len(reviews)} 份复盘", + "locked": not review_pre_unlocked, + "editable": review_pre_unlocked, + "risk_count": 0, + }, + ] + + async def build_risks(self, project_id: int) -> dict[str, Any]: + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + charter_ids = [int(c.id) for c in charters] + conflict_rows = [] + if charter_ids: + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + and_( + CognitiveConflict.truth_node_id.in_(charter_ids), + CognitiveConflict.tenant_id == self.tenant_id, + CognitiveConflict.status != "resolved", + ) + ) + ) + conflict_rows = list(conflict_result.scalars().all()) + + blocked_tasks = [t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES] + overdue_tasks = [ + t + for t in tasks + if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES + ] + risk_items = [ + { + "id": f"task-blocked-{t.id}", + "type": "task_blocked", + "priority": "high", + "title": f"任务阻塞:{t.title}", + "entity_id": str(t.id), + } + for t in blocked_tasks + ] + [ + { + "id": f"task-overdue-{t.id}", + "type": "task_overdue", + "priority": "medium", + "title": f"任务逾期:{t.title}", + "entity_id": str(t.id), + } + for t in overdue_tasks + ] + + conflicts = [ + { + "id": str(c.id), + "type": c.conflict_type, + "priority": "high", + "title": c.description[:80], + "entity_id": str(c.truth_node_id), + } + for c in conflict_rows + ] + return { + "conflicts": conflicts, + "risks": risk_items, + "summary": { + "conflict_count": len(conflicts), + "risk_count": len(risk_items), + "blocked_task_count": len(blocked_tasks), + "overdue_task_count": len(overdue_tasks), + }, + } + + async def build_chain(self, project_id: int) -> dict[str, Any]: + project = await self.get_project(project_id) + if not project: + return {} + + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + + tasks_by_charter: dict[str, list[dict[str, Any]]] = {} + for c in charters: + tasks_by_charter[str(c.id)] = [ + { + "id": str(t.id), + "title": t.title, + "status": t.status, + "priority": t.priority, + "charter_id": str(c.id), + "description": t.description, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + } + for t in tasks + if int(t.truth_node_id) == int(c.id) + ] + + return { + "project": { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + }, + "signals": [ + { + "id": str(s.id), + "source": s.source, + "content": (s.content or "")[:240], + "impact_score": s.impact_score, + "priority": s.priority, + "processed": s.processed, + } + for s in signals + ], + "charters": [ + { + "id": str(c.id), + "title": c.title, + "content": _to_json(c.content), + "signal_ids": _to_json(c.content).get("signal_ids", []), + "status": c.status, + "version": c.version, + "parent_id": str(c.parent_id) if c.parent_id else None, + } + for c in charters + ], + "tasks_by_charter": tasks_by_charter, + } + + async def has_member_confirmed(self, charter_id: int, user_id: int) -> bool: + result = await self.db.execute( + select(func.count(AlignmentRecord.id)).where( + and_( + AlignmentRecord.tenant_id == self.tenant_id, + AlignmentRecord.truth_node_id == charter_id, + AlignmentRecord.user_id == user_id, + AlignmentRecord.is_confirmed.is_(True), + ) + ) + ) + return int(result.scalar() or 0) > 0 + + async def get_project_member_ids(self, project_id: int) -> list[int]: + member_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.project_id == project_id, + UserProject.tenant_id == self.tenant_id, + ) + ) + ) + member_ids = {int(x) for x in member_result.scalars().all() if x is not None} + project = await self.get_project(project_id) + if not member_ids and project: + member_ids = set( + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project_id), + owner_id=int(project.owner_id) if project.owner_id else None, + assigned_role=project.assigned_role, + ) + ) + await self.db.commit() + if project and project.owner_id: + member_ids.add(int(project.owner_id)) + return sorted(member_ids) diff --git a/backend/app/services/symbol_index_service.py b/backend/app/services/symbol_index_service.py new file mode 100644 index 00000000..a8a9a1d6 --- /dev/null +++ b/backend/app/services/symbol_index_service.py @@ -0,0 +1,776 @@ +"""Symbol 索引服务。 + +目标: +1. 对代码库执行增量符号扫描(def/ref) +2. 提供符号查询、详情、概览 +3. 为 GraphRAG / Agent 检索提供结构化工具层 +""" + +from __future__ import annotations + +import ast +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import SymbolDefinition, SymbolReference + + +TS_SYMBOL_RE = re.compile( + r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +TS_IMPORT_RE = re.compile(r"^\s*import\s+.*?\s+from\s+['\"]([^'\"]+)['\"]", re.MULTILINE) +TS_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(") +TS_KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "new", "typeof", "await"} + + +@dataclass(slots=True) +class ParsedSymbol: + symbol_type: str + symbol_name: str + qualified_name: str + start_line: int + end_line: int + signature: str + docstring: str + payload: dict[str, Any] + + +@dataclass(slots=True) +class ParsedReference: + relation_type: str + target_symbol_name: str + target_qualified_name: str + source_qualified_name: str + start_line: int + end_line: int + payload: dict[str, Any] + + +class SymbolIndexService: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + @staticmethod + def _default_scan_roots(*, backend_root: Path, workspace_root: Path) -> list[Path]: + roots: list[Path] = [] + for candidate in [ + (backend_root / "app").resolve(), + (workspace_root / "frontend" / "src").resolve(), + ]: + if candidate.exists() and candidate.is_dir(): + roots.append(candidate) + return roots + + @staticmethod + def _to_rel_path(*, file_path: Path, workspace_root: Path, backend_root: Path) -> str: + try: + return str(file_path.resolve().relative_to(workspace_root)) + except Exception: + try: + return str(file_path.resolve().relative_to(backend_root)) + except Exception: + return str(file_path.resolve()) + + async def sync_codebase_symbols( + self, + *, + base_dir: Optional[str] = None, + max_files: int = 500, + force_reindex: bool = False, + ) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + workspace_root = backend_root.parent + if base_dir: + root = Path(base_dir).resolve() + if not root.exists() or not root.is_dir(): + raise ValueError(f"symbol scan path not found: {root}") + scan_roots = [root] + else: + scan_roots = self._default_scan_roots(backend_root=backend_root, workspace_root=workspace_root) + if not scan_roots: + raise ValueError("symbol scan path not found: backend/app and frontend/src") + + allowed = {".py", ".ts", ".tsx", ".js", ".jsx"} + files: list[Path] = [] + for root in scan_roots: + files.extend([p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in allowed]) + files.sort(key=lambda x: str(x)) + selected = files[: max(1, int(max_files))] + + stats = { + "root": str(scan_roots[0]), + "roots": [str(x) for x in scan_roots], + "candidate_files": len(files), + "selected_files": len(selected), + "changed_files": 0, + "skipped_unchanged_files": 0, + "indexed_symbols": 0, + "indexed_references": 0, + "failed_files": 0, + "errors": [], + } + + for file_path in selected: + language = self._detect_language(file_path) + if not language: + continue + try: + text = file_path.read_text(encoding="utf-8", errors="ignore") + rel_path = self._to_rel_path(file_path=file_path, workspace_root=workspace_root, backend_root=backend_root) + file_hash = hashlib.sha1(text.encode("utf-8")).hexdigest() + mtime = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc).replace(tzinfo=None) + if not force_reindex: + latest_hash = await self._get_latest_file_hash(rel_path=rel_path) + if latest_hash and latest_hash == file_hash: + stats["skipped_unchanged_files"] += 1 + continue + + symbols, refs = self._parse_file( + file_path=rel_path, + language=language, + content=text, + ) + s_count, r_count = await self._replace_file_index( + file_path=rel_path, + language=language, + file_hash=file_hash, + source_mtime=mtime, + symbols=symbols, + refs=refs, + ) + stats["changed_files"] += 1 + stats["indexed_symbols"] += s_count + stats["indexed_references"] += r_count + except Exception as exc: + stats["failed_files"] += 1 + stats["errors"].append(f"{str(file_path)}: {str(exc)[:180]}") + + await self.db.commit() + stats["errors"] = stats["errors"][:20] + stats["finished_at"] = datetime.now(timezone.utc).isoformat() + return stats + + async def search_symbols( + self, + *, + query: str, + language: Optional[str] = None, + symbol_type: Optional[str] = None, + limit: int = 30, + ) -> dict[str, Any]: + text = str(query or "").strip() + if not text: + return {"count": 0, "items": []} + + like = f"%{text}%" + stmt = select(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.status == "active", + or_( + SymbolDefinition.symbol_name.ilike(like), + SymbolDefinition.qualified_name.ilike(like), + SymbolDefinition.file_path.ilike(like), + ), + ) + ) + if language: + stmt = stmt.where(SymbolDefinition.language == str(language).strip().lower()[:20]) + if symbol_type: + stmt = stmt.where(SymbolDefinition.symbol_type == str(symbol_type).strip().lower()[:32]) + rows = ( + await self.db.execute( + stmt.order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()).limit(max(1, min(int(limit), 200))) + ) + ).scalars().all() + return { + "count": len(rows), + "items": [self._serialize_symbol(row) for row in rows], + } + + async def get_symbol_detail(self, *, symbol_id: int) -> dict[str, Any]: + symbol = ( + await self.db.execute( + select(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.id == int(symbol_id), + ) + ) + ) + ).scalar_one_or_none() + if symbol is None: + raise ValueError("symbol_not_found") + + refs = ( + await self.db.execute( + select(SymbolReference).where( + and_( + SymbolReference.tenant_id == self.tenant_id, + SymbolReference.source_symbol_id == int(symbol.id), + ) + ) + .order_by(SymbolReference.id.desc()) + .limit(120) + ) + ).scalars().all() + + inbound_refs = ( + await self.db.execute( + select(SymbolReference) + .where( + and_( + SymbolReference.tenant_id == self.tenant_id, + or_( + SymbolReference.target_symbol_name == str(symbol.symbol_name), + SymbolReference.target_qualified_name == str(symbol.qualified_name or ""), + ), + ) + ) + .order_by(SymbolReference.id.desc()) + .limit(120) + ) + ).scalars().all() + + return { + "symbol": self._serialize_symbol(symbol), + "outbound_refs": [self._serialize_ref(row) for row in refs], + "inbound_refs": [self._serialize_ref(row) for row in inbound_refs], + } + + async def get_overview( + self, + *, + top_n_files: int = 10, + auto_sync_if_empty: bool = False, + base_dir: Optional[str] = None, + ) -> dict[str, Any]: + total = int( + ( + await self.db.execute( + select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + if total == 0 and auto_sync_if_empty: + await self.sync_codebase_symbols(base_dir=base_dir, max_files=300, force_reindex=False) + total = int( + ( + await self.db.execute( + select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + + language_groups = ( + await self.db.execute( + select(SymbolDefinition.language, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.language) + ) + ).all() + type_groups = ( + await self.db.execute( + select(SymbolDefinition.symbol_type, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.symbol_type) + ) + ).all() + file_groups = ( + await self.db.execute( + select(SymbolDefinition.file_path, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.file_path) + .order_by(func.count(SymbolDefinition.id).desc()) + .limit(max(1, min(int(top_n_files), 50))) + ) + ).all() + ref_total = int( + ( + await self.db.execute( + select(func.count(SymbolReference.id)).where(SymbolReference.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + indexed_files_total = int( + ( + await self.db.execute( + select(func.count(func.distinct(SymbolDefinition.file_path))).where( + SymbolDefinition.tenant_id == self.tenant_id + ) + ) + ).scalar_one() + or 0 + ) + return { + "tenant_id": self.tenant_id, + "indexed_files": indexed_files_total, + "symbol_total": total, + "reference_total": ref_total, + "python_symbols": int(dict(language_groups).get("python", 0) or 0), + "typescript_symbols": int(dict(language_groups).get("typescript", 0) or 0), + "symbols_by_language": {str(k): int(v or 0) for k, v in language_groups}, + "symbols_by_type": {str(k): int(v or 0) for k, v in type_groups}, + "top_modules": [ + {"module": str(path), "symbols": int(cnt or 0)} + for path, cnt in file_groups + ], + "snapshot_at": datetime.now(timezone.utc).isoformat(), + } + + async def _get_latest_file_hash(self, *, rel_path: str) -> Optional[str]: + row = ( + await self.db.execute( + select(SymbolDefinition.file_hash).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.file_path == rel_path, + ) + ) + .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + return str(row).strip() if row else None + + async def _replace_file_index( + self, + *, + file_path: str, + language: str, + file_hash: str, + source_mtime: datetime, + symbols: list[ParsedSymbol], + refs: list[ParsedReference], + ) -> tuple[int, int]: + await self.db.execute( + delete(SymbolReference).where( + and_( + SymbolReference.tenant_id == self.tenant_id, + SymbolReference.file_path == file_path, + ) + ) + ) + await self.db.execute( + delete(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.file_path == file_path, + ) + ) + ) + + created_symbols: list[SymbolDefinition] = [] + qn_to_id: dict[str, int] = {} + name_to_id: dict[str, int] = {} + for item in symbols: + row = SymbolDefinition( + tenant_id=self.tenant_id, + file_path=file_path, + language=language, + symbol_type=item.symbol_type[:32], + symbol_name=item.symbol_name[:255], + qualified_name=item.qualified_name[:700] if item.qualified_name else None, + start_line=int(item.start_line) if item.start_line > 0 else None, + end_line=int(item.end_line) if item.end_line > 0 else None, + signature=item.signature[:1000] if item.signature else None, + docstring=item.docstring[:2000] if item.docstring else None, + file_hash=file_hash, + source_mtime=source_mtime, + status="active", + payload=item.payload, + last_indexed_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) + self.db.add(row) + created_symbols.append(row) + await self.db.flush() + for row in created_symbols: + if row.qualified_name: + qn_to_id[str(row.qualified_name)] = int(row.id) + name_to_id[str(row.symbol_name)] = int(row.id) + + ref_count = 0 + # DB 层有 (tenant_id,file_path,relation_type,target_symbol_name,start_line,source_symbol_id) 唯一约束, + # 这里先去重,避免同一行重复调用(例如链式调用)触发批量写入冲突。 + ref_seen_keys: set[tuple[str, str, Optional[int], Optional[int]]] = set() + for ref in refs: + source_id = None + source_qn = str(ref.source_qualified_name or "").strip() + if source_qn: + source_id = qn_to_id.get(source_qn) + if source_id is None: + source_tail = source_qn.split(".")[-1] + source_id = name_to_id.get(source_tail) + relation_type = ref.relation_type[:32] + target_symbol_name = ref.target_symbol_name[:255] + start_line = int(ref.start_line) if ref.start_line > 0 else None + dedupe_key = ( + relation_type, + target_symbol_name, + start_line, + int(source_id) if source_id is not None else None, + ) + if dedupe_key in ref_seen_keys: + continue + ref_seen_keys.add(dedupe_key) + row = SymbolReference( + tenant_id=self.tenant_id, + source_symbol_id=int(source_id) if source_id is not None else None, + file_path=file_path, + relation_type=relation_type, + target_symbol_name=target_symbol_name, + target_qualified_name=ref.target_qualified_name[:700] if ref.target_qualified_name else None, + start_line=start_line, + end_line=int(ref.end_line) if ref.end_line > 0 else None, + payload=ref.payload, + ) + self.db.add(row) + ref_count += 1 + await self.db.flush() + return (len(created_symbols), ref_count) + + def _parse_file( + self, + *, + file_path: str, + language: str, + content: str, + ) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + if language == "python": + return self._parse_python_file(file_path=file_path, content=content) + return self._parse_typescript_file(file_path=file_path, content=content) + + def _parse_python_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + text = str(content or "") + if not text.strip(): + return ([], []) + try: + tree = ast.parse(text) + except Exception: + return ([], []) + symbols: list[ParsedSymbol] = [] + refs: list[ParsedReference] = [] + + class Visitor(ast.NodeVisitor): + def __init__(self): + self.class_stack: list[str] = [] + self.func_stack: list[str] = [] + + def _current_source(self) -> str: + if self.func_stack: + return self.func_stack[-1] + if self.class_stack: + return self.class_stack[-1] + return "" + + def _qualify(self, name: str) -> str: + chain = [*self.class_stack] + if self.func_stack: + chain.extend(self.func_stack[-1].split(".")[len(self.class_stack):]) + chain.append(name) + uniq: list[str] = [] + for item in chain: + if item and (not uniq or uniq[-1] != item): + uniq.append(item) + return ".".join(uniq) if uniq else name + + def visit_ClassDef(self, node: ast.ClassDef) -> Any: + qualified = ".".join([*self.class_stack, node.name]) if self.class_stack else node.name + symbols.append( + ParsedSymbol( + symbol_type="class", + symbol_name=node.name, + qualified_name=qualified, + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + signature=f"class {node.name}", + docstring=str(ast.get_docstring(node) or ""), + payload={"file_path": file_path}, + ) + ) + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> Any: + self._visit_function_node(node=node, is_async=False) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any: + self._visit_function_node(node=node, is_async=True) + + def _visit_function_node(self, *, node: Any, is_async: bool) -> None: + if self.class_stack: + qualified = ".".join([*self.class_stack, node.name]) + symbol_type = "method" + else: + qualified = node.name + symbol_type = "function" + arg_names = [] + for arg in list(node.args.posonlyargs) + list(node.args.args): + arg_names.append(str(arg.arg)) + if node.args.vararg is not None: + arg_names.append(f"*{node.args.vararg.arg}") + for arg in node.args.kwonlyargs: + arg_names.append(str(arg.arg)) + if node.args.kwarg is not None: + arg_names.append(f"**{node.args.kwarg.arg}") + prefix = "async def" if is_async else "def" + signature = f"{prefix} {node.name}({', '.join(arg_names)})" + symbols.append( + ParsedSymbol( + symbol_type=symbol_type, + symbol_name=node.name, + qualified_name=qualified, + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + signature=signature[:1000], + docstring=str(ast.get_docstring(node) or ""), + payload={"file_path": file_path}, + ) + ) + self.func_stack.append(qualified) + self.generic_visit(node) + self.func_stack.pop() + + def visit_Import(self, node: ast.Import) -> Any: + source = self._current_source() + for alias in node.names: + target = str(alias.name or "").strip() + if not target: + continue + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=target.split(".")[-1][:255], + target_qualified_name=target[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={"alias": str(alias.asname or "")[:255] if alias.asname else None}, + ) + ) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: + source = self._current_source() + module = str(node.module or "").strip() + for alias in node.names: + name = str(alias.name or "").strip() + if not name: + continue + target_qn = f"{module}.{name}" if module else name + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=name[:255], + target_qualified_name=target_qn[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={"module": module[:255] if module else None}, + ) + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> Any: + source = self._current_source() + target = "" + if isinstance(node.func, ast.Name): + target = str(node.func.id or "").strip() + elif isinstance(node.func, ast.Attribute): + target = str(node.func.attr or "").strip() + if target: + refs.append( + ParsedReference( + relation_type="calls", + target_symbol_name=target[:255], + target_qualified_name=target[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={}, + ) + ) + self.generic_visit(node) + + Visitor().visit(tree) + return (symbols[:5000], refs[:12000]) + + def _parse_typescript_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + text = str(content or "") + if not text.strip(): + return ([], []) + symbols: list[ParsedSymbol] = [] + refs: list[ParsedReference] = [] + lines = text.splitlines() + line_symbol_map: dict[int, str] = {} + + for idx, line in enumerate(lines, start=1): + match = re.match( + r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", + line, + ) + if not match: + continue + symbol_type = str(match.group(1) or "").strip().lower() + name = str(match.group(2) or "").strip() + if not name: + continue + signature = line.strip() + symbols.append( + ParsedSymbol( + symbol_type=symbol_type, + symbol_name=name, + qualified_name=name, + start_line=idx, + end_line=idx, + signature=signature[:1000], + docstring="", + payload={"file_path": file_path}, + ) + ) + line_symbol_map[idx] = name + + if "frontend/src/router/" in str(file_path).replace("\\", "/"): + for idx, line in enumerate(lines, start=1): + m = re.search( + r'path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)', + line, + ) + if not m: + continue + route_path = str(m.group(1) or "").strip() + component = str(m.group(2) or "").strip() + if not route_path: + continue + clean_route = route_path if route_path.startswith("/") else f"/{route_path}" + symbol_name = f"route:{clean_route}" + symbols.append( + ParsedSymbol( + symbol_type="route", + symbol_name=symbol_name[:255], + qualified_name=symbol_name[:700], + start_line=idx, + end_line=idx, + signature=line.strip()[:1000], + docstring=f"component:{component}", + payload={"file_path": file_path, "route_path": clean_route, "component": component}, + ) + ) + + if str(file_path).replace("\\", "/").endswith("components/layout/Sidebar.tsx"): + for idx, line in enumerate(lines, start=1): + m = re.search(r'to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', line) + if not m: + continue + route_path = str(m.group(1) or "").strip() + label = str(m.group(2) or "").strip() + if not route_path or not label: + continue + clean_route = route_path if route_path.startswith("/") else f"/{route_path}" + symbol_name = f"feature:{label}" + symbols.append( + ParsedSymbol( + symbol_type="feature", + symbol_name=symbol_name[:255], + qualified_name=f"{label}@{clean_route}"[:700], + start_line=idx, + end_line=idx, + signature=line.strip()[:1000], + docstring=f"menu:{clean_route}", + payload={"file_path": file_path, "feature_label": label, "route_path": clean_route}, + ) + ) + + imports = TS_IMPORT_RE.finditer(text) + for match in imports: + module = str(match.group(1) or "").strip() + if not module: + continue + start = text[: match.start()].count("\n") + 1 + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=module.split("/")[-1][:255], + target_qualified_name=module[:700], + source_qualified_name="", + start_line=start, + end_line=start, + payload={}, + ) + ) + + for idx, line in enumerate(lines, start=1): + source_symbol = "" + candidate_lines = [x for x in line_symbol_map.keys() if x <= idx] + if candidate_lines: + source_symbol = line_symbol_map[max(candidate_lines)] + for call in TS_CALL_RE.finditer(line): + name = str(call.group(1) or "").strip() + if not name or name.lower() in TS_KEYWORDS: + continue + refs.append( + ParsedReference( + relation_type="calls", + target_symbol_name=name[:255], + target_qualified_name=name[:700], + source_qualified_name=source_symbol[:700], + start_line=idx, + end_line=idx, + payload={}, + ) + ) + return (symbols[:5000], refs[:12000]) + + @staticmethod + def _detect_language(path: Path) -> str: + ext = path.suffix.lower() + if ext == ".py": + return "python" + if ext in {".ts", ".tsx", ".js", ".jsx"}: + return "typescript" + return "" + + @staticmethod + def _serialize_symbol(row: SymbolDefinition) -> dict[str, Any]: + return { + "id": int(row.id), + "file_path": str(row.file_path or ""), + "language": str(row.language or ""), + "symbol_type": str(row.symbol_type or ""), + "symbol_name": str(row.symbol_name or ""), + "qualified_name": str(row.qualified_name or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + "signature": str(row.signature or ""), + "docstring": str(row.docstring or ""), + "status": str(row.status or ""), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "last_indexed_at": row.last_indexed_at.isoformat() if row.last_indexed_at else None, + } + + @staticmethod + def _serialize_ref(row: SymbolReference) -> dict[str, Any]: + return { + "id": int(row.id), + "source_symbol_id": int(row.source_symbol_id) if row.source_symbol_id is not None else None, + "file_path": str(row.file_path or ""), + "relation_type": str(row.relation_type or ""), + "target_symbol_name": str(row.target_symbol_name or ""), + "target_qualified_name": str(row.target_qualified_name or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/app/services/task_persistence.py b/backend/app/services/task_persistence.py new file mode 100644 index 00000000..a4e0ca3b --- /dev/null +++ b/backend/app/services/task_persistence.py @@ -0,0 +1,324 @@ +"""共享任务持久化规范。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import SkillTag, Task +from ..utils.title import compact_title +from . import workload_service +from .operation_log_service import log_operation + +TASK_PRIORITY_MAP = { + "p0": "high", + "critical": "high", + "high": "high", + "p1": "medium", + "medium": "medium", + "normal": "medium", + "default": "medium", + "p2": "low", + "p3": "low", + "low": "low", +} +TASK_STATUS_MAP = { + "pending": "todo", + "todo": "todo", + "open": "todo", + "new": "todo", + "in_progress": "in_progress", + "doing": "in_progress", + "processing": "in_progress", + "review": "review", + "qa": "review", + "testing": "review", + "done": "done", + "completed": "done", + "blocked": "blocked", + "closed": "closed", +} +SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} + + +@dataclass(slots=True) +class SkillTagResolution: + input_names: list[str] + matched_ids: list[int] + matched_items: list[dict[str, Any]] + unmatched_names: list[str] + + +@dataclass(slots=True) +class TaskCreateDraft: + title: str + description: Optional[str] = None + assignee_id: Optional[int] = None + required_skill_tag_ids: list[int] = field(default_factory=list) + priority: str = "medium" + status: str = "todo" + estimated_hours: Optional[float] = None + due_date: Optional[datetime] = None + actual_hours: Optional[float] = None + quality_score: Optional[float] = None + ai_recommended_assignees: Optional[dict[str, Any]] = None + + +def normalize_task_priority(value: Any, *, default: str = "medium") -> str: + key = str(value or default).strip().lower() + return TASK_PRIORITY_MAP.get(key, TASK_PRIORITY_MAP.get(default, "medium")) + + +def normalize_task_status(value: Any, *, default: str = "todo") -> str: + key = str(value or default).strip().lower() + normalized = TASK_STATUS_MAP.get(key, TASK_STATUS_MAP.get(default, "todo")) + if normalized not in SUPPORTED_TASK_STATUSES: + return default + return normalized + + +def normalize_estimated_hours(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + hours = round(float(value), 2) + except (TypeError, ValueError) as exc: + raise ValueError("invalid estimated_hours") from exc + if hours < 0 or hours > 999.99: + raise ValueError("invalid estimated_hours") + return hours + + +def normalize_due_date(value: Any) -> Optional[datetime]: + if value is None or value == "": + return None + if isinstance(value, datetime): + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + text = str(value).strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise ValueError("invalid due_date") from exc + + +def normalize_int_list(values: Any) -> list[int]: + if values is None: + return [] + if not isinstance(values, (list, tuple, set)): + values = [values] + normalized: list[int] = [] + seen: set[int] = set() + for raw in values: + if isinstance(raw, bool): + continue + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + continue + if value <= 0 or value in seen: + continue + seen.add(value) + normalized.append(value) + return normalized + + +def normalize_skill_names(values: Any) -> list[str]: + if values is None: + return [] + if not isinstance(values, (list, tuple, set)): + values = [values] + normalized: list[str] = [] + seen: set[str] = set() + for raw in values: + text = str(raw or "").strip() + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + normalized.append(text[:120]) + return normalized + + +def build_task_create_draft( + *, + title: str, + description: Optional[str] = None, + assignee_id: Optional[int] = None, + required_skill_tag_ids: Any = None, + priority: Any = "medium", + status: Any = "todo", + estimated_hours: Any = None, + due_date: Any = None, + actual_hours: Any = None, + quality_score: Any = None, + ai_recommended_assignees: Optional[dict[str, Any]] = None, +) -> TaskCreateDraft: + normalized_title = compact_title(str(title or "").strip(), max_len=32) + if not normalized_title: + raise ValueError("task title is empty") + return TaskCreateDraft( + title=normalized_title, + description=str(description or "").strip() or None, + assignee_id=int(assignee_id) if assignee_id is not None else None, + required_skill_tag_ids=normalize_int_list(required_skill_tag_ids), + priority=normalize_task_priority(priority), + status=normalize_task_status(status), + estimated_hours=normalize_estimated_hours(estimated_hours), + due_date=normalize_due_date(due_date), + actual_hours=normalize_estimated_hours(actual_hours), + quality_score=None if quality_score in {None, ""} else float(quality_score), + ai_recommended_assignees=ai_recommended_assignees, + ) + + +async def resolve_skill_tag_ids_by_names( + db: AsyncSession, + *, + tenant_id: int, + skill_names: Any, +) -> SkillTagResolution: + input_names = normalize_skill_names(skill_names) + if not input_names: + return SkillTagResolution( + input_names=[], + matched_ids=[], + matched_items=[], + unmatched_names=[], + ) + + rows = ( + await db.execute( + select(SkillTag).where(SkillTag.tenant_id == int(tenant_id)) + ) + ).scalars().all() + if not rows: + return SkillTagResolution( + input_names=input_names, + matched_ids=[], + matched_items=[], + unmatched_names=input_names, + ) + + exact: dict[str, SkillTag] = {} + fuzzy: list[tuple[str, SkillTag]] = [] + for row in rows: + name = str(row.name or "").strip().lower() + slug = str(row.slug or "").strip().lower() + if name: + exact[name] = row + fuzzy.append((name, row)) + if slug: + exact[slug] = row + fuzzy.append((slug, row)) + + matched_ids: list[int] = [] + matched_items: list[dict[str, Any]] = [] + unmatched_names: list[str] = [] + seen_ids: set[int] = set() + for name in input_names: + key = name.lower() + matched = exact.get(key) + if matched is None: + for candidate, row in fuzzy: + if key in candidate or candidate in key: + matched = row + break + if matched is None: + unmatched_names.append(name) + continue + skill_id = int(matched.id) + if skill_id not in seen_ids: + seen_ids.add(skill_id) + matched_ids.append(skill_id) + matched_items.append( + { + "input_name": name, + "skill_tag_id": skill_id, + "skill_tag_name": str(matched.name or "").strip(), + "skill_tag_slug": str(matched.slug or "").strip(), + } + ) + + return SkillTagResolution( + input_names=input_names, + matched_ids=matched_ids, + matched_items=matched_items, + unmatched_names=unmatched_names, + ) + + +async def persist_task( + db: AsyncSession, + *, + tenant_id: int, + truth_node_id: int, + project_id: int, + draft: TaskCreateDraft, + actor_user_id: Optional[int] = None, + action: str = "task.create", + remark: Optional[str] = None, + commit: bool = False, + refresh_workload: bool = False, +) -> Task: + task = Task( + tenant_id=int(tenant_id), + truth_node_id=int(truth_node_id), + project_id=int(project_id), + assignee_id=draft.assignee_id, + title=draft.title, + description=draft.description, + required_skill_tag_ids=draft.required_skill_tag_ids, + status=draft.status, + priority=draft.priority, + estimated_hours=draft.estimated_hours, + actual_hours=draft.actual_hours, + quality_score=draft.quality_score, + due_date=draft.due_date, + ai_recommended_assignees=draft.ai_recommended_assignees, + ) + db.add(task) + await db.flush() + + if actor_user_id is not None: + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(actor_user_id), + project_id=int(project_id), + entity_type="task", + entity_id=str(task.id), + action=action, + before_data=None, + after_data={ + "title": task.title, + "truth_node_id": int(truth_node_id), + "priority": task.priority, + "status": task.status, + "assignee_id": int(task.assignee_id) if task.assignee_id is not None else None, + "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + }, + remark=remark, + ) + + if refresh_workload and task.assignee_id is not None: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=int(tenant_id), + user_id=int(task.assignee_id), + ) + elif commit: + await db.commit() + + return task diff --git a/backend/app/services/wecom_integration.py b/backend/app/services/wecom_integration.py new file mode 100644 index 00000000..510e0dc6 --- /dev/null +++ b/backend/app/services/wecom_integration.py @@ -0,0 +1,293 @@ +""" +WeChat Work (企业微信) Integration Service + +功能: +1. 接收企业微信群聊消息事件 +2. AI 判断消息是否为有价值的信号 +3. 自动创建信号并关联到项目 +4. 推送通知给相关角色 +""" +import asyncio +import json +import hashlib +import hmac +import base64 +import struct +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +import httpx +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text + +from ..models import ( + Signal, Project, Integration +) +from .signal_capture import ProactiveSignalCaptureService + + +class WeChatWorkIntegrationService: + """企业微信集成服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + # 从数据库获取企业微信配置 + self.corp_id = None + self.agent_id = None + self.app_secret = None + self.token = None + self.encoding_aes_key = None + self.tenant_id = 1 + + async def load_config(self, tenant_id: int = 1): + """从数据库加载企业微信配置""" + self.tenant_id = tenant_id + + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'wecom', + Integration.is_active.is_(True), + ) + ) + integration = result.scalar_one_or_none() + + if integration: + if isinstance(integration.config, str): + config = json.loads(integration.config) if integration.config else {} + else: + config = integration.config or {} + self.corp_id = config.get('corp_id') + self.agent_id = config.get('agent_id') + self.app_secret = config.get('app_secret') + self.token = config.get('token') + self.encoding_aes_key = config.get('encoding_aes_key') + else: + self.corp_id = None + self.agent_id = None + self.app_secret = None + self.token = None + self.encoding_aes_key = None + + def is_configured(self) -> bool: + """检查是否已配置""" + return all([ + self.corp_id, + self.agent_id, + self.app_secret, + self.token, + self.encoding_aes_key + ]) + + async def save_config( + self, + corp_id: str, + agent_id: str, + app_secret: str, + token: str, + encoding_aes_key: str, + tenant_id: int = 1 + ): + """保存企业微信配置到数据库""" + config = { + 'corp_id': corp_id, + 'agent_id': agent_id, + 'app_secret': app_secret, + 'token': token, + 'encoding_aes_key': encoding_aes_key + } + + # 查找或创建集成记录 + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'wecom' + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.config = config + integration.is_active = True + else: + integration = Integration( + type='wecom', + config=config, + is_active=True, + tenant_id=tenant_id + ) + self.db.add(integration) + + await self.db.commit() + + # 更新内存配置 + self.corp_id = corp_id + self.agent_id = agent_id + self.app_secret = app_secret + self.token = token + self.encoding_aes_key = encoding_aes_key + self.tenant_id = tenant_id + + def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str: + """ + 验证企业微信事件订阅 URL + + 企业微信会发送一个 GET 请求来验证 URL 有效性 + """ + if not self.is_configured(): + raise ValueError("企业微信未配置") + + # 计算签名 + sign_str = f"{self.token}{timestamp}{nonce}" + signature = hashlib.sha256(sign_str.encode()).hexdigest() + + if signature != msg_signature: + raise ValueError("签名验证失败") + + # 解密 echostr + decrypted = self._decrypt(echostr) + return decrypted + + def verify_callback(self, msg_signature: str, timestamp: str, nonce: str, data: str) -> bool: + """ + 验证企业微信回调签名 + """ + if not self.token: + return False + + # 计算签名 + sign_str = f"{self.token}{timestamp}{nonce}{data}" + computed_signature = hashlib.sha256(sign_str.encode()).hexdigest() + + return computed_signature == msg_signature + + async def handle_message_event( + self, + event_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理企业微信消息事件 + + 支持的事件类型: + - add_half_user: 添加企业成员 + - update_user: 更新成员信息 + - send_msg_success: 消息发送成功 + """ + try: + event_type = event_data.get('Event', '') + + # 企业微信群聊消息通过其他方式处理,这里主要是事件处理 + return { + 'code': 0, + 'msg': f'Event {event_type} received' + } + + except Exception as e: + print(f"[WeCom] Handle event error: {e}") + return { + 'code': 1, + 'msg': str(e) + } + + def _decrypt(self, encoded_text: str) -> str: + """ + 解密企业微信加密消息 + + AES 加密的消息格式: base64编码的16字节随机字符串 + 4字节消息长度 + 消息内容 + """ + try: + # Base64 解码 + cipher_text = base64.b64decode(encoded_text) + + # AES 密钥处理 + aes_key = base64.b64decode(self.encoding_aes_key + "=") + + # 解密 + from Crypto.Cipher import AES + cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16]) + + decrypted = cipher.decrypt(cipher_text) + + # 去除补位字符 + pad = decrypted[-1] + if isinstance(pad, str): + pad = ord(pad) + decrypted = decrypted[:-pad] + + # 提取消息内容(前16字节是随机字符串,接着4字节是消息长度) + content = decrypted[16:] + msg_len = struct.unpack(">I", content[:4])[0] + message = content[4:4 + msg_len].decode('utf-8') + + return message + + except Exception as e: + print(f"[WeCom] Decrypt error: {e}") + raise ValueError("消息解密失败") + + async def send_message_to_group( + self, + chat_id: str, + message: str + ) -> Dict[str, Any]: + """ + 发送消息到企业微信群聊 + + 用于推送通知、简报等 + """ + if not self.is_configured(): + raise ValueError("企业微信未配置") + + # 获取 access_token + access_token = await self._get_access_token() + + # 发送消息 API + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + params = {"access_token": access_token} + + message_data = { + "touser": "", + "msgtype": "text", + "agentid": int(self.agent_id), + "text": { + "content": message + }, + "safe": 0 + } + + # 如果是群聊,使用 chatid + if chat_id: + message_data["chatid"] = chat_id + message_data["msgtype"] = "text" + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post(url, params=params, json=message_data) + resp.raise_for_status() + return resp.json() + + async def _get_access_token(self) -> str: + """获取企业微信 access_token""" + if not self.corp_id or not self.app_secret: + raise ValueError("企业微信未配置") + + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + params = { + "corpid": self.corp_id, + "corpsecret": self.app_secret + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + result = resp.json() + + if result.get('errcode') == 0: + return result.get('access_token') + raise Exception(f"获取 token 失败: {result}") + + +# 全局服务实例 +def get_wecom_service(db: AsyncSession) -> WeChatWorkIntegrationService: + """获取企业微信服务实例""" + return WeChatWorkIntegrationService(db) diff --git a/backend/app/services/workload_alert_service.py b/backend/app/services/workload_alert_service.py new file mode 100644 index 00000000..d451af3e --- /dev/null +++ b/backend/app/services/workload_alert_service.py @@ -0,0 +1,252 @@ +"""团队负载告警服务。""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import OperationLog, Task, User, UserProject, UserWorkload +from .lark import lark_service + +DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} + + +async def _list_alert_logs( + db: AsyncSession, + *, + tenant_id: int, + user_id: int, +) -> list[OperationLog]: + rows = await db.execute( + select(OperationLog).where( + and_( + OperationLog.tenant_id == tenant_id, + OperationLog.entity_type == "workload_alert", + OperationLog.entity_id == str(user_id), + ) + ).order_by(OperationLog.created_at.desc()) + ) + return list(rows.scalars().all()) + + +def _latest_by_action(logs: list[OperationLog], action: str) -> Optional[OperationLog]: + for row in logs: + if row.action == action: + return row + return None + + +def _is_unresolved(logs: list[OperationLog]) -> bool: + latest_detected = _latest_by_action(logs, "overload_detected") + if not latest_detected: + return False + latest_resolved = _latest_by_action(logs, "overload_resolved") + if not latest_resolved: + return True + return bool(latest_detected.created_at and latest_resolved.created_at and latest_detected.created_at > latest_resolved.created_at) + + +async def on_user_workload_changed( + db: AsyncSession, + *, + tenant_id: int, + user_id: int, + workload_ratio: float, +) -> dict: + """用户负载变化时触发告警/恢复。""" + logs = await _list_alert_logs(db, tenant_id=tenant_id, user_id=user_id) + unresolved = _is_unresolved(logs) + + if workload_ratio > 100 and not unresolved: + text = ( + f"成员 {user_id} 负载过载:{workload_ratio:.1f}% (>100%)," + f"请尽快处理任务转派。" + ) + notify_result = await lark_service.send_card_message( + chat_id=f"tenant-{tenant_id}-workload-alert", + content=text, + tasks=[], + ) + db.add( + OperationLog( + tenant_id=tenant_id, + user_id=user_id, + project_id=None, + entity_type="workload_alert", + entity_id=str(user_id), + action="overload_detected", + before_data={"unresolved": unresolved}, + after_data={ + "workload_ratio": round(workload_ratio, 2), + "notify_channel": "feishu", + "notify_status": notify_result.get("status"), + }, + remark=text, + ) + ) + return {"alerted": True, "resolved": False} + + if workload_ratio <= 100 and unresolved: + db.add( + OperationLog( + tenant_id=tenant_id, + user_id=user_id, + project_id=None, + entity_type="workload_alert", + entity_id=str(user_id), + action="overload_resolved", + before_data={"unresolved": True}, + after_data={"workload_ratio": round(workload_ratio, 2)}, + remark="workload_back_to_normal", + ) + ) + return {"alerted": False, "resolved": True} + + return {"alerted": False, "resolved": False} + + +async def _build_transfer_suggestions( + db: AsyncSession, + *, + tenant_id: int, + user_id: int, + max_items: int = 3, +) -> list[dict]: + tasks_result = await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(DONE_STATUSES), + ) + ).order_by(Task.updated_at.desc()) + ) + tasks = list(tasks_result.scalars().all()) + if not tasks: + return [] + + suggestions: list[dict] = [] + for task in tasks: + member_result = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == int(task.project_id), + ) + ) + ) + member_ids = [int(v) for v in member_result.scalars().all() if int(v) != user_id] + if not member_ids: + continue + + candidate_result = await db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio) + .join(User, User.id == UserWorkload.user_id) + .where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(member_ids), + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + .order_by(UserWorkload.workload_ratio.asc()) + ) + candidates = candidate_result.all() + if not candidates: + continue + + target_user_id, target_ratio = candidates[0] + suggestions.append( + { + "task_id": int(task.id), + "project_id": int(task.project_id), + "old_assignee_id": user_id, + "new_assignee_id": int(target_user_id), + "new_assignee_ratio": round(float(target_ratio or 0.0), 2), + "reason": "auto_transfer_after_24h_overload", + } + ) + if len(suggestions) >= max_items: + break + return suggestions + + +async def process_tenant_unresolved_overloads( + db: AsyncSession, + *, + tenant_id: int, +) -> dict: + """扫描租户过载并为持续 24h 未恢复的成员生成转派建议。""" + rows = await db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).join( + User, + and_( + User.id == UserWorkload.user_id, + User.tenant_id == tenant_id, + ), + ).where( + and_( + UserWorkload.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + user_rows = rows.all() + + created = 0 + now = datetime.utcnow() + for user_id_raw, ratio_raw in user_rows: + user_id = int(user_id_raw) + ratio = float(ratio_raw or 0.0) + await on_user_workload_changed( + db, + tenant_id=tenant_id, + user_id=user_id, + workload_ratio=ratio, + ) + if ratio <= 100: + continue + + logs = await _list_alert_logs(db, tenant_id=tenant_id, user_id=user_id) + latest_detected = _latest_by_action(logs, "overload_detected") + latest_resolved = _latest_by_action(logs, "overload_resolved") + latest_suggestion = _latest_by_action(logs, "transfer_suggestion") + if not latest_detected or not latest_detected.created_at: + continue + if latest_resolved and latest_resolved.created_at and latest_resolved.created_at > latest_detected.created_at: + continue + if now - latest_detected.created_at < timedelta(hours=24): + continue + if latest_suggestion and latest_suggestion.created_at and latest_suggestion.created_at > latest_detected.created_at: + continue + + suggestions = await _build_transfer_suggestions( + db, + tenant_id=tenant_id, + user_id=user_id, + ) + if not suggestions: + continue + db.add( + OperationLog( + tenant_id=tenant_id, + user_id=user_id, + project_id=None, + entity_type="workload_alert", + entity_id=str(user_id), + action="transfer_suggestion", + before_data={"workload_ratio": ratio}, + after_data={"suggestions": suggestions}, + remark="overload_unresolved_24h", + ) + ) + created += 1 + + await db.commit() + return {"suggestion_count": created, "user_count": len(user_rows)} diff --git a/backend/app/services/workload_service.py b/backend/app/services/workload_service.py new file mode 100644 index 00000000..6f91ae3e --- /dev/null +++ b/backend/app/services/workload_service.py @@ -0,0 +1,739 @@ +"""PMFlow 团队负载服务。""" + +from __future__ import annotations + +import json +from datetime import date, datetime, timedelta +from typing import Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_WORKLOAD_OPTIMIZATION +from ..models import AICallLog, Task, User, UserProject, UserSkillBinding, UserWorkload +from .llm import llm_service + +LEVEL_HOURS_MULTIPLIER = { + "expert": 0.8, + "advanced": 0.9, + "intermediate": 1.0, + "beginner": 1.1, +} + + +def remaining_workdays_this_week(reference_date: Optional[date] = None) -> int: + """计算本周剩余工作日(含当天)。""" + today = reference_date or date.today() + weekday = today.weekday() # Monday = 0 + days_to_friday = max(0, 4 - weekday) + count = 0 + for offset in range(days_to_friday + 1): + d = today + timedelta(days=offset) + if d.weekday() < 5: + count += 1 + return max(count, 1) + + +def calculate_workload_ratio( + assigned_hours: float, + max_weekly_hours: float, + reference_date: Optional[date] = None, +) -> float: + """ + 负载率公式(百分比): + ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week) + """ + remaining_days = remaining_workdays_this_week(reference_date) + denominator = (max_weekly_hours / 5.0) * remaining_days + if denominator <= 0: + return 0.0 + return round((assigned_hours / denominator) * 100.0, 2) + + +async def ensure_user_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> UserWorkload: + result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = result.scalar_one_or_none() + if workload: + return workload + + workload = UserWorkload( + tenant_id=tenant_id, + user_id=user_id, + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + db.add(workload) + await db.flush() + return workload + + +async def recalculate_user_workload_ratio( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + commit: bool = True, +) -> Optional[UserWorkload]: + """重算单用户负载率并同步 users.workload。""" + user_result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = user_result.scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + ratio = calculate_workload_ratio( + assigned_hours=float(workload.current_assigned_hours or 0), + max_weekly_hours=float(workload.max_available_hours_per_week or 40), + ) + workload.workload_ratio = ratio + user.workload = int(max(0, min(200, ratio))) + user.updated_at = datetime.utcnow() + try: + from . import workload_alert_service + + await workload_alert_service.on_user_workload_changed( + db, + tenant_id=tenant_id, + user_id=user_id, + workload_ratio=ratio, + ) + except Exception: + pass + + if commit: + await db.commit() + await db.refresh(workload) + return workload + + +async def recalculate_all_users_workload_ratio(db: AsyncSession, tenant_id: int) -> int: + """批量重算租户内所有用户负载率。""" + result = await db.execute(select(User.id).where(User.tenant_id == tenant_id)) + user_ids = [int(uid) for uid in result.scalars().all()] + for uid in user_ids: + await recalculate_user_workload_ratio(db, tenant_id, uid, commit=False) + await db.commit() + return len(user_ids) + + +async def list_tenant_ids(db: AsyncSession) -> list[int]: + """获取当前系统存在用户数据的租户 ID 列表。""" + result = await db.execute(select(User.tenant_id).distinct()) + return [int(tid) for tid in result.scalars().all() if tid is not None] + + +async def get_team_workload_board( + db: AsyncSession, + tenant_id: int = 1, + project_id: Optional[int] = None, +) -> list[dict]: + """团队负载看板数据。""" + query = ( + select(UserWorkload, User) + .join(User, UserWorkload.user_id == User.id) + .where( + and_( + UserWorkload.tenant_id == tenant_id, + User.tenant_id == tenant_id, + ) + ) + ) + if project_id is not None: + query = query.join( + UserProject, + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == User.id, + ), + ).where(UserProject.project_id == project_id) + + query = query.order_by(UserWorkload.workload_ratio.desc(), User.id.asc()) + rows = (await db.execute(query)).all() + + return [ + { + "user_id": str(w.user_id), + "user_name": u.name, + "username": u.username, + "role": u.role, + "is_active": u.is_active, + "current_assigned_hours": float(w.current_assigned_hours or 0), + "max_weekly_hours": float(w.max_available_hours_per_week or 40), + "workload_ratio": float(w.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(w.max_available_hours_per_week or 40) + - float(w.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": w.updated_at.isoformat() if w.updated_at else None, + } + for w, u in rows + ] + + +async def get_user_personal_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> Optional[dict]: + """个人负载详情。""" + row = ( + await db.execute( + select(UserWorkload, User) + .join(User, UserWorkload.user_id == User.id) + .where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + User.tenant_id == tenant_id, + ) + ) + ) + ).first() + if row: + w, u = row + return { + "user_id": str(u.id), + "user_name": u.name, + "username": u.username, + "is_active": u.is_active, + "current_assigned_hours": float(w.current_assigned_hours or 0), + "max_weekly_hours": float(w.max_available_hours_per_week or 40), + "workload_ratio": float(w.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(w.max_available_hours_per_week or 40) + - float(w.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": w.updated_at.isoformat() if w.updated_at else None, + } + + user = ( + await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + ).scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + await db.commit() + return { + "user_id": str(user.id), + "user_name": user.name, + "username": user.username, + "is_active": user.is_active, + "current_assigned_hours": float(workload.current_assigned_hours or 0), + "max_weekly_hours": float(workload.max_available_hours_per_week or 40), + "workload_ratio": float(workload.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(workload.max_available_hours_per_week or 40) + - float(workload.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": workload.updated_at.isoformat() if workload.updated_at else None, + } + + +async def update_user_max_weekly_hours( + db: AsyncSession, + tenant_id: int, + user_id: int, + max_weekly_hours: float, +) -> Optional[dict]: + """更新每周可用工时并重算负载率。""" + user = ( + await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + ).scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + workload.max_available_hours_per_week = max(1.0, float(max_weekly_hours)) + await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) + await db.commit() + await db.refresh(workload) + + return { + "user_id": str(user.id), + "max_weekly_hours": float(workload.max_available_hours_per_week), + "workload_ratio": float(workload.workload_ratio or 0), + } + + +async def list_user_assigned_tasks( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + status: Optional[str] = None, + priority: Optional[str] = None, +) -> list[dict]: + """查询用户任务列表。""" + query = select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + ) + ) + if status: + query = query.where(Task.status == status) + if priority: + query = query.where(Task.priority == priority) + + rows = (await db.execute(query.order_by(Task.updated_at.desc()))).scalars().all() + return [ + { + "id": str(t.id), + "title": t.title, + "project_id": str(t.project_id), + "priority": t.priority, + "status": t.status, + "estimated_hours": float(t.estimated_hours or 0), + "due_date": t.due_date.isoformat() if t.due_date else None, + } + for t in rows + ] + + +async def get_user_workload_trend( + db: AsyncSession, + tenant_id: int, + user_id: int, + days: int = 7, +) -> list[dict]: + """ + 用户负载趋势。 + + 当前无历史快照表时,返回最近 N 天的平滑序列用于前端趋势图展示。 + """ + workload = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + ).scalar_one_or_none() + ratio_now = float(workload.workload_ratio or 0) if workload else 0.0 + + days = max(1, min(days, 30)) + start = date.today() - timedelta(days=days - 1) + series: list[dict] = [] + for idx in range(days): + current_day = start + timedelta(days=idx) + # 缺少历史表时做线性平滑,保留可视化可用性。 + ratio = round(max(0.0, ratio_now * (0.92 + 0.08 * (idx + 1) / days)), 2) + series.append( + { + "date": current_day.isoformat(), + "workload_ratio": ratio, + } + ) + return series + + +async def refresh_user_assigned_hours_from_tasks( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> Optional[UserWorkload]: + """从任务表汇总 assigned_hours 并重算 ratio。""" + workload = await ensure_user_workload(db, tenant_id, user_id) + + task_rows = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(["done", "completed", "closed"]), + ) + ) + ) + ).scalars().all() + + skill_rows = ( + await db.execute( + select( + UserSkillBinding.skill_tag_id, + UserSkillBinding.level, + UserSkillBinding.proficiency_level, + UserSkillBinding.proficiency, + ).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + ).all() + + def normalize_level( + level: Optional[str], + proficiency_level: Optional[str], + proficiency: Optional[int], + ) -> str: + raw = (level or proficiency_level or "").strip().lower() + if raw in LEVEL_HOURS_MULTIPLIER: + return raw + score = int(proficiency or 0) + if score >= 85: + return "expert" + if score >= 70: + return "advanced" + if score >= 50: + return "intermediate" + return "beginner" + + level_map: dict[int, str] = { + int(skill_tag_id): normalize_level(level, proficiency_level, proficiency) + for skill_tag_id, level, proficiency_level, proficiency in skill_rows + } + + def effective_estimated_hours(task: Task) -> float: + base = float(task.estimated_hours or 0.0) + if base <= 0: + return 0.0 + required_ids = _safe_int_list(task.required_skill_tag_ids) + if not required_ids: + return base + multipliers = [ + LEVEL_HOURS_MULTIPLIER.get(level_map[sid], 1.0) + for sid in required_ids + if sid in level_map + ] + if not multipliers: + return base + factor = sum(multipliers) / len(multipliers) + return round(base * factor, 2) + + workload.current_assigned_hours = round( + sum(effective_estimated_hours(task) for task in task_rows), + 2, + ) + + await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) + await db.commit() + await db.refresh(workload) + return workload + + +def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> float: + if not task_skill_ids: + return 0.0 + matched = sum(1 for sid in task_skill_ids if sid in user_skill_ids) + return matched / len(task_skill_ids) + + +def _safe_int_list(values: object) -> list[int]: + """兼容历史脏数据:数组中可能混入技能名称等非数字值。""" + source = values + if source is None: + return [] + if isinstance(source, str): + text = source.strip() + if not text: + return [] + try: + source = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(source, list): + return [] + + parsed: list[int] = [] + for item in source: + if item is None or isinstance(item, bool): + continue + if isinstance(item, int): + parsed.append(item) + continue + if isinstance(item, float): + if item.is_integer(): + parsed.append(int(item)) + continue + if isinstance(item, str): + text = item.strip() + if text and text.lstrip("-").isdigit(): + parsed.append(int(text)) + return parsed + + +def _parse_json_response(text: str) -> dict: + body = (text or "").strip() + if body.startswith("```json"): + body = body[7:].strip() + if body.startswith("```"): + body = body[3:].strip() + if body.endswith("```"): + body = body[:-3].strip() + return json.loads(body) + + +async def build_ai_optimize_preview( + db: AsyncSession, + tenant_id: int, + *, + project_id: Optional[int] = None, + max_moves: int = 20, + unassigned_only: bool = False, +) -> list[dict]: + """生成任务重分配建议(预览,不落库)。""" + task_query = select(Task).where( + and_( + Task.tenant_id == tenant_id, + func.lower(Task.status).notin_(["done", "completed", "closed"]), + ) + ) + if unassigned_only: + task_query = task_query.where(Task.assignee_id.is_(None)) + if project_id is not None: + task_query = task_query.where(Task.project_id == project_id) + + task_rows = (await db.execute(task_query.order_by(Task.updated_at.desc()))).scalars().all() + if not task_rows: + return [] + + active_users_query = select(User).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + users = (await db.execute(active_users_query)).scalars().all() + if not users: + return [] + + workload_rows = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_([int(u.id) for u in users]), + ) + ) + ) + ).scalars().all() + workload_map = {int(w.user_id): float(w.workload_ratio or 0.0) for w in workload_rows} + + skill_rows = ( + await db.execute( + select(UserSkillBinding.user_id, UserSkillBinding.skill_tag_id).where( + UserSkillBinding.tenant_id == tenant_id + ) + ) + ).all() + user_skill_map: dict[int, set[int]] = {} + for uid, sid in skill_rows: + user_skill_map.setdefault(int(uid), set()).add(int(sid)) + + users_by_project: dict[int, list[User]] = {} + for task in task_rows: + if int(task.project_id) in users_by_project: + continue + links = ( + await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == int(task.project_id), + ) + ) + ) + ).scalars().all() + allowed_ids = {int(uid) for uid in links} + users_by_project[int(task.project_id)] = [u for u in users if int(u.id) in allowed_ids] + + llm_suggestion_map: dict[int, dict] = {} + members_payload = [] + for user in users: + uid = int(user.id) + members_payload.append( + { + "user_id": uid, + "user_name": user.name, + "workload_ratio": round(workload_map.get(uid, float(user.workload or 0)), 2), + "skill_tag_ids": sorted(list(user_skill_map.get(uid, set()))), + } + ) + tasks_payload = [ + { + "task_id": int(task.id), + "project_id": int(task.project_id), + "title": task.title, + "assignee_id": int(task.assignee_id) if task.assignee_id else None, + "required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids), + "estimated_hours": float(task.estimated_hours or 0), + "status": task.status, + } + for task in task_rows + ] + llm_prompt = PROMPT_WORKLOAD_OPTIMIZATION.format( + members_json=json.dumps(members_payload, ensure_ascii=False), + tasks_json=json.dumps(tasks_payload, ensure_ascii=False), + ) + llm_text = "" + llm_status = "success" + try: + llm_text = await llm_service.generate( + llm_prompt, + function_name="workload_optimize_prompt", + tenant_id=tenant_id, + ) + parsed = _parse_json_response(llm_text) + for item in parsed.get("proposals", []) if isinstance(parsed, dict) else []: + try: + tid = int(item.get("task_id")) + llm_suggestion_map[tid] = { + "new_assignee_id": int(item.get("new_assignee_id")), + "reason": str(item.get("reason") or "llm_suggested"), + } + except (TypeError, ValueError): + continue + except Exception as exc: + llm_status = "failed" + llm_text = str(exc) + + db.add( + AICallLog( + tenant_id=tenant_id, + user_id=None, + function_name="workload_optimize_prompt", + prompt=llm_prompt, + response=(llm_text or "")[:8000], + tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0), + cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000), + status=llm_status, + ) + ) + await db.commit() + + proposals: list[dict] = [] + for task in task_rows: + current_assignee = int(task.assignee_id or 0) + is_unassigned = current_assignee <= 0 + candidates = users_by_project.get(int(task.project_id), []) + if not candidates: + continue + + required_skill_ids = _safe_int_list(task.required_skill_tag_ids) + ranked = [] + llm_target_id = llm_suggestion_map.get(int(task.id), {}).get("new_assignee_id") + llm_reason = llm_suggestion_map.get(int(task.id), {}).get("reason") + for candidate in candidates: + uid = int(candidate.id) + if not is_unassigned and uid == current_assignee: + continue + ratio = workload_map.get(uid, float(candidate.workload or 0)) + skill_score = _skill_match_score(required_skill_ids, user_skill_map.get(uid, set())) + # workload 越低越优,skill_score 越高越优 + total_score = (skill_score * 0.7) + ((max(0.0, 120.0 - ratio) / 120.0) * 0.3) + if llm_target_id and uid == int(llm_target_id): + total_score += 0.2 + ranked.append((total_score, ratio, candidate)) + + ranked.sort(key=lambda x: (-x[0], x[1])) + if not ranked: + continue + + best_score, _best_ratio, best_user = ranked[0] + current_ratio = workload_map.get(current_assignee, 0.0) if not is_unassigned else 0.0 + if is_unassigned: + if best_score < 0.2: + continue + reason = f"initial_assignment_score={best_score:.2f}" + else: + if current_ratio <= 100 and best_score < 0.5: + continue + reason = f"skill_match+workload_score={best_score:.2f}" + if llm_target_id and int(best_user.id) == int(llm_target_id): + reason = f"{reason};llm={llm_reason or 'suggested'}" + + proposals.append( + { + "task_id": int(task.id), + "task_title": task.title, + "project_id": int(task.project_id), + "old_assignee_id": current_assignee if not is_unassigned else None, + "new_assignee_id": int(best_user.id), + "old_ratio": round(current_ratio, 2) if not is_unassigned else None, + "new_ratio": round(workload_map.get(int(best_user.id), 0.0), 2), + "reason": reason, + } + ) + if len(proposals) >= max_moves: + break + + return proposals + + +async def apply_ai_optimize_plan( + db: AsyncSession, + tenant_id: int, + proposals: list[dict], +) -> dict: + """应用优化方案并重算涉及成员负载。""" + touched_user_ids: set[int] = set() + applied = 0 + + for item in proposals: + task_id = int(item.get("task_id", 0)) + new_assignee_id = int(item.get("new_assignee_id", 0)) + if task_id <= 0 or new_assignee_id <= 0: + continue + + task = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.id == task_id, + ) + ) + ) + ).scalar_one_or_none() + if not task: + continue + + if task.assignee_id: + touched_user_ids.add(int(task.assignee_id)) + task.assignee_id = new_assignee_id + touched_user_ids.add(new_assignee_id) + applied += 1 + + if touched_user_ids: + for uid in touched_user_ids: + await refresh_user_assigned_hours_from_tasks(db, tenant_id, uid) + else: + await db.commit() + + return {"applied_count": applied, "affected_users": len(touched_user_ids)} diff --git a/backend/app/state.py b/backend/app/state.py new file mode 100644 index 00000000..6870f7d5 --- /dev/null +++ b/backend/app/state.py @@ -0,0 +1,18 @@ +""" +应用级全局状态 + +event_bus、ws_manager 等由 main.py startup 设置,各 router 通过 `from app import state` 使用。 +""" + +event_bus = None +ws_manager = None + + +def set_event_bus(eb): + global event_bus + event_bus = eb + + +def set_ws_manager(wm): + global ws_manager + ws_manager = wm diff --git a/backend/app/websocket.py b/backend/app/websocket.py new file mode 100644 index 00000000..f97e5e90 --- /dev/null +++ b/backend/app/websocket.py @@ -0,0 +1,263 @@ +""" +Neural Sync - WebSocket Manager + +实现实时事件广播和 WebSocket 连接管理。 +""" + +from typing import Dict, List, Set, Optional, Any +from datetime import datetime +import json +import logging +from fastapi import WebSocket, WebSocketDisconnect +from enum import Enum + +logger = logging.getLogger(__name__) + + +class ConnectionEventType(str, Enum): + """连接事件类型""" + CONNECTED = "connected" + DISCONNECTED = "disconnected" + SUBSCRIBED = "subscribed" + UNSUBSCRIBED = "unsubscribed" + + +class WebSocketConnection: + """WebSocket 连接包装器""" + + def __init__(self, websocket: WebSocket, client_id: str): + self.websocket = websocket + self.client_id = client_id + self.subscriptions: Set[str] = set() + self.connected_at = datetime.utcnow() + self.last_ping: Optional[datetime] = None + + async def send(self, data: Dict[str, Any]) -> bool: + """发送数据到客户端""" + try: + await self.websocket.send_json(data) + return True + except Exception as e: + logger.error(f"Error sending to client {self.client_id}: {e}") + return False + + async def ping(self) -> bool: + """发送 ping""" + try: + await self.websocket.send_json({"type": "ping", "timestamp": datetime.utcnow().timestamp()}) + self.last_ping = datetime.utcnow() + return True + except Exception as e: + logger.error(f"Error pinging client {self.client_id}: {e}") + return False + + def subscribe(self, event_type: str): + """订阅事件类型""" + self.subscriptions.add(event_type) + + def unsubscribe(self, event_type: str): + """取消订阅事件类型""" + self.subscriptions.discard(event_type) + + def is_subscribed(self, event_type: str) -> bool: + """检查是否订阅了事件类型""" + return event_type in self.subscriptions or "*" in self.subscriptions + + +class WebSocketManager: + """ + WebSocket 连接管理器 + + 特性: + - 连接管理 + - 事件订阅过滤 + - 心跳检测 + - 广播和单播 + """ + + def __init__(self): + self._connections: Dict[str, WebSocketConnection] = {} + self._client_counter = 0 + self._heartbeat_interval = 30 # seconds + self._heartbeat_task: Optional[Any] = None + + @property + def connections(self) -> Dict[str, WebSocketConnection]: + """获取所有连接""" + return self._connections.copy() + + @property + def connection_count(self) -> int: + """获取连接数""" + return len(self._connections) + + async def connect(self, websocket: WebSocket) -> str: + """ + 接受新的 WebSocket 连接 + + Args: + websocket: WebSocket 连接 + + Returns: + 客户端 ID + """ + await websocket.accept() + + self._client_counter += 1 + client_id = f"client_{self._client_counter}" + + connection = WebSocketConnection(websocket, client_id) + self._connections[client_id] = connection + + # 发送连接确认 + await connection.send({ + "type": ConnectionEventType.CONNECTED, + "client_id": client_id, + "timestamp": datetime.utcnow().timestamp(), + }) + + logger.info(f"WebSocket client connected: {client_id}") + return client_id + + async def disconnect(self, client_id: str): + """断开连接""" + if client_id in self._connections: + del self._connections[client_id] + logger.info(f"WebSocket client disconnected: {client_id}") + + async def subscribe(self, client_id: str, event_types: List[str]): + """订阅事件类型""" + if client_id in self._connections: + connection = self._connections[client_id] + for event_type in event_types: + connection.subscribe(event_type) + + await connection.send({ + "type": ConnectionEventType.SUBSCRIBED, + "event_types": list(connection.subscriptions), + "timestamp": datetime.utcnow().timestamp(), + }) + + async def unsubscribe(self, client_id: str, event_types: List[str]): + """取消订阅事件类型""" + if client_id in self._connections: + connection = self._connections[client_id] + for event_type in event_types: + connection.unsubscribe(event_type) + + await connection.send({ + "type": ConnectionEventType.UNSUBSCRIBED, + "event_types": list(connection.subscriptions), + "timestamp": datetime.utcnow().timestamp(), + }) + + async def broadcast(self, event_type: str, data: Dict[str, Any]) -> int: + """ + 广播事件到所有订阅的客户端 + + Args: + event_type: 事件类型 + data: 事件数据 + + Returns: + 发送成功的客户端数量 + """ + message = { + "type": "event", + "event_type": event_type, + "data": data, + "timestamp": datetime.utcnow().timestamp(), + } + + success_count = 0 + failed_clients = [] + + for client_id, connection in self._connections.items(): + if connection.is_subscribed(event_type): + if await connection.send(message): + success_count += 1 + else: + failed_clients.append(client_id) + + # 清理失败的连接 + for client_id in failed_clients: + await self.disconnect(client_id) + + if success_count > 0: + logger.debug(f"Broadcast {event_type} to {success_count} clients") + + return success_count + + async def send_to_client(self, client_id: str, data: Dict[str, Any]) -> bool: + """发送数据到指定客户端""" + if client_id in self._connections: + return await self._connections[client_id].send(data) + return False + + async def start_heartbeat(self): + """启动心跳检测""" + import asyncio + + async def heartbeat_loop(): + while True: + await asyncio.sleep(self._heartbeat_interval) + await self._send_heartbeat() + + self._heartbeat_task = asyncio.create_task(heartbeat_loop()) + logger.info("WebSocket heartbeat started") + + async def stop_heartbeat(self): + """停止心跳检测""" + if self._heartbeat_task: + self._heartbeat_task.cancel() + self._heartbeat_task = None + logger.info("WebSocket heartbeat stopped") + + async def _send_heartbeat(self): + """发送心跳到所有连接""" + failed_clients = [] + + for client_id, connection in self._connections.items(): + if not await connection.ping(): + failed_clients.append(client_id) + + # 清理失败的连接 + for client_id in failed_clients: + await self.disconnect(client_id) + + async def close_all(self): + """关闭所有连接""" + for client_id in list(self._connections.keys()): + await self.disconnect(client_id) + + await self.stop_heartbeat() + + +# 全局 WebSocket 管理器实例 +_global_manager: Optional[WebSocketManager] = None + + +def get_websocket_manager() -> WebSocketManager: + """获取全局 WebSocket 管理器实例""" + global _global_manager + if _global_manager is None: + _global_manager = WebSocketManager() + return _global_manager + + +# 事件总线桥接 - 自动将事件广播到 WebSocket 客户端 +async def setup_event_bridge(event_bus): + """设置事件总线与 WebSocket 的桥接""" + from .events import EventType + + async def broadcast_event(event): + """广播事件到 WebSocket 客户端""" + manager = get_websocket_manager() + await manager.broadcast( + event_type=event.type.value, + data=event.data, + ) + + # 订阅所有事件 + event_bus.subscribe_wildcard(broadcast_event) + logger.info("Event bus to WebSocket bridge established") diff --git a/backend/check_users.py b/backend/check_users.py new file mode 100644 index 00000000..f5b57c38 --- /dev/null +++ b/backend/check_users.py @@ -0,0 +1,27 @@ + +import asyncio +import sys +from pathlib import Path + +# Add backend directory to sys.path +sys.path.append(str(Path(__file__).parent.absolute())) + +from app.database import AsyncSessionLocal +from app.models.user import User +from sqlalchemy import select, func + +async def check_users(): + async with AsyncSessionLocal() as session: + result = await session.execute(select(func.count(User.id))) + count = result.scalar() + print(f"User count: {count}") + + if count > 0: + result = await session.execute(select(User.username, User.role).limit(5)) + users = result.fetchall() + print("Existing users:") + for u in users: + print(f"- {u.username} ({u.role})") + +if __name__ == "__main__": + asyncio.run(check_users()) diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 00000000..1a0306a8 --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" +Initialize/fix SQLite database by creating all missing tables. +Run this when the database schema is out of sync. +""" +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from backend.app.models import Base +from backend.app.database import DATABASE_URL + +from sqlalchemy import text + +async def init_database(): + """Create all tables that don't exist yet.""" + print(f"Using database: {DATABASE_URL}") + + engine = create_async_engine(DATABASE_URL, echo=True) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with engine.begin() as conn: + # Create all tables that don't exist + await conn.run_sync(Base.metadata.create_all) + print("✅ All tables created successfully") + + # Verify tables were created + async with engine.begin() as conn: + result = await conn.run_sync(lambda sync_conn: sync_conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'")).fetchall()) + tables = [row[0] for row in result] + print(f"📊 Tables in database: {', '.join(sorted(tables))}") + + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(init_database()) diff --git a/backend/scripts/migrate_sqlite_to_postgres.py b/backend/scripts/migrate_sqlite_to_postgres.py new file mode 100644 index 00000000..95a9c112 --- /dev/null +++ b/backend/scripts/migrate_sqlite_to_postgres.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""将本地 SQLite 数据迁移到当前 PostgreSQL(Docker)。 + +默认源库: /app/backend/neural_sync.db +默认目标库: 环境变量 DATABASE_URL +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sqlite3 +from datetime import datetime +from typing import Any + +import asyncpg + +TABLE_ORDER = [ + "skill_tags", + "users", + "projects", + "feishu_items", + "signals", + "truth_nodes", + "tasks", + "project_signal_links", + "user_skill_bindings", + "user_workloads", + "qa_sessions", + "qa_records", + "qa_feedback", +] + + +def _parse_json(value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, (dict, list)): + return value + text = str(value).strip() + if not text: + return default + try: + return json.loads(text) + except Exception: + return default + + +def _parse_ts(value: Any) -> Any: + if value is None: + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + text = text.replace("T", " ").replace("Z", "") + try: + return datetime.fromisoformat(text) + except Exception: + return text + + +def _parse_int_array(value: Any) -> list[int]: + if value is None: + return [] + if isinstance(value, list): + raw = value + else: + raw = _parse_json(value, default=[]) + if not isinstance(raw, list): + raw = [] + result: list[int] = [] + for item in raw: + try: + num = int(item) + result.append(num) + except Exception: + continue + return result + + +def _transform(table: str, row: dict[str, Any], pg_types: dict[str, str]) -> dict[str, Any]: + data = dict(row) + + if table == "users": + data["assigned_domains"] = _parse_json(data.get("assigned_domains"), default=[]) + + if table == "truth_nodes": + content = data.get("content") + parsed = _parse_json(content, default=None) + if parsed is None: + parsed = {"raw": str(content or "")} + data["content"] = parsed + + if table == "tasks": + data["required_skill_tag_ids"] = _parse_int_array(data.get("required_skill_tag_ids")) + data["ai_recommended_assignees"] = _parse_json(data.get("ai_recommended_assignees"), default=[]) + + if table == "qa_records": + data["sources"] = _parse_json(data.get("sources"), default=[]) + data["domains_used"] = _parse_json(data.get("domains_used"), default=[]) + + if table == "qa_feedback": + data["error_types"] = _parse_json(data.get("error_types"), default=[]) + + # 通用类型修正 + for col, typ in pg_types.items(): + if col not in data: + continue + if typ in {"json", "jsonb"}: + value = data[col] + if value is None: + continue + if isinstance(value, str): + data[col] = value + else: + data[col] = json.dumps(value, ensure_ascii=False) + continue + if typ == "boolean": + value = data[col] + if isinstance(value, bool): + continue + if value is None: + data[col] = False + else: + data[col] = str(value).strip().lower() in {"1", "true", "t", "yes", "y"} + elif typ == "timestamp without time zone": + data[col] = _parse_ts(data.get(col)) + + return data + + +async def _pg_table_columns(conn: asyncpg.Connection, table: str) -> list[str]: + rows = await conn.fetch( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + ORDER BY ordinal_position + """, + table, + ) + return [str(r["column_name"]) for r in rows] + + +async def _pg_table_types(conn: asyncpg.Connection, table: str) -> dict[str, str]: + rows = await conn.fetch( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + """, + table, + ) + return {str(r["column_name"]): str(r["data_type"]) for r in rows} + + +async def _pg_existing_tables(conn: asyncpg.Connection) -> set[str]: + rows = await conn.fetch( + """ + SELECT tablename + FROM pg_tables + WHERE schemaname='public' + """ + ) + return {str(r["tablename"]) for r in rows} + + +def _sqlite_columns(sqlite_conn: sqlite3.Connection, table: str) -> list[str]: + rows = sqlite_conn.execute(f"PRAGMA table_info({table})").fetchall() + return [str(r[1]) for r in rows] + + +def _sqlite_rows(sqlite_conn: sqlite3.Connection, table: str) -> list[dict[str, Any]]: + sqlite_conn.row_factory = sqlite3.Row + rows = sqlite_conn.execute(f"SELECT * FROM {table} ORDER BY id ASC").fetchall() + return [dict(r) for r in rows] + + +async def migrate(sqlite_path: str, postgres_url: str, *, mode: str = "merge") -> None: + sqlite_conn = sqlite3.connect(sqlite_path) + sqlite_conn.row_factory = sqlite3.Row + + pg_url = postgres_url.replace("postgresql+asyncpg://", "postgresql://") + pg = await asyncpg.connect(pg_url) + + try: + pg_tables = await _pg_existing_tables(pg) + sqlite_tables = { + r[0] + for r in sqlite_conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + + tables = [t for t in TABLE_ORDER if t in pg_tables and t in sqlite_tables] + if not tables: + raise RuntimeError("无可迁移表,请检查库路径与目标库") + + print(f"[INFO] migrate tables: {tables}") + print(f"[INFO] mode: {mode}") + + if mode == "replace": + # 清空目标核心表(含级联) + await pg.execute("TRUNCATE TABLE " + ", ".join(tables) + " RESTART IDENTITY CASCADE") + + for table in tables: + pg_cols = await _pg_table_columns(pg, table) + pg_types = await _pg_table_types(pg, table) + sqlite_cols = _sqlite_columns(sqlite_conn, table) + common_cols = [c for c in pg_cols if c in sqlite_cols] + if not common_cols: + print(f"[WARN] skip {table}: no common cols") + continue + + rows = _sqlite_rows(sqlite_conn, table) + inserted = 0 + for row in rows: + row = _transform(table, row, pg_types) + values = [row.get(c) for c in common_cols] + placeholders = ", ".join(f"${i+1}" for i in range(len(common_cols))) + cols_sql = ", ".join(common_cols) + if mode == "merge" and "id" in common_cols and "id" in pg_cols: + sql = ( + f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders}) " + "ON CONFLICT (id) DO NOTHING" + ) + else: + sql = f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders})" + try: + await pg.execute(sql, *values) + inserted += 1 + except Exception as exc: + print(f"[WARN] {table} row id={row.get('id')} failed: {exc}") + + if "id" in common_cols: + await pg.execute( + f"SELECT setval(pg_get_serial_sequence('{table}','id'), COALESCE((SELECT MAX(id) FROM {table}), 1), true)" + ) + + print(f"[OK] {table}: {inserted}/{len(rows)}") + + # 摘要 + summary_tables = [ + "users", + "projects", + "signals", + "truth_nodes", + "tasks", + "qa_sessions", + "qa_records", + "qa_feedback", + ] + print("[SUMMARY]") + for table in summary_tables: + if table not in tables: + continue + count = await pg.fetchval(f"SELECT count(*) FROM {table}") + print(f" - {table}: {int(count or 0)}") + finally: + await pg.close() + sqlite_conn.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sqlite", default="/app/backend/neural_sync.db") + parser.add_argument("--postgres", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--mode", choices=["merge", "replace"], default="merge") + args = parser.parse_args() + + if not args.postgres: + print("[ERROR] DATABASE_URL 为空,请使用 --postgres 指定") + return 2 + + if not os.path.exists(args.sqlite): + print(f"[ERROR] SQLite 文件不存在: {args.sqlite}") + return 2 + + try: + asyncio.run(migrate(args.sqlite, args.postgres, mode=args.mode)) + return 0 + except Exception as exc: + print(f"[ERROR] migration failed: {exc}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/seed_all.py b/backend/seed_all.py new file mode 100644 index 00000000..8195cb37 --- /dev/null +++ b/backend/seed_all.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Comprehensive seed script for PMFlow/Neural Sync. +Creates users, domains, and initial data. +""" +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from app.database import AsyncSessionLocal +from app.models import User, ProjectDomain, Project +from app.services.auth import AuthService + + +async def seed_users(): + """Create default users with known passwords.""" + # Create initial users (with simple password hashes for demo) + # In production, these should be created with proper password hashing + # admin_hash = AuthService.hash_password("admin123") + # boss_hash = AuthService.hash_password("boss123") + # ai_hash = AuthService.hash_password("ai123") + # be_hash = AuthService.hash_password("be123") + # fe_hash = AuthService.hash_password("fe123") + + async with AsyncSessionLocal() as session: + # Check if users already exist + result = await session.execute(select(User).where(User.username == "admin")) + admin = result.scalar_one_or_none() + + if not admin: + # Create admin user + admin = User( + username="admin", + password_hash=AuthService.hash_password("admin123"), + name="系统管理员", + email="admin@pmflow.ai", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + tenant_id=1, + ) + session.add(admin) + print("✅ Created admin user (username: admin, password: admin123)") + + # Create boss user + result = await session.execute(select(User).where(User.username == "boss")) + boss = result.scalar_one_or_none() + if not boss: + boss = User( + username="boss", + password_hash=AuthService.hash_password("boss123"), + name="老板", + email="boss@pmflow.ai", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + tenant_id=1, + ) + session.add(boss) + print("✅ Created boss user (username: boss, password: boss123)") + + # Create AI engineer user + result = await session.execute(select(User).where(User.username == "ai_engineer")) + ai_user = result.scalar_one_or_none() + if not ai_user: + ai_user = User( + username="ai_engineer", + password_hash=AuthService.hash_password("ai123"), + name="AI工程师", + email="ai@pmflow.ai", + role="AI", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + tenant_id=1, + ) + session.add(ai_user) + print("✅ Created ai_engineer user (username: ai_engineer, password: ai123)") + + # Create backend user + result = await session.execute(select(User).where(User.username == "backend")) + backend_user = result.scalar_one_or_none() + if not backend_user: + backend_user = User( + username="backend", + password_hash=AuthService.hash_password("be123"), + name="后端开发", + email="backend@pmflow.ai", + role="BE", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + tenant_id=1, + ) + session.add(backend_user) + print("✅ Created backend user (username: backend, password: be123)") + + # Create frontend user + result = await session.execute(select(User).where(User.username == "frontend")) + frontend_user = result.scalar_one_or_none() + if not frontend_user: + frontend_user = User( + username="frontend", + password_hash=AuthService.hash_password("fe123"), + name="前端开发", + email="frontend@pmflow.ai", + role="FE", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + tenant_id=1, + ) + session.add(frontend_user) + print("✅ Created frontend user (username: frontend, password: fe123)") + + await session.commit() + + +async def seed_domains(): + """Create default project domains.""" + async with AsyncSessionLocal() as session: + # Check if domains already exist + result = await session.execute(select(ProjectDomain)) + domains = result.scalars().all() + + if not domains: + default_domains = [ + ProjectDomain( + name="AI模型工程", + slug="ai-engineering", + description="AI模型训练、评估和部署相关项目", + is_default=True, + signal_count=0, + context_node_count=0, + experiment_count=0, + tenant_id=1, + ), + ProjectDomain( + name="后端服务", + slug="backend-services", + description="后端API、数据库、微服务等项目", + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + tenant_id=1, + ), + ProjectDomain( + name="前端应用", + slug="frontend-apps", + description="Vue/React前端应用、UI组件等", + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + tenant_id=1, + ), + ] + + for domain in default_domains: + session.add(domain) + + await session.commit() + print("✅ Created 3 default project domains: ai-engineering, backend-services, frontend-apps") + else: + print(f"ℹ️ Project domains already exist ({len(domains)} found)") + + +async def main(): + print("🌱 Seeding PMFlow database...") + print("=" * 50) + + await seed_users() + await seed_domains() + + print("=" * 50) + print("✅ Seeding completed!") + print() + print("📝 Default users created:") + print(" - admin / admin123 (BOSS)") + print(" - boss / boss123 (BOSS)") + print(" - ai_engineer / ai123 (AI)") + print(" - backend / be123 (BE)") + print(" - frontend / fe123 (FE)") + print() + print("🔐 Use these credentials to login at: http://127.0.0.1:5174/login") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_adaptive_rag_intent_corrections.py b/backend/tests/test_adaptive_rag_intent_corrections.py new file mode 100644 index 00000000..2401989d --- /dev/null +++ b/backend/tests/test_adaptive_rag_intent_corrections.py @@ -0,0 +1,36 @@ +import unittest + +from app.services.adaptive_rag_service import AdaptiveRAGService + + +class AdaptiveRAGIntentCorrectionTest(unittest.TestCase): + def test_build_intent_correction_record_is_structured_and_stable(self) -> None: + first = AdaptiveRAGService._build_intent_correction_record( + user_utterance="不对,是新增进 PMFlow 的项目列表里面", + wrong_interpretation="signal_intake", + correct_intent="create_project", + canonical_action="project.create", + clarification_template="请补充项目名称、项目描述、项目范围。", + risk_level="high", + project_id=None, + ) + second = AdaptiveRAGService._build_intent_correction_record( + user_utterance="不对,是新增进 PMFlow 的项目列表里面", + wrong_interpretation="signal_intake", + correct_intent="create_project", + canonical_action="project.create", + clarification_template="请补充项目名称、项目描述、项目范围。", + risk_level="high", + project_id=None, + ) + + self.assertEqual(first[0], second[0]) + self.assertEqual(first[1], "意图纠错/create_project") + self.assertIn("用户原话:", first[2]) + self.assertEqual(first[3]["record_type"], "intent_correction") + self.assertEqual(first[3]["correct_intent"], "create_project") + self.assertEqual(first[3]["canonical_action"], "project.create") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ai_logs_api.py b/backend/tests/test_ai_logs_api.py new file mode 100644 index 00000000..1ae3d2d0 --- /dev/null +++ b/backend/tests/test_ai_logs_api.py @@ -0,0 +1,184 @@ +import os +import unittest +from tempfile import NamedTemporaryFile + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.api.ai_logs import _apply_project_filter +from app.models import AIProcessingLog, Base, IngestEvent, Project, User, WorkItem + + +class AILogsApiTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-logs-test-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + project_a = Project( + tenant_id=1, + name="项目A", + title="项目A", + description="A", + owner_id=int(user.id), + status="active", + domain_id=None, + assigned_role="pm", + signal_count=0, + ) + project_b = Project( + tenant_id=1, + name="项目B", + title="项目B", + description="B", + owner_id=int(user.id), + status="active", + domain_id=None, + assigned_role="pm", + signal_count=0, + ) + db.add_all([project_a, project_b]) + await db.flush() + + ingest_a = IngestEvent( + tenant_id=1, + channel="feishu_message", + event_key="om-project-a", + status="done", + payload={}, + retry_count=0, + error_message=None, + processed_at=None, + ) + ingest_b = IngestEvent( + tenant_id=1, + channel="feishu_message", + event_key="om-project-b", + status="done", + payload={}, + retry_count=0, + error_message=None, + processed_at=None, + ) + db.add_all([ingest_a, ingest_b]) + await db.flush() + + db.add_all( + [ + WorkItem( + tenant_id=1, + ingest_event_id=int(ingest_a.id), + source_system="feishu", + source_message_id="om-project-a", + state="succeeded", + intent="project.create", + decision_level="project", + project_id=int(project_a.id), + ), + WorkItem( + tenant_id=1, + ingest_event_id=int(ingest_b.id), + source_system="feishu", + source_message_id="om-project-b", + state="succeeded", + intent="signal.create", + decision_level="project", + project_id=int(project_b.id), + ), + ] + ) + db.add_all( + [ + AIProcessingLog( + tenant_id=1, + feishu_message_id="om-project-a", + feishu_item_id=None, + raw_message="创建项目A", + ai_raw_output="{}", + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text="创建项目A", + route="ai_pmo.project.create", + intent="project.create", + executor="ai_pmo_kernel", + operation_code="project.create", + operation_result={"project_id": int(project_a.id)}, + reply_source="action_succeeded", + routing_decision="ai_pmo.project.create", + routing_reason="AI PMO kernel route=ai_pmo.project.create", + reasoning_bullets=["work_item_id=1"], + processing_status="success", + error_message=None, + duration_ms=100, + ), + AIProcessingLog( + tenant_id=1, + feishu_message_id="om-project-b", + feishu_item_id=None, + raw_message="创建信号B", + ai_raw_output="{}", + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text="创建信号B", + route="ai_pmo.signal.create", + intent="signal.create", + executor="ai_pmo_kernel", + operation_code="signal.enter", + operation_result={"project_id": int(project_b.id)}, + reply_source="action_succeeded", + routing_decision="ai_pmo.signal.create", + routing_reason="AI PMO kernel route=ai_pmo.signal.create", + reasoning_bullets=["work_item_id=2"], + processing_status="success", + error_message=None, + duration_ms=100, + ), + ] + ) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + async def test_apply_project_filter_includes_ai_pmo_work_item_message_ids(self) -> None: + async with self.session_factory() as db: + stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc()) + rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=1))).scalars().all() + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].feishu_message_id, "om-project-a") + + async def test_apply_project_filter_keeps_other_project_logs_out(self) -> None: + async with self.session_factory() as db: + stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc()) + rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=2))).scalars().all() + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].feishu_message_id, "om-project-b") diff --git a/backend/tests/test_ai_pmo_full_scenario.py b/backend/tests/test_ai_pmo_full_scenario.py new file mode 100644 index 00000000..ae513df2 --- /dev/null +++ b/backend/tests/test_ai_pmo_full_scenario.py @@ -0,0 +1,640 @@ +import json +import os +import unittest +from datetime import datetime +from tempfile import NamedTemporaryFile +from unittest.mock import AsyncMock, patch + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.api.users import _create_user_with_defaults +from app.models import ( + Base, + Budget, + Project, + ProjectDomain, + ProjectSignalLink, + Signal, + SkillTag, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from app.schemas import UserCreate +from app.services.ai_pmo_cadence_service import AIPMOCadenceService +from app.services.ai_pmo_kernel_service import AIPMOKernelService +from app.services.signal_capture import ProactiveSignalCaptureService +from app.services.skill_service import recommend_personnel +from app.services import workload_service + + +class AIPMOFullScenarioTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-scenario-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + self.policy_skill_id = await self._create_skill_tag(db, name="政策分析", slug="policy-analysis") + self.research_skill_id = await self._create_skill_tag(db, name="行业研究", slug="market-research") + self.web_skill_id = await self._create_skill_tag(db, name="爬虫采集", slug="web-crawling") + self.graph_skill_id = await self._create_skill_tag(db, name="知识图谱", slug="knowledge-graph") + self.pipeline_skill_id = await self._create_skill_tag(db, name="数据管道", slug="data-pipeline") + + boss = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(boss) + await db.flush() + self.boss_id = int(boss.id) + + domain = ProjectDomain( + tenant_id=1, + name="医药", + slug="pharma", + description="医药产业情报域", + config={}, + is_active=True, + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + owner_id=str(boss.id), + ) + db.add(domain) + await db.flush() + self.domain_id = int(domain.id) + + project = Project( + tenant_id=1, + name="医药行业十五五规划布局研究", + title="医药行业十五五规划布局研究", + description="跟踪医保支付改革、器械招采与创新药方向。", + owner_id=int(boss.id), + status="active", + domain_id=int(domain.id), + assigned_role="pm", + signal_count=0, + ) + db.add(project) + await db.flush() + self.base_project_id = int(project.id) + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(boss.id), + title="医药研究项目宪章", + content={"success_criteria": ["两周内形成华东医保支付改革观察报告"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + self.base_charter_id = int(charter.id) + + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=1500.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=620.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(boss.id), project_id=int(project.id))) + db.add( + UserWorkload( + tenant_id=1, + user_id=int(boss.id), + current_assigned_hours=18.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + ) + await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=86) + await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=92) + db.add( + Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=int(boss.id), + ai_recommended_assignees=None, + title="拆解华东医保支付改革样本", + description="需要比对江苏/浙江 DRG-DIP 改革口径和器械招采变化。", + required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], + status="blocked", + priority="high", + estimated_hours=12.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 12), + completed_at=None, + ) + ) + await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(boss.id), commit=False) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + async def _create_skill_tag(self, db: AsyncSession, *, name: str, slug: str) -> int: + row = SkillTag( + tenant_id=1, + name=name, + slug=slug, + category="skill", + description=f"{name}能力", + is_system=False, + sort_order=0, + ) + db.add(row) + await db.flush() + return int(row.id) + + async def _bind_skill( + self, + db: AsyncSession, + *, + user_id: int, + skill_tag_id: int, + level: str, + proficiency: int, + ) -> None: + db.add( + UserSkillBinding( + tenant_id=1, + user_id=int(user_id), + skill_tag_id=int(skill_tag_id), + proficiency_level=level, + proficiency=int(proficiency), + level=level, + years_experience=5.0, + quality_score=8.5, + efficiency_score=8.0, + ) + ) + await db.flush() + + async def _set_workload_hours(self, db: AsyncSession, *, user_id: int, assigned_hours: float) -> None: + workload = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == 1, + UserWorkload.user_id == int(user_id), + ) + ) + ) + ).scalar_one() + workload.current_assigned_hours = float(assigned_hours) + await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(user_id), commit=False) + + async def test_full_difficult_scenario_covers_feishu_context_project_signal_workflow_and_workload(self) -> None: + async with self.session_factory() as db: + strategy_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="hanqing", + password="password-1", + name="韩青", + email="hanqing@example.com", + role="ADMIN", + assigned_domains=["pharma"], + project_ids=[self.base_project_id], + ), + actor_id=self.boss_id, + commit=False, + ) + crawler_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="zhouzhan", + password="password-2", + name="周湛", + email="zhouzhan@example.com", + role="BE", + assigned_domains=["pharma"], + project_ids=[], + ), + actor_id=self.boss_id, + commit=False, + ) + graph_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="guyan", + password="password-3", + name="顾言", + email="guyan@example.com", + role="AI", + assigned_domains=["pharma"], + project_ids=[], + ), + actor_id=self.boss_id, + commit=False, + ) + await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.policy_skill_id, level="expert", proficiency=93) + await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=90) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.web_skill_id, level="expert", proficiency=95) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=84) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=88) + await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.graph_skill_id, level="expert", proficiency=91) + await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=82) + await self._set_workload_hours(db, user_id=int(strategy_user.id), assigned_hours=28.0) + await self._set_workload_hours(db, user_id=int(crawler_user.id), assigned_hours=8.0) + await self._set_workload_hours(db, user_id=int(graph_user.id), assigned_hours=10.0) + await db.commit() + + kernel = AIPMOKernelService(db, tenant_id=1) + capture_service = ProactiveSignalCaptureService(db) + + hard_question = ( + "项目ID: 1 如果把样本窗口收紧到 2025Q4-2026Q1,只看江苏/浙江的 DRG-DIP 支付、" + "三甲院器械招采与设备更新预算,当前状态下最可能拖慢结论闭环的到底是预算、" + "blocked task 还是技能缺口?为什么?别给套话,按证据优先级直接判断。" + ) + with ( + patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock( + return_value={ + "answer": "优先级 1 是 blocked task,其次是技能缺口,预算暂未触顶。", + "sources": [], + } + ), + ), + patch( + "app.services.signal_capture.context_cache_service.get_session_messages", + AsyncMock(return_value=[]), + ), + patch( + "app.services.signal_capture.context_cache_service.append_session_messages", + AsyncMock(), + ) as cache_append_mock, + patch( + "app.services.signal_capture.AdaptiveRAGService.ingest_feishu_message_async", + AsyncMock(), + ), + ): + qa_result = await capture_service.capture_from_feishu( + group_chat_id="oc_complex_case", + message_content=hard_question, + sender="老板", + tenant_id=1, + proposer_open_id="ou_boss", + captured_at=datetime(2026, 3, 9, 9, 0, 0), + feishu_message_id="om-qa-hard-1", + chat_type="group", + ) + + async def create_project_side_effect(*, input_text: str, creator_id: int, tenant_id: int) -> dict: + project = Project( + tenant_id=int(tenant_id), + name="华东医保控费与器械国产替代观察台", + title="华东医保控费与器械国产替代观察台", + description="聚焦华东 DRG-DIP、器械带量采购和国产替代节奏。", + owner_id=int(creator_id), + status="active", + domain_id=self.domain_id, + assigned_role="ai_engineer", + signal_count=0, + ) + db.add(project) + await db.flush() + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(creator_id), + title="华东医保观察项目宪章", + content={"success_criteria": ["10 天内交付策略 memo、政策雷达和竞品跟踪框架"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + + bootstrap_signal = Signal( + tenant_id=1, + source="PMFlow", + title="医保控费与器械国产替代立项信号", + content=input_text, + summary="立项信号", + signal_type="新需求", + priority="high", + impact_score=0.85, + matched_project_id=int(project.id), + reasoning=json.dumps(["来自高强度情景立项指令"], ensure_ascii=False), + confidence=0.92, + recommended_action="enter_only", + processed=True, + tasks_generated=json.dumps([], ensure_ascii=False), + status="已处理", + ) + db.add(bootstrap_signal) + await db.flush() + db.add( + ProjectSignalLink( + tenant_id=1, + project_id=int(project.id), + signal_id=int(bootstrap_signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=3200.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=240.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(creator_id), project_id=int(project.id))) + task_one = Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=None, + ai_recommended_assignees=None, + title="搭建政策抓取与公告解析管道", + description="每天跟踪江苏/浙江/安徽 医保支付改革、招采公告与价格体系拐点。", + required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], + status="todo", + priority="high", + estimated_hours=18.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 19), + completed_at=None, + ) + task_two = Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=None, + ai_recommended_assignees=None, + title="沉淀器械中标结构知识图谱", + description="把区域政策、招采公告和竞品动作串成结构化图谱。", + required_skill_tag_ids=[self.graph_skill_id], + status="todo", + priority="medium", + estimated_hours=12.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 20), + completed_at=None, + ) + db.add_all([task_one, task_two]) + await db.flush() + return { + "project": {"id": int(project.id), "title": project.title, "name": project.name}, + "signal": {"id": int(bootstrap_signal.id), "title": bootstrap_signal.title}, + "tasks": [ + {"id": int(task_one.id), "title": task_one.title}, + {"id": int(task_two.id), "title": task_two.title}, + ], + } + + with patch( + "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", + AsyncMock(side_effect=create_project_side_effect), + ): + project_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-project-hard-1", + message_text=( + "不要复用现在这个宏观研究盘子,单独立一个项目,名字就叫" + "“华东医保控费与器械国产替代观察台”,目标不是写周报," + "而是 10 天内拿出策略 memo、政策雷达和竞品跟踪框架。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + ) + + new_project_id = int(project_result["created_project_id"]) + + signal_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-signal-hard-1", + message_text=( + f"给项目ID: {new_project_id} 新增一条信号:持续盯江苏、浙江、安徽 2026 年器械带量采购、" + "DRG-DIP 支付改革和国产替代中标结构,只要价格体系或准入口径出现拐点就预警," + "不要只抓新闻标题。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + project_hint_id=new_project_id, + ) + + source_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-source-hard-1", + message_text=( + f"给项目ID: {new_project_id} 接一个复合信号源,不是简单 RSS:每天自动抓医保支付改革、" + "器械招采公告、上市公司电话会和 GitHub 开源竞品动态,优先走自动接入。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + project_hint_id=new_project_id, + ) + + db.add_all( + [ + UserProject(tenant_id=1, user_id=int(strategy_user.id), project_id=new_project_id), + UserProject(tenant_id=1, user_id=int(crawler_user.id), project_id=new_project_id), + UserProject(tenant_id=1, user_id=int(graph_user.id), project_id=new_project_id), + ] + ) + await db.flush() + + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 9, + "registry_status": "active", + "onboarding_status": "completed", + "openclaw_status": "completed", + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "worker_executions": [{"used_fallback": False}], + "pending_workers": [], + "next_step": "继续自动执行后续任务", + "fallback_used": False, + "raw_result": {}, + } + ), + ) as onboarding_run_mock: + resume_result = await kernel.process_ingress( + source_system="ai_pmo_ui", + source_message_id="resume-work-item-hard-1", + message_text=f"继续处理 work_item #{int(source_result['work_item_id'])}", + sender="老板", + channel_ref="ai_pmo_ui", + project_hint_id=new_project_id, + ) + + target_work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.id == int(source_result["work_item_id"]), + ) + ) + ) + ).scalar_one() + + new_project_tasks = ( + await db.execute( + select(Task) + .where(and_(Task.tenant_id == 1, Task.project_id == new_project_id)) + .order_by(Task.id.asc()) + ) + ).scalars().all() + task_policy_stream = next(task for task in new_project_tasks if "抓取" in task.title) + task_graph = next(task for task in new_project_tasks if "知识图谱" in task.title) + + recommendation_result = await recommend_personnel( + db=db, + tenant_id=1, + required_skills=["policy-analysis", "web-crawling"], + task_description=task_policy_stream.description, + task_id=str(task_policy_stream.id), + limit=5, + ) + + with patch( + "app.services.workload_service.llm_service.generate", + AsyncMock( + return_value=json.dumps( + { + "proposals": [ + { + "task_id": int(task_policy_stream.id), + "new_assignee_id": int(crawler_user.id), + "reason": "crawler engineer has exact skill mix and lower load", + }, + { + "task_id": int(task_graph.id), + "new_assignee_id": int(graph_user.id), + "reason": "graph engineer owns ontology and extraction pipeline", + }, + ] + }, + ensure_ascii=False, + ) + ), + ): + optimize_preview = await workload_service.build_ai_optimize_preview( + db, + tenant_id=1, + project_id=new_project_id, + max_moves=5, + unassigned_only=True, + ) + + optimize_result = await workload_service.apply_ai_optimize_plan( + db, + tenant_id=1, + proposals=optimize_preview, + ) + board_rows = await workload_service.get_team_workload_board(db, tenant_id=1, project_id=new_project_id) + cadence_service = AIPMOCadenceService(db, tenant_id=1) + program_view = await cadence_service.build_program_view() + + self.assertEqual(qa_result["status"], "answered") + self.assertIn("blocked task", qa_result["direct_reply_text"]) + cache_append_mock.assert_awaited_once() + self.assertEqual(cache_append_mock.await_args.kwargs["question"], hard_question) + + self.assertEqual(project_result["status"], "captured") + self.assertIsNotNone(project_result["created_project_id"]) + self.assertIn("已立项", project_result["direct_reply_text"]) + + self.assertEqual(signal_result["status"], "captured") + self.assertEqual(signal_result["linked_project_ids"], [new_project_id]) + self.assertIsNotNone(signal_result["signal_id"]) + + self.assertEqual(source_result["status"], "captured") + self.assertIn("技能缺口", source_result["direct_reply_text"]) + self.assertFalse(bool(source_result["review_summary"]["verified"])) + + self.assertEqual(onboarding_run_mock.await_count, 1) + self.assertEqual(resume_result["status"], "answered") + self.assertIn("已继续处理", resume_result["direct_reply_text"]) + self.assertEqual(target_work_item.state, "succeeded") + self.assertEqual(target_work_item.execution_result["signal_source_workflow"]["status"], "completed") + + self.assertGreaterEqual(recommendation_result["total_count"], 1) + self.assertEqual(recommendation_result["recommendations"][0]["user_name"], "周湛") + + self.assertGreaterEqual(len(optimize_preview), 2) + self.assertEqual(optimize_result["applied_count"], 2) + + task_assignee_map = {int(task.id): int(task.assignee_id or 0) for task in new_project_tasks} + self.assertEqual(task_assignee_map[int(task_policy_stream.id)], int(crawler_user.id)) + self.assertEqual(task_assignee_map[int(task_graph.id)], int(graph_user.id)) + + board_by_name = {str(row["user_name"]): row for row in board_rows} + self.assertIn("周湛", board_by_name) + self.assertIn("顾言", board_by_name) + self.assertGreater(float(board_by_name["周湛"]["current_assigned_hours"]), 8.0) + self.assertGreaterEqual(float(board_by_name["顾言"]["current_assigned_hours"]), 9.5) + + self.assertGreaterEqual(program_view["summary"]["program_count"], 1) + self.assertTrue( + any(new_project_id in program["project_ids"] for program in program_view["programs"]) + ) diff --git a/backend/tests/test_ai_pmo_kernel_service.py b/backend/tests/test_ai_pmo_kernel_service.py new file mode 100644 index 00000000..60363902 --- /dev/null +++ b/backend/tests/test_ai_pmo_kernel_service.py @@ -0,0 +1,590 @@ +import os +import unittest +from datetime import datetime +from tempfile import NamedTemporaryFile +from unittest.mock import AsyncMock, patch + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.models import Base, Budget, PortfolioSnapshot, Project, ProjectDomain, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem +from app.services.ai_pmo_cadence_service import AIPMOCadenceService +from app.services.ai_pmo_kernel_service import ( + AIPMOKernelService, + INTENT_SIGNAL_SOURCE_ONBOARD, + IntentResolver, +) +from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow +from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService + + +class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-test-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + domain = ProjectDomain( + tenant_id=1, + name="医药", + slug="pharma", + description="医药研究域", + config={}, + is_active=True, + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + owner_id=str(user.id), + ) + db.add(domain) + await db.flush() + + project = Project( + tenant_id=1, + name="医药行业十五五规划布局研究", + title="医药行业十五五规划布局研究", + description="研究医药行业方向和机会。", + owner_id=int(user.id), + status="active", + domain_id=int(domain.id), + assigned_role="pm", + signal_count=0, + ) + db.add(project) + await db.flush() + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(user.id), + title="项目宪章", + content={"success_criteria": ["两周内形成研究报告"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=1000.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=500.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(user.id), project_id=int(project.id))) + db.add( + UserWorkload( + tenant_id=1, + user_id=int(user.id), + current_assigned_hours=20.0, + max_available_hours_per_week=40.0, + workload_ratio=0.5, + ) + ) + db.add( + UserSkillBinding( + tenant_id=1, + user_id=int(user.id), + skill_tag_id=1, + proficiency_level="expert", + proficiency=5, + level="expert", + years_experience=8.0, + quality_score=9.0, + efficiency_score=8.5, + ) + ) + db.add( + Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=int(user.id), + ai_recommended_assignees=None, + title="梳理外部依赖", + description="当前依赖较多,任务处于 blocked。", + required_skill_tag_ids=[2], + status="blocked", + priority="high", + estimated_hours=8.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 1), + completed_at=None, + ) + ) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + def test_intent_resolver_detects_signal_source_request(self) -> None: + intent = IntentResolver.resolve( + text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex 仓库。", + project_id=1, + ) + self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD) + + async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + world_model = await service._build_world_model( + message_text="给项目ID: 1 新增一个医药信号。", + sender="老板", + project_hint_id=1, + ) + + self.assertEqual(world_model["budget"]["budget_id"], 1) + self.assertEqual(len(world_model["capacity"]), 1) + self.assertEqual(len(world_model["skill_graph"]), 1) + self.assertEqual(world_model["resolved_project_id"], 1) + self.assertEqual(world_model["project_domain"]["slug"], "pharma") + self.assertEqual(world_model["dependencies"]["pressure"], "high") + self.assertEqual(world_model["task_summary"]["uncovered_skill_tag_ids"], [2]) + + async def test_process_query_message_persists_work_item_and_reply_receipt(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock(return_value={"answer": "当前项目状态正常。", "sources": []}), + ): + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="msg-query-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "msg-query-1", + ) + ) + ) + ).scalar_one() + review_cycle = ( + await db.execute( + select(PortfolioSnapshot).where( + and_( + PortfolioSnapshot.tenant_id == 1, + PortfolioSnapshot.scope_type == "review_cycle", + PortfolioSnapshot.scope_ref == f"work_item:{int(work_item.id)}", + ) + ) + ) + ).scalar_one() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "answered") + self.assertTrue(result["direct_reply_required"]) + self.assertEqual(result["direct_reply_text"], "当前项目状态正常。") + self.assertEqual(result["decision_level"], "project") + self.assertIn("agent_quality_score", work_item.review_summary) + self.assertTrue(bool(work_item.review_summary.get("verified"))) + self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id)) + + async def test_duplicate_message_is_filtered(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock(return_value={"answer": "OK", "sources": []}), + ): + first = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="dup-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + second = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="dup-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertEqual(first["status"], "answered") + self.assertEqual(second["status"], "ignored") + self.assertFalse(second["direct_reply_required"]) + + async def test_policy_gate_blocks_archive_like_request(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="gate-1", + message_text="请把项目ID: 1 直接归档。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIn("人工确认", result["direct_reply_text"]) + + async def test_process_project_create_uses_new_kernel(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", + AsyncMock( + return_value={ + "project": {"id": 9, "title": "医药新项目", "name": "医药新项目"}, + "signal": {"id": 11}, + "tasks": [{"id": 21, "title": "任务A"}], + } + ), + ): + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="project-1", + message_text="请创建项目:医药新项目,做医药趋势研究。", + sender="老板", + channel_ref="pmflow", + ) + work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "project-1", + ) + ) + ) + ).scalar_one() + + self.assertEqual(result["status"], "captured") + self.assertEqual(result["created_project_id"], 9) + self.assertEqual(result["linked_project_ids"], [9]) + self.assertTrue(result["direct_reply_required"]) + self.assertEqual(work_item.project_id, 9) + + async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="source-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIn("技能缺口", result["direct_reply_text"]) + + async def test_signal_create_stays_auto_executable_under_dependency_pressure(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="signal-auto-1", + message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革和器械招采拐点。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIsNotNone(result["signal_id"]) + self.assertEqual(result["linked_project_ids"], [1]) + self.assertIn("已入库", result["direct_reply_text"]) + + async def test_final_phase_generates_risk_cadence_and_program_view(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="source-risk-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + cadence_service = AIPMOCadenceService(db, tenant_id=1) + latest_risk = await cadence_service.get_latest_snapshot( + cadence_type="risk_review", + project_id=1, + ) + program_view = await cadence_service.build_program_view() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIsNotNone(latest_risk) + self.assertEqual(latest_risk["scope_type"], "risk_review") + self.assertGreaterEqual(int(latest_risk["summary"]["blocked_task_count"]), 1) + self.assertEqual(program_view["summary"]["program_count"], 1) + self.assertGreaterEqual(int(program_view["summary"]["pending_manual_total"]), 1) + + async def test_followup_continue_resumes_single_waiting_manual_work_item(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + first = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 7, + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "fallback_used": False, + "raw_result": {}, + } + ), + ): + followup = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-followup-1", + message_text="继续处理", + sender="老板", + channel_ref="oc_phase3", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + original = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "feishu-source-1", + ) + ) + ) + ).scalar_one() + + self.assertEqual(first["status"], "captured") + self.assertEqual(followup["status"], "answered") + self.assertIn("已继续处理", followup["direct_reply_text"]) + self.assertEqual(original.state, "succeeded") + + async def test_followup_continue_requires_explicit_target_when_multiple_waiting_manual_items(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-2a", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-2b", + message_text="给项目ID: 1 新增一个 Web 搜索信号源,监控医保动态。", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + followup = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-followup-2", + message_text="继续处理", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + followup_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "feishu-followup-2", + ) + ) + ) + ).scalar_one() + + self.assertEqual(followup["status"], "captured") + self.assertIn("多个待确认工作项", followup["direct_reply_text"]) + self.assertEqual(followup_item.state, "waiting_manual") + + async def test_followup_explicit_work_item_id_can_resume_across_source_system(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + first = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-3", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3_explicit", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + target_id = int(first["work_item_id"]) + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 9, + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "fallback_used": False, + "raw_result": {}, + } + ), + ): + followup = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id="resume-ui-1", + message_text=f"继续处理 work_item #{target_id}", + sender="老板", + channel_ref="ai_pmo_ui", + project_hint_id=1, + ) + + self.assertEqual(followup["status"], "answered") + self.assertIn("已继续处理", followup["direct_reply_text"]) + + def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None: + normalized = SignalSourceDomainWorkflow.normalize( + { + "status": "partial", + "signal_source": { + "registry_id": 7, + "status": "pending", + "onboarding_status": "pending_manual", + }, + "openclaw": { + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "notification.create", + "status": "applied", + "payload": {"title": "待确认"}, + } + ] + } + }, + }, + "worker_executions": [ + { + "worker_role_key": "pm_dispatcher", + "status": "completed", + "used_fallback": True, + } + ], + "pending_workers": [], + } + ) + + self.assertEqual(normalized["status"], "pending_manual") + self.assertEqual(normalized["material_action_count"], 0) + + async def test_transport_mode_statement_is_answered_without_creating_work_item(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="transport-mode-1", + message_text="现在都是以长连接的形式,并不是webhook。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + persisted = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "transport-mode-1", + ) + ) + ) + ).scalar_one_or_none() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "answered") + self.assertTrue(result["direct_reply_required"]) + self.assertIn("长连接模式", result["direct_reply_text"]) + self.assertIsNone(persisted) + + async def test_signal_source_registry_write_re_normalizes_invalid_kind_and_generic_name(self) -> None: + async with self.session_factory() as db: + service = SignalSourceOrchestrationService(db) + result = await service._create_or_reuse_registry( + tenant_id=1, + project_id=1, + signal_id=None, + operator_user_id=None, + requested_via="feishu", + source_payload={ + "source_kind": "long", + "source_name": "给项目ID", + "source_identifier": "long:给项目id", + "description": "每日搜寻最新的医药行业变革动态", + "config": {"keywords": ["医药行业", "变革"]}, + }, + initiating_text="给项目ID: 1 接一个复合信号源:每日搜寻最新的医药行业变革动态。", + dry_run=False, + ) + + self.assertEqual(result["source_kind"], "feishu") + self.assertNotEqual(result["source_name"], "给项目ID") + self.assertTrue(str(result["source_identifier"]).startswith("feishu:")) diff --git a/backend/tests/test_ai_steward_fail_fast.py b/backend/tests/test_ai_steward_fail_fast.py new file mode 100644 index 00000000..b0852ccd --- /dev/null +++ b/backend/tests/test_ai_steward_fail_fast.py @@ -0,0 +1,107 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services.ai_steward import AIStewardService +from app.models import CognitiveConflict + + +class AIStewardFailFastTest(unittest.IsolatedAsyncioTestCase): + async def test_analyze_project_input_times_out_to_structured_fallback(self) -> None: + service = AIStewardService(db=None) # type: ignore[arg-type] + + with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())): + result = await service._analyze_project_input( + "创建一个 3 周上线的 AI 客服项目,先出可运行 MVP。", + tenant_id=1, + ) + + self.assertEqual(result["primary_role"], "ai_engineer") + self.assertEqual(result["timeline"], "3周") + + async def test_generate_task_chain_draft_times_out_to_default_tasks(self) -> None: + service = AIStewardService(db=None) # type: ignore[arg-type] + + with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())): + tasks = await service._generate_task_chain_draft( + analysis={ + "project_title": "AI 客服项目", + "tech_stack": ["FastAPI", "React"], + "timeline": "3周", + "primary_role": "ai_engineer", + }, + tenant_id=1, + ) + + self.assertGreaterEqual(len(tasks), 2) + self.assertEqual(tasks[0]["title"], "技术方案设计") + + async def test_persist_project_creation_draft_writes_conflict_user_ids_as_list(self) -> None: + added_rows = [] + + class FakeDB: + def add(self, row): + added_rows.append(row) + + async def flush(self): + current = len(added_rows) + for index, row in enumerate(added_rows, start=1): + if getattr(row, "id", None) is None: + row.id = index + + service = AIStewardService(db=FakeDB()) # type: ignore[arg-type] + + with patch( + "app.services.ai_steward.persist_task", + AsyncMock( + return_value=SimpleNamespace( + id=101, + title="任务A", + description="desc", + estimated_hours=8.0, + priority="high", + status="todo", + ) + ), + ): + payload = await service._persist_project_creation_draft( + normalized={ + "project": { + "name": "测试项目", + "title": "测试项目", + "description": "desc", + "assigned_role": "ai_engineer", + "domain_id": 1, + }, + "signal": { + "content": "signal", + "impact_score": 0.8, + "priority": "high", + "strategic_analysis": {}, + }, + "charter": { + "title": "项目宪章", + "content": {"success_criteria": ["ok"]}, + }, + "tasks": [SimpleNamespace(title="任务A", description="desc")], + "task_audit": [], + "risks": [ + { + "type": "schedule_delay", + "description": "任务超时预警", + "threshold_hours": 48.0, + "severity": "medium", + } + ], + "analysis": {}, + }, + input_text="创建测试项目", + creator_id=2, + tenant_id=1, + ) + + risk_rows = [row for row in added_rows if isinstance(row, CognitiveConflict)] + self.assertEqual(len(risk_rows), 1) + self.assertEqual(risk_rows[0].involved_user_ids, []) + self.assertEqual(payload["risks"][0]["description"], "任务超时预警") diff --git a/backend/tests/test_api_router_order.py b/backend/tests/test_api_router_order.py new file mode 100644 index 00000000..575c228e --- /dev/null +++ b/backend/tests/test_api_router_order.py @@ -0,0 +1,22 @@ +from app.api.router import api_router + + +def test_project_signal_route_uses_single_sstc_canonical_endpoint() -> None: + canonical = [] + legacy = [] + for route in api_router.routes: + path = getattr(route, "path", "") + methods = set(getattr(route, "methods", set()) or set()) + if path == "/projects/{project_id}/signals" and "GET" in methods: + endpoint = getattr(route, "endpoint", None) + canonical.append( + ( + getattr(endpoint, "__module__", ""), + getattr(endpoint, "__name__", ""), + ) + ) + if path == "/projects/{project_id}/linked-signals" and "GET" in methods: + legacy.append(path) + + assert canonical == [("app.api.sstc", "list_signals")] + assert legacy == [] diff --git a/backend/tests/test_auth_password_validation.py b/backend/tests/test_auth_password_validation.py new file mode 100644 index 00000000..2b7747cc --- /dev/null +++ b/backend/tests/test_auth_password_validation.py @@ -0,0 +1,40 @@ +from app.services.auth import AuthService +from app.services import auth as auth_module + + +def test_validate_password_rejects_over_72_utf8_bytes_with_friendly_message() -> None: + password = "a" * 73 + + try: + AuthService.hash_password(password) + raise AssertionError("expected ValueError for overlong password") + except ValueError as exc: + message = str(exc) + + assert "72 个 UTF-8 字节" in message + assert "72 bytes" not in message.lower() + + +def test_validate_password_counts_utf8_bytes_not_only_character_length() -> None: + password = "密" * 25 + + try: + AuthService.validate_password(password) + raise AssertionError("expected ValueError for multibyte password overflow") + except ValueError as exc: + message = str(exc) + + assert "72 个 UTF-8 字节" in message + + +def test_hash_password_falls_back_when_bcrypt_backend_misreports_72_bytes(monkeypatch) -> None: + class _BrokenPwdContext: + @staticmethod + def hash(_password: str) -> str: + raise ValueError("password cannot be longer than 72 bytes, truncate manually if necessary") + + monkeypatch.setattr(auth_module, "HAS_PASSLIB", True) + monkeypatch.setattr(auth_module, "pwd_context", _BrokenPwdContext(), raising=False) + hashed = AuthService.hash_password("Passw0rd!") + + assert hashed == AuthService._simple_hash("Passw0rd!") diff --git a/backend/tests/test_feishu_long_connection.py b/backend/tests/test_feishu_long_connection.py new file mode 100644 index 00000000..1f80f2ec --- /dev/null +++ b/backend/tests/test_feishu_long_connection.py @@ -0,0 +1,131 @@ +import unittest +from unittest.mock import patch + +from app.services.feishu_long_connection import FeishuLongConnectionManager + + +class _AliveThread: + def __init__(self) -> None: + self._alive = True + + def is_alive(self) -> bool: + return self._alive + + def join(self, timeout: float | None = None) -> None: + _ = timeout + self._alive = False + + +class _StubFeishuLongConnectionManager(FeishuLongConnectionManager): + def __init__(self, config: dict | None, preflight_exc: Exception | None = None) -> None: + super().__init__(session_factory=None) # type: ignore[arg-type] + self._sdk_available = True + self._stub_config = config + self._preflight_exc = preflight_exc + self.start_calls: list[tuple[str, str]] = [] + + async def _load_active_config(self) -> dict | None: + if self._stub_config is None: + return None + return dict(self._stub_config) + + async def _preflight_auth(self, *, tenant_id: int) -> None: + _ = tenant_id + if self._preflight_exc is not None: + raise self._preflight_exc + + def _start_ws_thread(self, app_id: str, app_secret: str) -> None: + self.start_calls.append((app_id, app_secret)) + self._thread = _AliveThread() + + +class FeishuLongConnectionManagerTest(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self) -> None: + manager = getattr(self, "manager", None) + if manager is not None and manager.status().get("running"): + await manager.stop() + + async def test_auth_failure_is_classified_without_starting_thread(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 9, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + }, + preflight_exc=ValueError("获取 tenant_access_token 失败(http=401): {'code': 99991663}"), + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["tenant_id"], 9) + self.assertEqual(status["last_error_code"], "auth_failed") + self.assertFalse(status["last_error_retryable"]) + self.assertFalse(status["thread_alive"]) + self.assertEqual(self.manager.start_calls, []) + + async def test_network_failure_is_marked_retryable(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 3, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + }, + preflight_exc=ValueError("访问飞书 auth 接口失败: timed out"), + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["last_error_code"], "network_error") + self.assertTrue(status["last_error_retryable"]) + self.assertEqual(status["restart_attempts"], 1) + self.assertIsNotNone(status["next_retry_at"]) + self.assertEqual(self.manager.start_calls, []) + + async def test_successful_start_records_tenant_and_auth_timestamp(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 12, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + } + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["tenant_id"], 12) + self.assertTrue(status["thread_alive"]) + self.assertIsNotNone(status["last_auth_at"]) + self.assertEqual(self.manager.start_calls, [("cli_xxx", "secret_xxx")]) + + async def test_retry_strategy_is_1_2_4_and_stops_after_three_attempts(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 7, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + } + ) + self.manager._set_error("network", code="network_error", retryable=True) + + with patch("app.services.feishu_long_connection.time.time", return_value=100.0): + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 1) + self.assertEqual(self.manager._next_retry_at, 101.0) + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 2) + self.assertEqual(self.manager._next_retry_at, 102.0) + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 3) + self.assertEqual(self.manager._next_retry_at, 104.0) + # 第 4 次触发应熔断,不再继续重试。 + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 3) + self.assertEqual(self.manager._next_retry_at, 0.0) + self.assertFalse(self.manager._last_error_retryable) diff --git a/backend/tests/test_feishu_routing_receipts.py b/backend/tests/test_feishu_routing_receipts.py new file mode 100644 index 00000000..9babf0b6 --- /dev/null +++ b/backend/tests/test_feishu_routing_receipts.py @@ -0,0 +1,177 @@ +import unittest +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services.feishu_integration import FeishuIntegrationService +from app.services.feishu_signal_pipeline import FeishuSignalPipelineService +from app.services.signal_capture import ProactiveSignalCaptureService +from app.services.context_cache import context_cache_service + + +class FeishuRoutingReceiptsTest(unittest.TestCase): + def test_normalize_incoming_text_strips_bot_mentions_and_quote_noise(self) -> None: + text = FeishuSignalPipelineService._normalize_incoming_text( + "@PMFlow\n回复:\n> 上一条消息\n[附带图片]\n麻烦看下统一通知入口" + ) + + self.assertEqual(text, "麻烦看下统一通知入口") + + def test_pending_receipt_prefers_clarification_message(self) -> None: + reply = FeishuIntegrationService._build_execution_receipt_reply( + message_content="@PMFlow 继续处理", + capture_result={ + "status": "captured", + "results": [{"route_status": "queued_for_decision"}], + "clarification_message": "当前群最近同时涉及项目#12、#18,请明确归属项目。", + }, + ) + + self.assertEqual(reply, "当前群最近同时涉及项目#12、#18,请明确归属项目。") + + def test_failed_receipt_uses_controlled_executor_message(self) -> None: + reply = FeishuIntegrationService._build_execution_receipt_reply( + message_content="@PMFlow 创建任务", + capture_result={ + "status": "captured", + "results": [{"route_status": "failed"}], + }, + ) + + self.assertEqual(reply, "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。") + + def test_followup_confirmation_reply_uses_real_db_ids(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + reply = service._build_followup_confirmation_reply( + target_item=SimpleNamespace(id=23, level="signal"), + confirm_result={"signal_id": "91", "project_id": "12"}, + ) + + self.assertEqual(reply, "已继续处理:信号#91 已确认并关联项目#12。") + + def test_provider_error_guard_blocks_aborted_error_text(self) -> None: + self.assertTrue(FeishuIntegrationService._looks_like_provider_error("Request was aborted.")) + + def test_normalize_outgoing_reply_text_replaces_provider_error(self) -> None: + self.assertEqual( + FeishuIntegrationService._normalize_outgoing_reply_text("Request was aborted."), + "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。", + ) + + def test_pipeline_decorate_capture_result_sanitizes_provider_error_reply(self) -> None: + result = FeishuSignalPipelineService._decorate_capture_result( + { + "status": "answered", + "classification": "question", + "direct_reply_text": "Request was aborted.", + }, + chat_type="group", + normalized_text="@PMFlow 现在状态如何", + route="group.qa_direct", + db_mutations=[], + ) + + self.assertEqual( + result["direct_reply_text"], + "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。", + ) + + def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None: + service = FeishuIntegrationService(db=None) # type: ignore[arg-type] + event = { + "message": { + "message_id": "om_duplicate", + "message_type": "text", + "chat_id": "oc_test", + "chat_type": "group", + "content": '{"text":"@PMFlow 帮我继续处理"}', + }, + "chat": { + "chat_id": "oc_test", + "chat_type": "group", + }, + "sender": { + "sender_type": "user", + "sender_id": {"open_id": "ou_test"}, + "sender_name": "林宇航", + }, + "type": "im.message.receive_v1", + } + + with ( + patch.object(service, "_record_ingress_trace", AsyncMock()), + patch.object(service, "_record_message_trace", AsyncMock()), + patch.object(service, "_resolve_sender_display_name", AsyncMock(return_value="林宇航")), + patch.object(service, "_safe_reply_text", AsyncMock()) as reply_mock, + patch( + "app.services.feishu_integration.ProactiveSignalCaptureService.capture_from_feishu", + AsyncMock( + return_value={ + "status": "ignored", + "reason": "duplicate_filtered", + "classification": "duplicate", + "direct_reply_required": True, + "direct_reply_text": "Request was aborted.", + } + ), + ), + ): + asyncio.run(service._handle_message_receive(event)) + + reply_mock.assert_not_awaited() + + def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None: + service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type] + + with ( + patch.object(context_cache_service, "get_session_messages", AsyncMock(return_value=[])), + patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock, + ): + asyncio.run( + service._sync_feishu_recent_cache( + tenant_id=1, + group_chat_id="oc_test", + sender="林宇航", + sender_open_id="ou_test", + message="目前医药方向值得关注什么?", + pipeline_result={ + "rag_result": {"answer": "可关注创新药、器械和公共卫生。"}, + "direct_reply_text": "可关注创新药、器械和公共卫生。", + }, + ) + ) + + append_mock.assert_awaited_once() + + def test_sync_feishu_recent_cache_skips_duplicate_turn(self) -> None: + service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type] + + with ( + patch.object( + context_cache_service, + "get_session_messages", + AsyncMock( + return_value=[ + {"role": "user", "content": "目前医药方向值得关注什么?"}, + {"role": "assistant", "content": "可关注创新药、器械和公共卫生。"}, + ] + ), + ), + patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock, + ): + asyncio.run( + service._sync_feishu_recent_cache( + tenant_id=1, + group_chat_id="oc_test", + sender="林宇航", + sender_open_id="ou_test", + message="目前医药方向值得关注什么?", + pipeline_result={ + "rag_result": {"answer": "可关注创新药、器械和公共卫生。"}, + "direct_reply_text": "可关注创新药、器械和公共卫生。", + }, + ) + ) + + append_mock.assert_not_awaited() diff --git a/backend/tests/test_inbox_queue_view.py b/backend/tests/test_inbox_queue_view.py new file mode 100644 index 00000000..e78bc9cb --- /dev/null +++ b/backend/tests/test_inbox_queue_view.py @@ -0,0 +1,369 @@ +import unittest +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from app.models.feishu_item import FeishuItem +from app.services.feishu_signal_pipeline import FOLLOWUP_STATUS_RE, FeishuSignalPipelineService + + +class InboxQueueViewTest(unittest.TestCase): + def test_has_processed_message_ignores_ingress_trace_only(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + + async def fake_execute(stmt): + sql = str(stmt) + if "FROM feishu_items" in sql: + return SimpleNamespace(scalar_one_or_none=lambda: None) + self.assertIn("ai_processing_logs.level IS NULL OR ai_processing_logs.level !=", sql) + self.assertIn( + "ai_processing_logs.routing_decision IS NULL OR ai_processing_logs.routing_decision !=", + sql, + ) + return SimpleNamespace(scalar_one_or_none=lambda: None) + + service.db.execute = AsyncMock(side_effect=fake_execute) # type: ignore[attr-defined] + + exists = asyncio.run(service._has_processed_message("om_ingress_only")) + + self.assertFalse(exists) + + def test_dedupe_queue_items_aggregates_occurrence_count(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + first = FeishuItem( + tenant_id=1, + raw_message="接入 GitHub 信号源", + level="signal", + card_type="signal", + route_status="pending", + dedupe_key="signal:3:group:external_integration", + occurrence_count=2, + ) + second = FeishuItem( + tenant_id=1, + raw_message="接入 GitHub 信号源", + level="signal", + card_type="signal", + route_status="pending", + dedupe_key="signal:3:group:external_integration", + occurrence_count=1, + ) + + rows = service._dedupe_queue_items([first, second]) + + self.assertEqual(len(rows), 1) + self.assertEqual(int(rows[0].occurrence_count), 3) + + def test_resolve_message_class_falls_back_from_route_status(self) -> None: + pending_item = FeishuItem( + tenant_id=1, + raw_message="同步 GitHub issue", + level="signal", + card_type="signal", + route_status="pending", + ) + auto_done_item = FeishuItem( + tenant_id=1, + raw_message="同步 GitHub issue", + level="signal", + card_type="signal", + route_status="auto_processed", + ) + + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(pending_item), "action_required") + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(auto_done_item), "auto_done") + + def test_resolve_message_class_supports_new_status_machine(self) -> None: + queued_item = FeishuItem( + tenant_id=1, + raw_message="新增一个项目", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + ) + undone_item = FeishuItem( + tenant_id=1, + raw_message="新增一个项目", + level="command", + card_type="project_create", + route_status="undone", + ) + + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(queued_item), "action_required") + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(undone_item), "auto_done") + + def test_extract_project_intent_fields_requires_title_description_and_scope(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + fields = service._extract_project_intent_fields("新增一个项目,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。") + + self.assertTrue(fields["ready"]) + self.assertEqual(fields["title"], "飞书协同中台") + self.assertIn("统一多群消息分发", fields["description"]) + self.assertIn("PMFlow 主数据入库", fields["project_scope"]) + + def test_resolve_intent_gate_recognizes_explicit_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + gate = asyncio.run( + service._resolve_intent_gate( + message="不对,是新增进 PMFlow 的项目列表里面,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。", + inferred_project_id=None, + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["canonical_action"], "project.create") + + def test_resolve_intent_gate_uses_context_prefill_for_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + service._maybe_prefill_project_fields_from_context = AsyncMock( # type: ignore[method-assign] + return_value={ + "title": "医药十五五方向研究", + "description": "基于最近一轮医药方向问答整理投资与立项建议。", + "project_scope": "围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + "missing_fields": [], + "ready": True, + } + ) + + gate = asyncio.run( + service._resolve_intent_gate( + message="汇总以上内容给我创建个项目在项目列表", + inferred_project_id=None, + source_chat_id="oc_test", + source_sender_open_id="ou_test", + sender="林宇航", + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["fields"]["title"], "医药十五五方向研究") + + def test_resolve_intent_gate_uses_embedded_message_context_for_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + gate = asyncio.run( + service._resolve_intent_gate( + message=( + '目前"十五五"规划(2026-2030)还在制定中,但从已有信号来看,医药行业这几个方向值得关注:' + " 创新药会是核心 中医药传承创新 应对老龄化 供应链安全 出海国际化" + " 根据这个汇总一下,给我创建一个项目在项目列表" + ), + inferred_project_id=None, + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["fields"]["title"], "十五五医药方向研究") + self.assertIn("创新药会是核心", gate["fields"]["description"]) + + def test_resolve_intent_gate_blocks_project_create_when_recent_context_is_failure_text(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + service._load_recent_chat_turns = AsyncMock( # type: ignore[method-assign] + return_value=[ + { + "question": "目前十五五规划里,医药行业值得关注哪些方向?", + "answer": "我暂时无法生成可靠回答(模型服务异常)。为避免误导,本次不直接给结论;请稍后重试。", + } + ] + ) + service._load_recent_chat_reply_fallback_text = AsyncMock(return_value="") # type: ignore[method-assign] + + gate = asyncio.run( + service._resolve_intent_gate( + message="根据上面汇总一下,给我创建一个项目在项目列表", + inferred_project_id=None, + source_chat_id="oc_test", + source_sender_open_id="ou_test", + sender="林宇航", + ) + ) + + self.assertEqual(gate["intent"], "clarification_required") + self.assertIn("title", gate["fields"]["missing_fields"]) + + def test_followup_continue_accepts_project_reference_message(self) -> None: + self.assertTrue( + FeishuSignalPipelineService._looks_like_continue_followup("你应该继续创建我刚才要求的项目") + ) + self.assertFalse( + FeishuSignalPipelineService._looks_like_continue_followup("继续创建一个项目,名称叫飞书协同中台") + ) + + def test_followup_status_regex_matches_creation_question(self) -> None: + self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("是否已经创建"))) + self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("创建了吗"))) + + def test_select_followup_status_target_prefers_project_create(self) -> None: + source_item = FeishuItem( + tenant_id=1, + raw_message="新增一个Web搜索信号源", + level="command", + card_type="command", + route_status="auto_done", + result_payload={"operation_code": "source.connect"}, + ) + project_item = FeishuItem( + tenant_id=1, + raw_message="给我创建一个项目在项目列表", + level="command", + card_type="project_create", + route_status="auto_done", + result_payload={"operation_code": "project.create"}, + ) + + target = FeishuSignalPipelineService._select_followup_status_target( + message="是否已经创建", + recent_items=[source_item, project_item], + ) + + self.assertIs(target, project_item) + + def test_resolve_project_candidate_fields_blocks_placeholder_project(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + item = FeishuItem( + tenant_id=1, + raw_message="给我创建一个项目在项目列表", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + result_payload={ + "operation_code": "project.create", + "operation_payload": { + "title": "", + "description": "", + }, + "project_scope": "", + }, + blocking_reason="未执行:当前建项目请求还缺少 项目名称。", + ) + + fields = service._resolve_project_candidate_fields(item=item, suggestion=None) + + self.assertFalse(fields["ready"]) + self.assertIn("title", fields["missing_fields"]) + + def test_create_project_gate_missing_fields_only_returns_clarification(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign] + service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign] + service._queue_project_intent_item = AsyncMock() # type: ignore[method-assign] + service._write_processing_log = AsyncMock() # type: ignore[method-assign] + service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("executor should not run") + ) + + result = asyncio.run( + service._handle_create_project_gate( + message="汇总以上内容给我创建个项目在项目列表", + sender="林宇航", + source_chat_id="oc_test", + source_sender_open_id="ou_test", + feishu_message_id="om_test", + captured_at=None, + intent_gate={ + "intent": "clarification_required", + "canonical_action": "project.create", + "fields": { + "title": "", + "description": "汇总以上内容给我创建个项目在项目列表", + "project_scope": "", + "missing_fields": ["title"], + "ready": False, + }, + "clarification_template": "未执行:当前建项目请求还缺少 项目名称。", + }, + process_started_at=0.0, + ) + ) + + self.assertEqual(result["intent"], "clarification_required") + self.assertEqual(result["direct_reply_text"], "未执行:当前建项目请求还缺少 项目名称。") + self.assertEqual(result["results"], []) + service._queue_project_intent_item.assert_not_awaited() + service._execute_controlled_operation.assert_not_awaited() + + def test_create_task_gate_missing_fields_only_returns_clarification(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign] + service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign] + service._queue_command_intent_item = AsyncMock() # type: ignore[method-assign] + service._write_processing_log = AsyncMock() # type: ignore[method-assign] + service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("executor should not run") + ) + + result = asyncio.run( + service._handle_create_task_gate( + message="帮我建个任务", + sender="林宇航", + source_chat_id="oc_test", + source_sender_open_id="ou_test", + feishu_message_id="om_test", + captured_at=None, + intent_gate={ + "intent": "clarification_required", + "canonical_action": "task.create", + "fields": { + "project_id": None, + "title": "", + "description": "", + "missing_fields": ["project_id", "title"], + "ready": False, + }, + "clarification_template": "未执行:当前建任务请求还缺少 归属项目、任务标题。", + }, + process_started_at=0.0, + ) + ) + + self.assertEqual(result["intent"], "clarification_required") + self.assertEqual(result["direct_reply_text"], "未执行:当前建任务请求还缺少 归属项目、任务标题。") + self.assertEqual(result["results"], []) + service._queue_command_intent_item.assert_not_awaited() + service._execute_controlled_operation.assert_not_awaited() + + def test_should_hide_pending_item_from_inbox_for_query_and_missing_fields(self) -> None: + lookup_item = FeishuItem( + tenant_id=1, + raw_message="现在有哪些项目", + level="signal", + card_type="signal", + route_status="pending", + ) + clarification_item = FeishuItem( + tenant_id=1, + raw_message="汇总以上内容给我创建个项目在项目列表", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + decision_reason="missing_required_fields", + ) + status_item = FeishuItem( + tenant_id=1, + raw_message="你好 长连接时都已通", + level="signal", + card_type="signal", + route_status="pending", + ) + + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(lookup_item)) + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(clarification_item)) + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(status_item)) + + def test_build_project_preview_prefers_structured_title_and_detail(self) -> None: + title = FeishuSignalPipelineService._build_project_preview_title( + title="", + description="基于最近一轮医药方向问答整理投资与立项建议。", + project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + ) + summary = FeishuSignalPipelineService._build_project_preview_summary( + description="基于最近一轮医药方向问答整理投资与立项建议。", + project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + blocking_reason="未执行:当前建项目请求还缺少 项目名称。", + fallback_text="汇总以上内容给我创建个项目在项目列表", + ) + + self.assertEqual(title, "围绕创新药") + self.assertIn("投资与立项建议", summary) diff --git a/backend/tests/test_llm_structured_extraction.py b/backend/tests/test_llm_structured_extraction.py new file mode 100644 index 00000000..d1e7cf25 --- /dev/null +++ b/backend/tests/test_llm_structured_extraction.py @@ -0,0 +1,207 @@ +import unittest +from unittest.mock import AsyncMock + +from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult + + +class LLMStructuredExtractionTest(unittest.TestCase): + def setUp(self) -> None: + self.service = LLMService() + + def _extract(self, response: dict, *, function_name: str = "openclaw_command_dispatch") -> StructuredExtractionResult: + return self.service._extract_structured_object_from_response( + function_name=function_name, + response=response, + fallback_text="", + provider="glm", + model="GLM-4.7", + ) + + def test_extracts_object_from_plain_content(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '{"summary":"ok","confidence":0.9,"operations":[]}', + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "ok") + + def test_extracts_object_from_typed_chunk_content(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": [ + {"type": "output_text", "text": '{"summary":"chunk","confidence":0.8,"operations":[]}'} + ] + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "chunk") + + def test_extracts_object_from_tool_call_arguments(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "tool_calls": [ + { + "function": { + "arguments": '{"summary":"args","confidence":0.7,"operations":[]}' + } + } + ], + "content": "", + } + } + ] + } + ) + + self.assertEqual(result.source_field, "tool_calls.function.arguments") + self.assertEqual(result.payload["summary"], "args") + + def test_extracts_json_from_reasoning_content_with_prefix_text(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": "", + "reasoning_content": "先分析上下文,再给结果:\n{\"summary\":\"from_reasoning\",\"confidence\":0.66,\"operations\":[]}", + } + } + ] + } + ) + + self.assertEqual(result.source_field, "message.reasoning_content") + self.assertEqual(result.payload["summary"], "from_reasoning") + + def test_returns_json_not_found_when_reasoning_has_only_natural_language(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": "", + "reasoning_content": "我认为还需要更多上下文。", + } + } + ] + } + ) + + self.assertEqual(result.error_code, "structured_json_not_found") + self.assertEqual(result.source_field, "message.reasoning_content") + + def test_unwraps_nested_json_field_once(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '{"json":"{\\"summary\\":\\"nested\\",\\"confidence\\":0.55,\\"operations\\":[]}"}', + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "nested") + + def test_rejects_top_level_array_for_object_only_function(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '[{"summary":"bad"}]', + } + } + ] + } + ) + + self.assertEqual(result.error_code, "structured_schema_invalid") + + +class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase): + async def test_generate_structured_object_uses_cache_after_first_hit(self) -> None: + service = LLMService() + calls: list[str] = [] + cache: dict[str, str] = {} + + class _FakeRedis: + async def get(self, key: str): + return cache.get(key) + + async def setex(self, key: str, _ttl: int, value: str): + cache[key] = value + + service._get_redis = AsyncMock(return_value=_FakeRedis()) + service._resolve_engine_for_call = AsyncMock( + return_value={ + "provider": "glm", + "model": "GLM-4.7", + "api_key": "fake", + "base_url": "http://unit.test", + } + ) + service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""}) + service._log_ai_call = AsyncMock() + + async def _fake_chat_completion_raw(**_: object) -> ChatCompletionResult: + calls.append("hit") + return ChatCompletionResult( + text='{"summary":"cached","confidence":0.9,"operations":[]}', + raw_response={ + "choices": [ + { + "message": { + "content": '{"summary":"cached","confidence":0.9,"operations":[]}', + } + } + ] + }, + ) + + service._chat_completion_raw = AsyncMock(side_effect=_fake_chat_completion_raw) + + first = await service.generate_structured_object( + "planner prompt", + db=object(), + tenant_id=1, + function_name="openclaw_command_dispatch", + response_mode="fast", + ) + second = await service.generate_structured_object( + "planner prompt", + db=object(), + tenant_id=1, + function_name="openclaw_command_dispatch", + response_mode="fast", + ) + + self.assertEqual(first.payload["summary"], "cached") + self.assertEqual(second.payload["summary"], "cached") + self.assertTrue(second.from_cache) + self.assertEqual(len(calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_openclaw_commander_service.py b/backend/tests/test_openclaw_commander_service.py new file mode 100644 index 00000000..15de8509 --- /dev/null +++ b/backend/tests/test_openclaw_commander_service.py @@ -0,0 +1,445 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.models import Project +from app.services.openclaw_commander_service import ( + OpenClawCommanderService, + OperationExecutorService, +) +from app.services.llm import StructuredExtractionResult + + +class _DuplicatePersistentExecutor(OperationExecutorService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _claim_persistent_idempotency(self, **_: object): + return None, { + "status": "skipped", + "message": "duplicate_persistent", + "entity_type": "notification", + "entity_id": "21", + "trace_id": "openclaw-dup-1", + } + + async def _execute_single_operation(self, **_: object): + raise AssertionError("duplicate notification should not execute") + + +class _SignalSourceFallbackCommander(OpenClawCommanderService): + def __init__(self, *, charter_ready: bool) -> None: + super().__init__(db=None) # type: ignore[arg-type] + self.charter_ready = charter_ready + + async def _load_project(self, **_: object): + return SimpleNamespace(id=3, owner_id=7, title="项目A", status="active") + + async def _load_latest_aligned_charter(self, **_: object): + if not self.charter_ready: + return None + return SimpleNamespace(id=2, status="aligned") + + async def _resolve_signal_source_notification_recipient(self, **_: object): + return 7 + + +class _ProjectFallbackCommander(OpenClawCommanderService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _load_project_directory(self, **_: object): + return [{"project_id": 8, "title": "财务冲账协同", "status": "active"}] + + +class _MemoryDB: + def __init__(self) -> None: + self.added: list[object] = [] + + def add(self, obj: object) -> None: + self.added.append(obj) + + async def flush(self) -> None: + for obj in self.added: + if isinstance(obj, Project) and getattr(obj, "id", None) in {None, 0}: + obj.id = 8 + + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + +class _ProjectExecutor(OperationExecutorService): + def __init__(self, *, existing_project: object | None = None) -> None: + self.memory_db = _MemoryDB() + super().__init__(db=self.memory_db) # type: ignore[arg-type] + self.existing_project = existing_project + + async def _load_project_by_title(self, **_: object): + return self.existing_project + + async def _load_active_user(self, *, tenant_id: int, user_id: int): + _ = tenant_id + return SimpleNamespace(id=user_id, status="active") + + +class OpenClawCommanderServiceTest(unittest.TestCase): + def setUp(self) -> None: + self.executor = OperationExecutorService(db=None) # type: ignore[arg-type] + + def test_plan_operations_accepts_tool_call_json(self) -> None: + service = OpenClawCommanderService(db=None) # type: ignore[arg-type] + service._commander_operation_catalog = lambda: [ # type: ignore[method-assign] + { + "operation_code": "task.assign", + "description": "assign", + "input_schema": {"properties": {"task_id": {}, "assignee_id": {}}}, + } + ] + + with patch( + "app.services.openclaw_commander_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "summary": "先补负责人,再发通知", + "confidence": 0.82, + "operations": [ + { + "operation_code": "task.assign", + "payload": {"task_id": 12, "assignee_id": 5}, + "reason": "技能更匹配", + } + ], + }, + source_field="tool_calls.function.arguments", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"), + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""), + tasks=[], + task_summary={}, + members=[], + instruction="把任务 12 分给张三", + dry_run=False, + max_operations=4, + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments") + self.assertEqual(plan["operations"][0]["payload"]["task_id"], 12) + + def test_plan_operations_returns_schema_invalid_diagnostics_on_bad_payload(self) -> None: + service = OpenClawCommanderService(db=None) # type: ignore[arg-type] + service._commander_operation_catalog = lambda: [] # type: ignore[method-assign] + service._build_fallback_plan = AsyncMock(return_value={"summary": "等待补充上下文。", "confidence": 0.0, "operations": []}) # type: ignore[method-assign] + + with patch( + "app.services.openclaw_commander_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="message.content", + provider="glm", + model="GLM-4.7", + error_code="structured_schema_invalid", + validation="invalid", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"), + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""), + tasks=[], + task_summary={}, + members=[], + instruction="把任务 12 分给张三", + dry_run=False, + max_operations=4, + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_schema_invalid") + self.assertEqual(plan["operations"], []) + + def test_validate_payload_coerces_integer_and_rejects_extra_fields(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="task.assign", + payload={"task_id": "12", "assignee_id": "8"}, + ) + + self.assertEqual(normalized, {"task_id": 12, "assignee_id": 8}) + + with self.assertRaisesRegex(ValueError, "unsupported fields"): + self.executor._validate_and_normalize_payload( + operation_code="task.assign", + payload={"task_id": 12, "assignee_id": 8, "foo": "bar"}, + ) + + def test_validate_task_create_payload_supports_array_and_numeric_fields(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="task.create", + payload={ + "project_id": "7", + "title": "补充告警兜底", + "required_skill_tag_ids": ["1", 2, "3"], + "estimated_hours": "4.5", + "priority": "P0", + }, + ) + + self.assertEqual(normalized["project_id"], 7) + self.assertEqual(normalized["required_skill_tag_ids"], [1, 2, 3]) + self.assertEqual(normalized["estimated_hours"], 4.5) + self.assertEqual(normalized["priority"], "P0") + + def test_validate_project_create_payload_supports_optional_owner_and_description(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="project.create", + payload={ + "title": "财务冲账协同", + "description": "用于协调财务冲账任务。", + "owner_id": "7", + }, + ) + + self.assertEqual( + normalized, + { + "title": "财务冲账协同", + "description": "用于协调财务冲账任务。", + "owner_id": 7, + }, + ) + + def test_workload_update_payload_requires_max_weekly_hours(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="personnel.workload_update", + payload={"user_id": "5", "max_weekly_hours": "32"}, + ) + + self.assertEqual(normalized, {"user_id": 5, "max_weekly_hours": 32.0}) + + with self.assertRaisesRegex(ValueError, "unsupported fields"): + self.executor._validate_and_normalize_payload( + operation_code="personnel.workload_update", + payload={"user_id": 5, "workload": 70}, + ) + + def test_notification_idempotency_key_uses_trimmed_content_hash(self) -> None: + base_payload = { + "user_id": 3, + "title": "请处理阻塞", + "content": " 任务 A 已重新分配给你 ", + } + same_after_trim = { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 A 已重新分配给你", + } + changed_content = { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 B 已重新分配给你", + } + + key_a = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=base_payload, + tenant_id=1, + project_id=9, + ) + key_b = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=same_after_trim, + tenant_id=1, + project_id=9, + ) + key_c = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=changed_content, + tenant_id=1, + project_id=9, + ) + key_other_project = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=same_after_trim, + tenant_id=1, + project_id=10, + ) + + self.assertEqual(key_a, key_b) + self.assertNotEqual(key_a, key_c) + self.assertNotEqual(key_a, key_other_project) + + def test_execute_operations_skips_persistent_duplicate_notification(self) -> None: + executor = _DuplicatePersistentExecutor() + + result = self.async_run( + executor.execute_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9), + project=SimpleNamespace(id=4), + operations=[ + { + "operation_code": "notification.create", + "payload": { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 A 已重新分配给你", + }, + "reason": "重复回放", + } + ], + dry_run=False, + trace_id="openclaw-1", + ) + ) + + self.assertEqual(result["requested_count"], 1) + self.assertEqual(result["skipped_count"], 1) + self.assertEqual(result["results"][0]["message"], "duplicate_persistent") + self.assertEqual(result["results"][0]["entity_id"], "21") + + def test_normalize_task_priority_maps_p_levels(self) -> None: + self.assertEqual(self.executor._normalize_task_priority("P0"), "high") + self.assertEqual(self.executor._normalize_task_priority("P1"), "medium") + self.assertEqual(self.executor._normalize_task_priority("P3"), "low") + + def test_signal_source_fallback_without_charter_creates_notification_only(self) -> None: + commander = _SignalSourceFallbackCommander(charter_ready=False) + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=3, + instruction="新增一个 GitHub 信号源,监控核心仓库 issue。", + max_operations=4, + error="llm offline", + ) + ) + + self.assertEqual(plan["summary"], "OpenClaw 规划失败,已回退为信号源接入最小闭环。") + self.assertEqual(len(plan["operations"]), 1) + self.assertEqual(plan["operations"][0]["operation_code"], "notification.create") + self.assertEqual(plan["operations"][0]["payload"]["title"], "GitHub信号源接入待确认") + + def test_signal_source_fallback_with_charter_creates_task_and_notification(self) -> None: + commander = _SignalSourceFallbackCommander(charter_ready=True) + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=3, + instruction="新增一个 GitHub 信号源,监控核心仓库 issue。", + max_operations=4, + error="llm offline", + ) + ) + + self.assertEqual([item["operation_code"] for item in plan["operations"]], ["task.create", "notification.create"]) + self.assertEqual(plan["operations"][0]["payload"]["project_id"], 3) + self.assertEqual(plan["operations"][0]["payload"]["title"], "接入GitHub信号源") + + def test_project_create_fallback_builds_project_create_operation(self) -> None: + commander = _ProjectFallbackCommander() + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=None, + instruction="帮我创建一个项目,财务冲账协同", + max_operations=4, + error="structured_http_error", + project_directory=None, + ) + ) + + self.assertEqual(plan["operations"][0]["operation_code"], "project.create") + self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同") + + def test_project_status_fallback_resolves_project_id_from_directory(self) -> None: + commander = _ProjectFallbackCommander() + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=None, + instruction="把财务冲账协同项目归档", + max_operations=4, + error="structured_http_error", + project_directory=None, + ) + ) + + self.assertEqual(plan["operations"][0]["operation_code"], "project.archive") + self.assertEqual(plan["operations"][0]["payload"]["project_id"], 8) + + def test_execute_project_create_creates_active_project(self) -> None: + executor = _ProjectExecutor() + + with patch( + "app.services.openclaw_commander_service.log_operation", + AsyncMock(), + ), patch( + "app.services.openclaw_commander_service.context_cache_service.invalidate_system_context", + AsyncMock(), + ): + result = self.async_run( + executor._execute_project_create( + tenant_id=1, + operator_user=SimpleNamespace(id=9, role="BOSS"), + payload={"title": "财务冲账协同", "description": "用于财务冲账。"}, + reason="explicit_project_create", + trace_id="openclaw-1", + ) + ) + + created_project = next(obj for obj in executor.memory_db.added if isinstance(obj, Project)) + self.assertEqual(result["status"], "applied") + self.assertEqual(created_project.title, "财务冲账协同") + self.assertEqual(created_project.status, "active") + self.assertEqual(result["result"]["project_id"], 8) + + def test_execute_project_create_skips_existing_project(self) -> None: + executor = _ProjectExecutor( + existing_project=SimpleNamespace(id=5, title="财务冲账协同", name="财务冲账协同", status="active") + ) + + result = self.async_run( + executor._execute_project_create( + tenant_id=1, + operator_user=SimpleNamespace(id=9, role="BOSS"), + payload={"title": "财务冲账协同"}, + reason="explicit_project_create", + trace_id="openclaw-1", + ) + ) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["entity_id"], "5") + self.assertEqual(result["result"]["status"], "active") + + def async_run(self, awaitable): + import asyncio + + return asyncio.run(awaitable) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_signal_source_orchestration_service.py b/backend/tests/test_signal_source_orchestration_service.py new file mode 100644 index 00000000..266118f3 --- /dev/null +++ b/backend/tests/test_signal_source_orchestration_service.py @@ -0,0 +1,422 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.models.signal_source_registry import SignalSourceRegistry +from app.services.llm import StructuredExtractionResult +from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService + + +class _StubSignalSourceOrchestrationService(SignalSourceOrchestrationService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _load_project(self, **_: object): + return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1) + + async def _load_signal(self, **_: object): + return SimpleNamespace( + id=9, + title="新增 GitHub 信号源", + summary="监控关键仓库 issue/PR", + signal_type="新需求", + recommended_action="enter_only", + content="新增 GitHub 信号源并自动拆任务", + ) + + async def _plan_onboarding(self, **_: object): + return ( + { + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源并下发 AI worker。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:core-repo", + "description": "跟踪 issue、pr、release", + }, + "worker_roles": ["pm_dispatcher", "tech_architect"], + "openclaw_instruction": "为 GitHub 信号源创建接入任务并通知负责人。", + }, + "planned", + { + "used_fallback": False, + "error_code": None, + "source_field": "message.content", + "provider": "glm", + "model": "GLM-4.7", + }, + ) + + async def _create_or_reuse_registry(self, **_: object): + row = SignalSourceRegistry( + tenant_id=1, + project_id=3, + signal_id=9, + source_kind="github", + source_name="核心仓库", + source_identifier="github:core-repo", + description="跟踪 issue、pr、release", + requested_via="pmflow", + status="active", + onboarding_status="pending", + ) + row.id = 11 # type: ignore[assignment] + return { + "status": "created", + "registry_id": 11, + "row": row, + } + + async def _resolve_operator_user(self, **_: object): + return SimpleNamespace(id=1, role="BOSS") + + async def _dispatch_openclaw(self, **_: object): + return { + "status": "completed", + "result": { + "trace_id": "openclaw-123", + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "entity_id": "21", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + }, + }, + } + + async def _execute_worker_roles(self, **_: object): + return [ + { + "worker_role_key": "pm_dispatcher", + "worker_role_name": "产品经理(任务分派)", + "status": "completed", + "summary": "已输出接入拆解。", + }, + { + "worker_role_key": "tech_architect", + "worker_role_name": "技术架构师", + "status": "completed", + "summary": "已输出接入约束。", + }, + ] + + +class SignalSourceOrchestrationServiceTest(unittest.TestCase): + def async_run(self, awaitable): + import asyncio + + return asyncio.run(awaitable) + + def test_detect_signal_source_request(self) -> None: + self.assertTrue( + SignalSourceOrchestrationService.looks_like_signal_source_request( + "请新增一个 GitHub 信号源,监控核心仓库 issue。" + ) + ) + self.assertFalse( + SignalSourceOrchestrationService.looks_like_signal_source_request( + "把任务 12 分给张三。" + ) + ) + + def test_orchestrate_from_signal_returns_completed_payload(self) -> None: + service = _StubSignalSourceOrchestrationService() + + result = self.async_run( + service.orchestrate_from_signal( + tenant_id=1, + project_id=3, + signal_id=9, + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + operator_user_id=1, + dry_run=True, + ) + ) + + self.assertEqual(result["status"], "planned") + self.assertEqual(result["signal_source"]["registry_id"], 11) + self.assertEqual(result["signal_source"]["source_kind"], "github") + self.assertEqual(len(result["worker_executions"]), 2) + self.assertEqual(result["openclaw"]["status"], "completed") + + def test_orchestrate_from_signal_skips_non_source_request(self) -> None: + service = _StubSignalSourceOrchestrationService() + + result = self.async_run( + service.orchestrate_from_signal( + tenant_id=1, + project_id=3, + signal_id=9, + initiating_text="把任务 12 调成 P0。", + requested_via="pmflow", + operator_user_id=1, + dry_run=True, + ) + ) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["reason"], "not_signal_source_request") + + def test_normalize_source_payload_uses_request_hash_for_generic_source_name(self) -> None: + service = _StubSignalSourceOrchestrationService() + + normalized = service._normalize_source_payload( + source_payload={}, + fallback_text="请新增一个 GitHub 信号源,监控核心仓库 issue 和 PR。", + requested_via="pmflow", + ) + + self.assertEqual(normalized["source_kind"], "github") + self.assertEqual(normalized["source_name"], "GitHub") + self.assertTrue(normalized["source_identifier"].startswith("github:req-")) + + def test_build_source_identifier_prefers_explicit_repo_locator(self) -> None: + identifier = SignalSourceOrchestrationService._build_source_identifier( + source_kind="github", + source_name="GitHub", + text="新增 GitHub 信号源,仓库是 openai/codex。", + source_payload={"repo": "OpenAI/Codex"}, + ) + + self.assertEqual(identifier, "github:openai/codex") + + def test_map_orchestration_status_updates_signal_status_label(self) -> None: + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("completed"), + "已编排", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("pending_manual"), + "待确认接入", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("partial"), + "部分编排", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("failed"), + "编排失败", + ) + + def test_resolve_registry_activation_requires_real_config_and_non_fallback_result(self) -> None: + registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation( + source_payload={ + "source_kind": "pmflow", + "source_name": "Web搜索", + "source_identifier": "pmflow:req-abc123", + "description": "新增一个Web搜索信号源,关键词:医药行业。", + "config": {}, + }, + openclaw_result={ + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + } + }, + }, + worker_results=[ + { + "worker_role_key": "pm_dispatcher", + "status": "completed", + "used_fallback": True, + } + ], + ) + + self.assertEqual(registry_status, "pending") + self.assertEqual(onboarding_status, "pending_manual") + + def test_resolve_registry_activation_allows_explicit_repo_with_real_worker_output(self) -> None: + registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation( + source_payload={ + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 openai/codex issue 和 PR", + "config": {}, + "repo": "openai/codex", + }, + openclaw_result={ + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + } + }, + }, + worker_results=[ + { + "worker_role_key": "tech_architect", + "status": "completed", + "used_fallback": False, + } + ], + ) + + self.assertEqual(registry_status, "active") + self.assertEqual(onboarding_status, "completed") + + def test_plan_onboarding_accepts_reasoning_only_json(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + "worker_roles": ["pm_dispatcher"], + "openclaw_instruction": "创建接入任务。", + }, + source_field="message.reasoning_content", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertTrue(plan["should_onboard"]) + self.assertEqual(diagnostics["source_field"], "message.reasoning_content") + + def test_plan_onboarding_uses_tool_call_payload_when_content_is_empty(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + "worker_roles": ["pm_dispatcher"], + "openclaw_instruction": "创建接入任务。", + }, + source_field="tool_calls.function.arguments", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + _, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments") + + def test_plan_onboarding_returns_schema_invalid_diagnostics(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="message.content", + provider="glm", + model="GLM-4.7", + error_code="structured_schema_invalid", + validation="invalid", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_schema_invalid") + self.assertTrue(plan["should_onboard"]) + + def test_plan_onboarding_returns_json_not_found_diagnostics(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="response.reasoning_content", + provider="glm", + model="GLM-4.7", + error_code="structured_json_not_found", + validation="invalid", + ) + ), + ): + _, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_json_not_found") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sstc_signal_state_machine.py b/backend/tests/test_sstc_signal_state_machine.py new file mode 100644 index 00000000..a0090368 --- /dev/null +++ b/backend/tests/test_sstc_signal_state_machine.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.api.sstc import ( + _build_signal_created_at_range, + _collect_locked_signal_conflicts, + _extract_source_attachments, + _match_signal_filters, + _normalize_signal_status_code, +) +from app.services.feishu_integration import FeishuIntegrationService + + +class SignalStateMachineHelpersTest(unittest.TestCase): + def test_normalize_signal_status_code_prefers_linked_state(self) -> None: + status_code = _normalize_signal_status_code( + linked_state="linked", + signal_status="待处理", + processed=False, + ) + self.assertEqual(status_code, "linked") + + def test_normalize_signal_status_code_uses_closed_for_processed(self) -> None: + status_code = _normalize_signal_status_code( + linked_state=None, + signal_status="", + processed=True, + ) + self.assertEqual(status_code, "closed") + + def test_collect_locked_signal_conflicts(self) -> None: + charters = [ + SimpleNamespace( + id=101, + title="宪章A", + content={"signal_ids": [1, 2]}, + signal_id=None, + ), + SimpleNamespace( + id=102, + title="宪章B", + content={"signal_ids": [3]}, + signal_id=5, + ), + ] + conflict_ids, conflict_map = _collect_locked_signal_conflicts( + charters=charters, # type: ignore[arg-type] + candidate_signal_ids=[2, 5, 9], + ) + self.assertEqual(conflict_ids, [2, 5]) + self.assertEqual(conflict_map[2][0]["charter_id"], 101) + self.assertEqual(conflict_map[5][0]["charter_id"], 102) + + def test_build_signal_created_at_range_rejects_invalid_order(self) -> None: + with self.assertRaises(HTTPException): + _build_signal_created_at_range( + created_from="2026-03-11", + created_to="2026-03-10", + ) + + def test_match_signal_filters_keyword_and_status(self) -> None: + row = { + "id": "88", + "title": "新增飞书信号源", + "content": "支持附件同步", + "proposer_name": "老板", + "charter_name": "信号治理宪章", + "source": "Feishu", + "priority": "P1", + "status_code": "pending", + } + matched = _match_signal_filters( + row=row, + status_filter="pending", + keyword="附件", + source="Feishu", + priority="p1", + created_from_utc=datetime(2026, 3, 1, 0, 0, 0), + created_to_utc=datetime(2026, 3, 20, 0, 0, 0), + created_at_dt=datetime(2026, 3, 10, 12, 0, 0), + ) + self.assertTrue(matched) + + def test_extract_source_attachments_normalizes_and_deduplicates(self) -> None: + attachments = _extract_source_attachments( + { + "attachments": [ + {"attachment_type": "image", "image_key": "img_a", "file_name": "截图A"}, + {"attachment_type": "img", "image_key": "img_a", "file_name": "截图A重复"}, + {"attachment_type": "file", "file_key": "file_b", "file_name": "需求文档.pdf"}, + ] + } + ) + self.assertEqual(len(attachments), 2) + self.assertEqual(attachments[0]["attachment_type"], "image") + self.assertEqual(attachments[1]["file_name"], "需求文档.pdf") + + +class FeishuMessagePayloadParserTest(unittest.TestCase): + def setUp(self) -> None: + self.service = FeishuIntegrationService(db=None) # type: ignore[arg-type] + + def test_extract_message_payload_for_image_message(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "image", + "content": '{"image_key":"img_001"}', + } + } + ) + self.assertEqual(payload["message_type"], "image") + self.assertEqual(len(payload["attachments"]), 1) + self.assertIn("附件消息", str(payload["text"] or "")) + + def test_extract_message_payload_for_file_message(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "file", + "content": '{"file_key":"file_001","file_name":"立项材料.docx","file_size":1024}', + } + } + ) + self.assertEqual(len(payload["attachments"]), 1) + attachment = payload["attachments"][0] + self.assertEqual(attachment["attachment_type"], "file") + self.assertEqual(attachment["file_name"], "立项材料.docx") + + def test_extract_message_payload_for_post_with_text_and_image(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "post", + "content": { + "post": { + "zh_cn": { + "content": [ + [ + {"tag": "text", "text": "请评估这张图的风险点"}, + {"tag": "img", "image_key": "img_post_1"}, + ] + ] + } + } + }, + } + } + ) + self.assertEqual(payload["message_type"], "post") + self.assertEqual(len(payload["attachments"]), 1) + self.assertIn("请评估这张图的风险点", str(payload["text"] or "")) + + +if __name__ == "__main__": + unittest.main() + diff --git a/backend/tests/test_task_persistence.py b/backend/tests/test_task_persistence.py new file mode 100644 index 00000000..54eaf05c --- /dev/null +++ b/backend/tests/test_task_persistence.py @@ -0,0 +1,80 @@ +import unittest + +from app.models import SkillTag +from app.services.task_persistence import ( + build_task_create_draft, + resolve_skill_tag_ids_by_names, +) + + +class _ScalarRows: + def __init__(self, rows: list[object]) -> None: + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + +class _FakeDB: + def __init__(self, rows: list[object]) -> None: + self._rows = rows + + async def execute(self, *_args, **_kwargs): + return _ScalarRows(self._rows) + + +class TaskPersistenceTest(unittest.IsolatedAsyncioTestCase): + def test_build_task_create_draft_normalizes_fields(self) -> None: + draft = build_task_create_draft( + title=" 补齐统一通知入口 ", + required_skill_tag_ids=["1", 2, "2"], + priority="P0", + status="pending", + estimated_hours="4.5", + due_date="2026-03-07T10:00:00Z", + ) + + self.assertEqual(draft.title, "补齐统一通知入口") + self.assertEqual(draft.required_skill_tag_ids, [1, 2]) + self.assertEqual(draft.priority, "high") + self.assertEqual(draft.status, "todo") + self.assertEqual(draft.estimated_hours, 4.5) + self.assertIsNotNone(draft.due_date) + self.assertIsNone(draft.due_date.tzinfo) + + async def test_resolve_skill_tag_ids_by_names_preserves_unknowns_for_audit(self) -> None: + db = _FakeDB( + [ + SkillTag(id=11, tenant_id=1, name="系统设计", slug="system-design", category="skill"), + SkillTag(id=12, tenant_id=1, name="开发", slug="development", category="skill"), + ] + ) + + resolution = await resolve_skill_tag_ids_by_names( + db, + tenant_id=1, + skill_names=["系统设计", "开发", "未知技能"], + ) + + self.assertEqual(resolution.matched_ids, [11, 12]) + self.assertEqual(resolution.unmatched_names, ["未知技能"]) + self.assertEqual( + resolution.matched_items, + [ + { + "input_name": "系统设计", + "skill_tag_id": 11, + "skill_tag_name": "系统设计", + "skill_tag_slug": "system-design", + }, + { + "input_name": "开发", + "skill_tag_id": 12, + "skill_tag_name": "开发", + "skill_tag_slug": "development", + }, + ], + ) diff --git a/devops/tunnel/setup.sh b/devops/tunnel/setup.sh new file mode 100755 index 00000000..545746ce --- /dev/null +++ b/devops/tunnel/setup.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# Cloudflare Tunnel Setup Script for PMFlow + +set -e + +echo "🚀 Starting Cloudflare Tunnel Setup..." + +# Check if cloudflared is installed +if ! command -v cloudflared &> /dev/null; then + echo "❌ cloudflared is not installed. Please install it first:" + echo " brew install cloudflared" + exit 1 +fi + +# Ask for configuration +read -p "Enter your Frontend domain (e.g., pmflow.com): " FRONTEND_DOMAIN +read -p "Enter your Backend domain (e.g., api.pmflow.com): " BACKEND_DOMAIN +read -p "Enter a name for your tunnel (e.g., pmflow-tunnel): " TUNNEL_NAME + +if [ -z "$FRONTEND_DOMAIN" ] || [ -z "$BACKEND_DOMAIN" ] || [ -z "$TUNNEL_NAME" ]; then + echo "❌ Frontend domain, Backend domain, and Tunnel name are required." + exit 1 +fi + +echo "Checking authentication..." +# Check if cert.pem exists in default location +if [ ! -f "$HOME/.cloudflared/cert.pem" ]; then + echo "⚠️ You need to login to Cloudflare first." + cloudflared tunnel login +fi + +echo "Creating tunnel '$TUNNEL_NAME'..." +# Check if tunnel exists, if not create it +if ! cloudflared tunnel list | grep -q "$TUNNEL_NAME"; then + cloudflared tunnel create "$TUNNEL_NAME" +else + echo "ℹ️ Tunnel '$TUNNEL_NAME' already exists." +fi + +echo "Routing DNS..." +# Route DNS for both domains +cloudflared tunnel route dns "$TUNNEL_NAME" "$FRONTEND_DOMAIN" +cloudflared tunnel route dns "$TUNNEL_NAME" "$BACKEND_DOMAIN" + +# Get Tunnel ID +TUNNEL_ID=$(cloudflared tunnel list | grep "$TUNNEL_NAME" | awk '{print $1}') + +if [ -z "$TUNNEL_ID" ]; then + echo "❌ Could not find Tunnel ID for '$TUNNEL_NAME'" + exit 1 +fi + +echo "Generating config file..." +# Create config.yml +mkdir -p devops/tunnel +cat > devops/tunnel/config.yml <> "$ENV_FILE" + fi +else + sed -i "s|VITE_APP_DOMAIN=.*|VITE_APP_DOMAIN=$FRONTEND_DOMAIN|" "$ENV_FILE" + if grep -q "VITE_API_BASE_URL=" "$ENV_FILE"; then + sed -i "s|VITE_API_BASE_URL=.*|VITE_API_BASE_URL=https://$BACKEND_DOMAIN|" "$ENV_FILE" + else + echo "VITE_API_BASE_URL=https://$BACKEND_DOMAIN" >> "$ENV_FILE" + fi +fi + +if ! grep -q "VITE_APP_DOMAIN=" "$ENV_FILE"; then + echo "VITE_APP_DOMAIN=$FRONTEND_DOMAIN" >> "$ENV_FILE" +fi + +echo "✅ Updated $ENV_FILE with:" +echo " VITE_APP_DOMAIN=$FRONTEND_DOMAIN" +echo " VITE_API_BASE_URL=https://$BACKEND_DOMAIN" + +echo "🎉 Setup complete! You can now run the tunnel with:" +echo " ./devops/tunnel/start.sh" + diff --git a/devops/tunnel/start-quick.sh b/devops/tunnel/start-quick.sh new file mode 100755 index 00000000..ef2177ae --- /dev/null +++ b/devops/tunnel/start-quick.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Cloudflare Quick Tunnel (No Domain Required) +# Generates a random stable-ish URL for the session + +echo "🚀 Starting Quick Tunnel..." +echo "⚠️ Note: This uses a random trycloudflare.com URL." +echo "⚠️ For a fixed custom domain, you must PURCHASE a domain and configure it via setup.sh" + +# Kill existing tunnels +pkill -f "cloudflared tunnel" >/dev/null 2>&1 || true + +echo "🌐 Starting Frontend Tunnel (port 5174)..." +cloudflared tunnel --url http://localhost:5174 > logs/tunnel-frontend.log 2>&1 & +PID_FRONT=$! + +echo "🌐 Starting Backend Tunnel (port 8000)..." +cloudflared tunnel --url http://localhost:8000 > logs/tunnel-backend.log 2>&1 & +PID_BACK=$! + +echo "⏳ Waiting for tunnels to initialize..." +sleep 5 + +URL_FRONT=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-frontend.log | head -1) +URL_BACK=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-backend.log | head -1) + +check_url() { + local url="$1" + local max_retry="$2" + local retry=1 + while [ "$retry" -le "$max_retry" ]; do + if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + retry=$((retry + 1)) + done + return 1 +} + +if [ -n "$URL_FRONT" ] && [ -n "$URL_BACK" ]; then + FRONT_OK=false + BACK_OK=false + if check_url "${URL_FRONT}/login" 25 || check_url "${URL_FRONT}" 25; then + FRONT_OK=true + fi + if check_url "${URL_BACK}/healthz" 25; then + BACK_OK=true + fi + + echo "" + if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then + echo "✅ Tunnels Started and reachable!" + else + echo "⚠️ Tunnels started, but health check not fully ready yet. Retry in 10-30s." + fi + echo "----------------------------------------" + echo "Frontend: $URL_FRONT" + echo "Backend: $URL_BACK" + echo "----------------------------------------" + echo "ℹ️ Frontend tunnel can call backend via /api proxy; no need to set VITE_API_BASE_URL." + echo "----------------------------------------" +else + echo "❌ Failed to get tunnel URLs. Check logs/tunnel-*.log" +fi + +wait $PID_FRONT $PID_BACK diff --git a/devops/tunnel/start.sh b/devops/tunnel/start.sh new file mode 100755 index 00000000..51eb4cbc --- /dev/null +++ b/devops/tunnel/start.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Cloudflare Tunnel Start Script for PMFlow + +CONFIG_FILE="devops/tunnel/config.yml" +SETUP_SCRIPT="devops/tunnel/setup.sh" + +echo "🚀 Checking Cloudflare Tunnel configuration..." + +# Check if config.yml exists +if [ ! -f "$CONFIG_FILE" ]; then + echo "⚠️ Named Tunnel Configuration not found." + echo " If you have a domain, you can set up a stable tunnel." + echo " If not, you can use Quick Tunnels (random URLs)." + echo "" + read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE + + if [[ "$SETUP_CHOICE" =~ ^[Yy]$ ]]; then + if [ -f "$SETUP_SCRIPT" ]; then + # Run setup script and check its exit code + if ! bash "$SETUP_SCRIPT"; then + echo "❌ Setup failed or was cancelled." + exit 1 + fi + else + echo "❌ Setup script not found at $SETUP_SCRIPT" + exit 1 + fi + else + echo "🔄 Falling back to Quick Tunnels..." + bash devops/tunnel/start-quick.sh + exit 0 + fi +fi + +# Re-check config after potential setup +if [ -f "$CONFIG_FILE" ]; then + echo "✅ Configuration found." + echo "🌐 Starting tunnel for Frontend and Backend..." + + # Run the tunnel using the config file + cloudflared tunnel run --config "$CONFIG_FILE" +else + echo "❌ Failed to establish configuration." + exit 1 +fi diff --git a/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx b/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx new file mode 100644 index 00000000..27464b2d --- /dev/null +++ b/frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx @@ -0,0 +1,390 @@ +import { useEffect, useMemo, useState } from "react"; +import { + AlertCircle, + Bot, + CheckCircle2, + Loader2, + Play, + RefreshCcw, + ShieldAlert, + Wand2, +} from "lucide-react"; + +import { aiStewardApi, type OpenClawCommandDispatchResponse } from "@/api/aiSteward"; +import { projectsApi, type ProjectSummary } from "@/api/projects"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { formatProjectNameWithId, toZhStatus } from "@/lib/display"; + +const EXAMPLE_COMMANDS = [ + "把当前未分配的高优先级任务分给最合适的人,并通知相关成员。", + "根据当前负载重新平衡本项目任务,先 dry run 给出调整方案。", + "检查 blocked 任务,能恢复推进的直接切状态,不能恢复的通知负责人补充信息。", +]; + +const MANAGER_ROLES = new Set(["BOSS", "ADMIN"]); + +function resultTone(status: string): string { + const key = String(status || "").trim().toLowerCase(); + if (key === "applied") return "bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800"; + if (key === "planned") return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800"; + if (key === "skipped") return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800"; + return "bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800"; +} + +function renderOperationLabel(operationCode: string): string { + const code = String(operationCode || "").trim().toLowerCase(); + if (code === "task.create") return "任务创建"; + if (code === "task.assign") return "任务分配"; + if (code === "task.prioritize") return "优先级调整"; + if (code === "task.status_transition") return "状态迁移"; + if (code === "notification.create") return "通知创建"; + if (code === "personnel.workload_update") return "工时容量调整"; + return operationCode || "-"; +} + +interface OpenClawCommanderPanelProps { + currentUserRole?: string | null; +} + +export function OpenClawCommanderPanel({ currentUserRole }: OpenClawCommanderPanelProps) { + const [projects, setProjects] = useState([]); + const [projectLoading, setProjectLoading] = useState(false); + const [projectId, setProjectId] = useState(""); + const [instruction, setInstruction] = useState(""); + const [dryRun, setDryRun] = useState(true); + const [maxOperations, setMaxOperations] = useState("6"); + const [dispatching, setDispatching] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const canDispatch = MANAGER_ROLES.has(String(currentUserRole || "").trim().toUpperCase()); + const selectedProject = useMemo( + () => projects.find((item) => String(item.id) === String(projectId)) || null, + [projectId, projects] + ); + + const loadProjects = async () => { + setProjectLoading(true); + try { + const payload = await projectsApi.getSummary(); + const items = payload.projects || []; + setProjects(items); + if (!projectId && items[0]?.id) { + setProjectId(String(items[0].id)); + } + } catch (loadError) { + console.error("load commander projects failed", loadError); + setError("加载项目列表失败"); + } finally { + setProjectLoading(false); + } + }; + + useEffect(() => { + if (!canDispatch) { + return; + } + void loadProjects(); + }, [canDispatch]); + + const handleDispatch = async () => { + const parsedProjectId = Number(projectId); + const parsedMaxOperations = Number(maxOperations); + + if (!Number.isInteger(parsedProjectId) || parsedProjectId <= 0) { + setError("请选择有效项目"); + return; + } + if (!instruction.trim()) { + setError("请输入调度指令"); + return; + } + if (!Number.isInteger(parsedMaxOperations) || parsedMaxOperations < 1 || parsedMaxOperations > 20) { + setError("max operations 仅支持 1-20"); + return; + } + + setDispatching(true); + setError(null); + try { + const payload = await aiStewardApi.dispatchCommand({ + project_id: parsedProjectId, + instruction: instruction.trim(), + dry_run: dryRun, + max_operations: parsedMaxOperations, + }); + setResult(payload); + if (!dryRun) { + void loadProjects(); + } + } catch (dispatchError: any) { + const text = typeof dispatchError?.message === "string" ? dispatchError.message : "指挥调度失败"; + setError(text); + } finally { + setDispatching(false); + } + }; + + if (!canDispatch) { + return ( + + + +
Commander 仅开放给管理角色
+
+ 当前接口要求 `BOSS / ADMIN`,普通成员仍可通过任务看板或 AI 问答拿建议,但不能直接下发执行。 +
+
+
+ ); + } + + return ( +
+ + +
+
+ +
+
+ OpenClaw Commander + 直接下达自然语言指令,让指挥层规划并驱动 AI 管家执行。 +
+
+
+ +
+
+ + +
+
+ {projects.length === 0 ? ( +
+ {projectLoading ? "正在加载项目..." : "暂无可调度项目"} +
+ ) : ( + projects.map((project) => { + const active = String(project.id) === String(projectId); + return ( + + ); + }) + )} +
+
+ +
+
+
+ + setProjectId(event.target.value)} + placeholder="例如 1" + /> +
+
+ + setMaxOperations(event.target.value)} + inputMode="numeric" + placeholder="6" + /> +
+
+ + {selectedProject ? ( +
+
{formatProjectNameWithId(selectedProject)}
+
+ Active {selectedProject.in_progress_tasks} / Pending {selectedProject.pending_tasks} / Blocked 由执行结果返回。 +
+
+ ) : null} + +
+ +