diff --git a/.openclaw/pmflow-routing-rules.json b/.openclaw/pmflow-routing-rules.json index 0818c1d1..cd365b48 100644 --- a/.openclaw/pmflow-routing-rules.json +++ b/.openclaw/pmflow-routing-rules.json @@ -29,6 +29,16 @@ "api_env": "OPENAI_API_KEY", "function_exact": "classify_intent_complex" }, + { + "name": "engine_low_intent_kernel_exact", + "enabled": true, + "priority": 310, + "tier": "low", + "provider": "glm", + "model": "GLM-4.7", + "api_env": "GLM_API_KEY", + "function_exact": "classify_intent_ai_pmo_kernel" + }, { "name": "engine_low_execute_glm_flash", "enabled": true, diff --git a/.tmp/feishu_case1_v4.html b/.tmp/feishu_case1_v4.html new file mode 100644 index 00000000..35bf9db7 --- /dev/null +++ b/.tmp/feishu_case1_v4.html @@ -0,0 +1 @@ +

Case 1 回复

source_message_id: real-scene-p1-v4-1-1773760579
杨宇当前负载为 199.17%。根据系统快照,他身兼多职(FE, BOSS, AI, BE, ADMIN),负责 4 个项目,负载已严重超载,建议尽快进行任务调配。
\ No newline at end of file diff --git a/.tmp/feishu_case1_v4.png b/.tmp/feishu_case1_v4.png new file mode 100644 index 00000000..92c03a44 Binary files /dev/null and b/.tmp/feishu_case1_v4.png differ diff --git a/.tmp/feishu_case2_fail.html b/.tmp/feishu_case2_fail.html new file mode 100644 index 00000000..33861985 --- /dev/null +++ b/.tmp/feishu_case2_fail.html @@ -0,0 +1,26 @@ + + + + + Feishu 验收失败样本 + + + +
+

真实飞书验收 - 失败证据(Case 2)

+
source_message_id: real-scene-p1-v2-2-1773749870
+
最近AI行业有什么动态
+
已收到,正在处理。
+
模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容...
+
+ + diff --git a/.tmp/feishu_case2_fail.png b/.tmp/feishu_case2_fail.png new file mode 100644 index 00000000..edce1b2b Binary files /dev/null and b/.tmp/feishu_case2_fail.png differ diff --git a/.tmp/feishu_case2_v4.html b/.tmp/feishu_case2_v4.html new file mode 100644 index 00000000..4af2d13f --- /dev/null +++ b/.tmp/feishu_case2_v4.html @@ -0,0 +1,25 @@ +

Case 2 回复

source_message_id: real-scene-p1-v4-2-1773760726
[行业动态]
+当前未接入实时数据,以下基于训练数据(截止2024年4月):
+
+**大模型领域:**
+- GPT-4 Turbo、Claude 3、Gemini 1.5 Pro 等多模态大模型持续迭代,上下文窗口突破百万级
+- 开源模型快速追赶,Llama 3、Mistral、Qwen 等性能逼近闭源模型
+- 模型成本大幅下降,API 价格战激烈,Token 成本降低约 10 倍
+
+**应用落地趋势:**
+- AI Agent(智能体)成为主流方向,从单轮对话转向多步骤任务执行
+- RAG(检索增强生成)技术成熟,企业知识库问答成为标配场景
+- 垂直领域 AI 应用爆发:客服、法律、医疗、教育等场景深度渗透
+
+**行业监管:**
+- 欧盟 AI 法案正式通过,全球 AI 治理框架逐步成型
+- 中国发布生成式 AI 管理办法,合规要求趋严
+
+---
+
+[对项目的影响]
+对你们 **AI劝退助手** 项目的影响:
+
+1. **技术红利**:大模型成本下降、推理能力提升,可降低项目运营成本,支持更复杂的对话策略(如多轮情绪分析、个性化劝退话术生成)
+
+2. **竞争压力**:AI 应用门槛降低
\ No newline at end of file diff --git a/.tmp/feishu_case2_v4.png b/.tmp/feishu_case2_v4.png new file mode 100644 index 00000000..404db500 Binary files /dev/null and b/.tmp/feishu_case2_v4.png differ diff --git a/.tmp/p0_feishu_reply_validation.png b/.tmp/p0_feishu_reply_validation.png new file mode 100644 index 00000000..e1b7a62c Binary files /dev/null and b/.tmp/p0_feishu_reply_validation.png differ diff --git a/.tmp/p1_real_validate.py b/.tmp/p1_real_validate.py new file mode 100644 index 00000000..2c27de67 --- /dev/null +++ b/.tmp/p1_real_validate.py @@ -0,0 +1,313 @@ +import asyncio +import json +import time +import urllib.request +import urllib.parse +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import select, func + +from app.core.database import AsyncSessionLocal +from app.models import AIOrchestrationRun, FeishuReplyOutbox, AIProcessingLog, AICallLog + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" + +CASES = [ + {"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]}, + {"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]}, + {"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]}, + {"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]}, + {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, +] + +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, *, message: str, message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=240) as resp: + text = resp.read().decode("utf-8") + try: + return json.loads(text) + except Exception: + return {"raw": text} + + +def detect_prompt_scene(prompt: str) -> Optional[str]: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return None + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(reply: str) -> bool: + for line in str(reply or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def fetch_max_ai_call_id(db) -> int: + m = (await db.execute(select(func.max(AICallLog.id)))).scalar() + return int(m or 0) + + +async def wait_run(db, source_message_id: str, timeout: int = 540) -> Optional[AIOrchestrationRun]: + deadline = time.time() + timeout + last = None + while time.time() < deadline: + row = ( + await db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.source_message_id == source_message_id) + .order_by(AIOrchestrationRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is not None: + last = row + s = str(row.status or "").lower() + if s in {"succeeded", "failed", "cancelled"}: + return row + await asyncio.sleep(2) + return last + + +async def fetch_outbox(db, source_message_id: str): + return ( + await db.execute( + select(FeishuReplyOutbox) + .where(FeishuReplyOutbox.source_message_id == source_message_id) + .order_by(FeishuReplyOutbox.id.asc()) + ) + ).scalars().all() + + +async def wait_outbox(db, source_message_id: str, run_status: str, timeout: int = 240): + deadline = time.time() + timeout + expected = 2 if run_status == "succeeded" else 1 + latest = [] + while time.time() < deadline: + latest = await fetch_outbox(db, source_message_id) + sent = [r for r in latest if str(r.status or "") == "sent"] + if len(sent) >= expected: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(db, source_message_id: str): + return ( + await db.execute( + select(AIProcessingLog) + .where(AIProcessingLog.feishu_message_id == source_message_id) + .order_by(AIProcessingLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + +async def fetch_scene_from_call_logs(db, min_id: int, keys: list[str]): + rows = ( + await db.execute( + select(AICallLog) + .where( + AICallLog.id > min_id, + AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]), + ) + .order_by(AICallLog.id.asc()) + .limit(160) + ) + ).scalars().all() + + picked = "unknown" + inspected = [] + lk = [k.lower() for k in keys if k] + for r in rows: + prompt = str(r.prompt or "") + pl = prompt.lower() + if lk and not any(k in pl for k in lk): + continue + sc = detect_prompt_scene(prompt) + inspected.append( + { + "id": int(r.id), + "scene": sc or "unknown", + "function_name": str(r.function_name or ""), + "created_at": str(r.created_at or ""), + } + ) + if sc: + picked = sc + + if picked == "unknown" and inspected: + picked = str(inspected[-1].get("scene") or "unknown") + return picked, inspected + + +def evaluate(case_idx: int, scene: str, reply: str, run_status: str): + leak = has_thinking_leak(reply) + if case_idx == 1: + ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak + return ok, "member_workload+百分比+无项目列表" + if case_idx == 2: + sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply + src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply + ok = scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段+来源限制" + if case_idx == 3: + mixed = "[回答]" in reply and "[已收到需求]" in reply + prog = "进行中" in reply + ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建+进行中数量" + if case_idx == 4: + ok = scene == "default" and "完成率" in reply and "%" in reply and not leak + return ok, "default+完成率" + if case_idx == 5: + ok = scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def run_case(token: str, case: dict[str, Any]): + idx = int(case["idx"]) + message = str(case["message"]) + source_message_id = f"real-scene-p1-v2-{idx}-{int(time.time())}" + + async with AsyncSessionLocal() as db: + before_call_id = await fetch_max_ai_call_id(db) + + capture = post_capture(token, message=message, message_id=source_message_id) + + async with AsyncSessionLocal() as db: + run_row = await wait_run(db, source_message_id) + run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing" + outbox_rows = await wait_outbox(db, source_message_id, run_status) + processing = await fetch_processing(db, source_message_id) + prompt_scene, candidates = await fetch_scene_from_call_logs(db, before_call_id, list(case.get("keys") or [])) + + sent = [r for r in outbox_rows if str(r.status or "") == "sent"] + ack = str(sent[0].message_text or "").strip() if sent else "" + final_reply = str(sent[-1].message_text or "").strip() if sent else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + + passed, rule = evaluate(idx, actual_scene, final_reply, run_status) + + return { + "idx": idx, + "source_message_id": source_message_id, + "message": message, + "expected_scene": str(case["expected_scene"]), + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "ack": ack, + "final_reply": final_reply, + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "thinking_leak": has_thinking_leak(final_reply), + "processing": { + "route": str(getattr(processing, "route", "") or ""), + "intent": str(getattr(processing, "intent", "") or ""), + "status": str(getattr(processing, "processing_status", "") or ""), + "duration_ms": int(getattr(processing, "duration_ms", 0) or 0), + "error_message": str(getattr(processing, "error_message", "") or ""), + "reply_source": str(getattr(processing, "reply_source", "") or ""), + }, + "outbox": [ + { + "id": int(r.id), + "status": str(r.status or ""), + "attempts": int(r.attempts or 0), + "created_at": str(r.created_at or ""), + "text": str(r.message_text or ""), + } + for r in outbox_rows + ], + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": rule, + } + + +async def main(): + token = login_token() + if not token: + raise RuntimeError("token empty") + + results = [] + for case in CASES: + print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True) + one = await run_case(token, case) + print(f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']}", flush=True) + results.append(one) + await asyncio.sleep(2) + + payload = { + "generated_at": datetime.now().isoformat(), + "chat_id": CHAT_ID, + "results": results, + } + print("===RESULT_JSON_START===") + print(json.dumps(payload, ensure_ascii=False)) + print("===RESULT_JSON_END===") + + +asyncio.run(main()) diff --git a/.tmp/p1_real_validate_v4.py b/.tmp/p1_real_validate_v4.py new file mode 100644 index 00000000..1b5360d8 --- /dev/null +++ b/.tmp/p1_real_validate_v4.py @@ -0,0 +1,330 @@ +import asyncio +import json +import time +import urllib.parse +import urllib.request +from datetime import datetime +from typing import Any + +from sqlalchemy import text + +from app.core.database import AsyncSessionLocal + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" + +CASES = [ + {"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]}, + {"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]}, + {"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]}, + {"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]}, + {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, +] + +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": source_message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=240) as resp: + text_body = resp.read().decode("utf-8") + try: + return json.loads(text_body) + except Exception: + return {"raw": text_body} + + +def detect_prompt_scene(prompt: str) -> str: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return "unknown" + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(reply: str) -> bool: + for line in str(reply or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def get_max_ai_call_id() -> int: + async with AsyncSessionLocal() as db: + row = ( + await db.execute(text("SELECT COALESCE(MAX(id), 0) AS v FROM ai_call_logs")) + ).mappings().first() + return int((row or {}).get("v") or 0) + + +async def wait_run(source_message_id: str, timeout_sec: int = 600) -> dict[str, Any] | None: + deadline = time.time() + timeout_sec + last = None + while time.time() < deadline: + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + text( + """ + SELECT id, status, trace_id, source_message_id, created_at, updated_at + FROM ai_orchestration_runs + WHERE source_message_id = :sid + ORDER BY id DESC + LIMIT 1 + """ + ), + {"sid": source_message_id}, + ) + ).mappings().first() + if row: + last = dict(row) + status = str(last.get("status") or "").lower() + if status in {"succeeded", "failed", "cancelled", "waiting_manual"}: + return last + await asyncio.sleep(2) + return last + + +async def wait_outbox(source_message_id: str, expected_sent: int, timeout_sec: int = 300) -> list[dict[str, Any]]: + deadline = time.time() + timeout_sec + latest: list[dict[str, Any]] = [] + while time.time() < deadline: + async with AsyncSessionLocal() as db: + rows = ( + await db.execute( + text( + """ + SELECT id, status, attempts, message_text, created_at + FROM feishu_reply_outbox + WHERE source_message_id = :sid + ORDER BY id ASC + """ + ), + {"sid": source_message_id}, + ) + ).mappings().all() + latest = [dict(r) for r in rows] + sent_count = sum(1 for r in latest if str(r.get("status") or "") == "sent") + if sent_count >= expected_sent: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(source_message_id: str) -> dict[str, Any]: + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + text( + """ + SELECT id, route, intent, processing_status, duration_ms, error_message, reply_source, created_at + FROM ai_processing_logs + WHERE feishu_message_id = :sid + ORDER BY id DESC + LIMIT 1 + """ + ), + {"sid": source_message_id}, + ) + ).mappings().first() + return dict(row or {}) + + +async def fetch_prompt_scene(min_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]: + async with AsyncSessionLocal() as db: + rows = ( + await db.execute( + text( + """ + SELECT id, function_name, prompt, created_at + FROM ai_call_logs + WHERE id > :min_id + AND function_name IN ('adaptive_rag_answer_normal', 'adaptive_rag_answer_deep') + ORDER BY id ASC + LIMIT 400 + """ + ), + {"min_id": min_id}, + ) + ).mappings().all() + + lowered = [str(k).lower() for k in (keys or []) if str(k).strip()] + picked = "unknown" + candidates: list[dict[str, Any]] = [] + for r in rows: + prompt = str(r.get("prompt") or "") + pl = prompt.lower() + if lowered and not any(k in pl for k in lowered): + continue + scene = detect_prompt_scene(prompt) + candidates.append( + { + "id": int(r.get("id") or 0), + "scene": scene, + "function_name": str(r.get("function_name") or ""), + "created_at": str(r.get("created_at") or ""), + } + ) + if scene != "unknown": + picked = scene + + if picked == "unknown" and candidates: + picked = str(candidates[-1].get("scene") or "unknown") + return picked, candidates + + +def evaluate(case_idx: int, scene: str, reply: str, run_status: str) -> tuple[bool, str]: + leak = has_thinking_leak(reply) + if case_idx == 1: + ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak + return ok, "member_workload+百分比+无项目列表" + if case_idx == 2: + sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply + src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply + ok = scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段+来源限制" + if case_idx == 3: + mixed = "[回答]" in reply and "[已收到需求]" in reply + prog = "进行中" in reply + ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建+进行中数量" + if case_idx == 4: + ok = scene == "default" and "完成率" in reply and "%" in reply and not leak + return ok, "default+完成率" + if case_idx == 5: + ok = scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def run_case(token: str, case: dict[str, Any]) -> dict[str, Any]: + idx = int(case["idx"]) + source_message_id = f"real-scene-p1-v4-{idx}-{int(time.time())}" + message = str(case["message"]) + + before_call_id = await get_max_ai_call_id() + capture = post_capture(token, message, source_message_id) + + run_row = await wait_run(source_message_id) + run_status = str((run_row or {}).get("status") or "missing") + expected_sent = 2 if run_status == "succeeded" else 1 + outbox_rows = await wait_outbox(source_message_id, expected_sent) + + sent = [r for r in outbox_rows if str(r.get("status") or "") == "sent"] + ack = str(sent[0].get("message_text") or "").strip() if sent else "" + final_reply = str(sent[-1].get("message_text") or "").strip() if sent else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + processing = await fetch_processing(source_message_id) + prompt_scene, candidates = await fetch_prompt_scene(before_call_id, list(case.get("keys") or [])) + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + passed, pass_rule = evaluate(idx, actual_scene, final_reply, run_status) + + return { + "idx": idx, + "source_message_id": source_message_id, + "message": message, + "expected_scene": str(case["expected_scene"]), + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "ack": ack, + "final_reply": final_reply, + "thinking_leak": has_thinking_leak(final_reply), + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "processing": { + "route": str(processing.get("route") or ""), + "intent": str(processing.get("intent") or ""), + "status": str(processing.get("processing_status") or ""), + "duration_ms": int(processing.get("duration_ms") or 0), + "error_message": str(processing.get("error_message") or ""), + "reply_source": str(processing.get("reply_source") or ""), + }, + "outbox": outbox_rows, + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": pass_rule, + } + + +async def main() -> None: + token = login_token() + if not token: + raise RuntimeError("token empty") + + results = [] + for case in CASES: + print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True) + one = await run_case(token, case) + print( + f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']} leak={one['thinking_leak']}", + flush=True, + ) + results.append(one) + await asyncio.sleep(2) + + payload = { + "generated_at": datetime.now().isoformat(), + "chat_id": CHAT_ID, + "results": results, + } + print("===RESULT_JSON_START===") + print(json.dumps(payload, ensure_ascii=False)) + print("===RESULT_JSON_END===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/.tmp/real_feishu_case_1.png b/.tmp/real_feishu_case_1.png new file mode 100644 index 00000000..11c011ed Binary files /dev/null and b/.tmp/real_feishu_case_1.png differ diff --git a/.tmp/real_feishu_case_2.png b/.tmp/real_feishu_case_2.png new file mode 100644 index 00000000..f12e34cb Binary files /dev/null and b/.tmp/real_feishu_case_2.png differ diff --git a/.tmp/real_feishu_case_3.png b/.tmp/real_feishu_case_3.png new file mode 100644 index 00000000..1e341520 Binary files /dev/null and b/.tmp/real_feishu_case_3.png differ diff --git a/.tmp/real_feishu_case_4.png b/.tmp/real_feishu_case_4.png new file mode 100644 index 00000000..0c6cab08 Binary files /dev/null and b/.tmp/real_feishu_case_4.png differ diff --git a/.tmp/real_feishu_case_5.png b/.tmp/real_feishu_case_5.png new file mode 100644 index 00000000..7660def3 Binary files /dev/null and b/.tmp/real_feishu_case_5.png differ diff --git a/.tmp/run_feishu_case.py b/.tmp/run_feishu_case.py new file mode 100644 index 00000000..81359fda --- /dev/null +++ b/.tmp/run_feishu_case.py @@ -0,0 +1,291 @@ +import argparse +import asyncio +import json +import time +import urllib.parse +import urllib.request +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import func, select + +from app.core.database import AsyncSessionLocal +from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuReplyOutbox + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": source_message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + text = resp.read().decode("utf-8") + try: + return json.loads(text) + except Exception: + return {"raw": text} + + +def detect_prompt_scene(prompt: str) -> Optional[str]: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return None + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(text: str) -> bool: + for line in str(text or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def fetch_max_ai_call_id(db) -> int: + row = (await db.execute(select(func.max(AICallLog.id)))).scalar() + return int(row or 0) + + +async def wait_run(db, source_message_id: str, timeout_sec: int = 720) -> Optional[AIOrchestrationRun]: + deadline = time.time() + timeout_sec + last = None + while time.time() < deadline: + row = ( + await db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.source_message_id == source_message_id) + .order_by(AIOrchestrationRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is not None: + last = row + st = str(row.status or "").lower() + if st in {"succeeded", "failed", "cancelled"}: + return row + await asyncio.sleep(2) + return last + + +async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: int = 300) -> list[FeishuReplyOutbox]: + deadline = time.time() + timeout_sec + expected = 2 if run_status == "succeeded" else 1 + latest: list[FeishuReplyOutbox] = [] + while time.time() < deadline: + latest = ( + await db.execute( + select(FeishuReplyOutbox) + .where(FeishuReplyOutbox.source_message_id == source_message_id) + .order_by(FeishuReplyOutbox.id.asc()) + ) + ).scalars().all() + sent = [r for r in latest if str(r.status or "") == "sent"] + if len(sent) >= expected: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]: + return ( + await db.execute( + select(AIProcessingLog) + .where(AIProcessingLog.feishu_message_id == source_message_id) + .order_by(AIProcessingLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + +async def fetch_prompt_scene(db, min_call_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]: + rows = ( + await db.execute( + select(AICallLog) + .where( + AICallLog.id > min_call_id, + AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]), + ) + .order_by(AICallLog.id.asc()) + .limit(200) + ) + ).scalars().all() + + lowered = [k.lower() for k in keys if k] + picked = "unknown" + candidates: list[dict[str, Any]] = [] + + for row in rows: + prompt = str(row.prompt or "") + pl = prompt.lower() + if lowered and not any(k in pl for k in lowered): + continue + scene = detect_prompt_scene(prompt) + candidates.append( + { + "id": int(row.id), + "scene": scene or "unknown", + "function": str(row.function_name or ""), + "created_at": str(row.created_at or ""), + } + ) + if scene: + picked = scene + + if picked == "unknown" and candidates: + picked = str(candidates[-1].get("scene") or "unknown") + + return picked, candidates + + +def evaluate(idx: int, actual_scene: str, final_reply: str, run_status: str) -> tuple[bool, str]: + leak = has_thinking_leak(final_reply) + if idx == 1: + ok = actual_scene == "member_workload" and "%" in final_reply and "项目列表" not in final_reply and not leak + return ok, "member_workload+百分比+无项目列表" + if idx == 2: + sec = "[行业动态]" in final_reply and ("[对项目影响]" in final_reply or "[对项目的影响]" in final_reply) and "[建议]" in final_reply + src = "未接入实时数据" in final_reply or "训练数据" in final_reply or "数据来源" in final_reply + ok = actual_scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段输出+来源限制" + if idx == 3: + mixed = "[回答]" in final_reply and "[已收到需求]" in final_reply + prog = "进行中" in final_reply + ok = actual_scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建成功+进行中数量" + if idx == 4: + ok = actual_scene == "default" and "完成率" in final_reply and "%" in final_reply and not leak + return ok, "default+项目状态完成率" + if idx == 5: + ok = actual_scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--idx", type=int, required=True) + parser.add_argument("--message", type=str, required=True) + parser.add_argument("--keys", type=str, default="") + parser.add_argument("--prefix", type=str, default="real-scene-p1-manual") + args = parser.parse_args() + + keys = [k.strip() for k in str(args.keys).split("|") if k.strip()] + source_message_id = f"{args.prefix}-{args.idx}-{int(time.time())}" + + token = login_token() + if not token: + raise RuntimeError("token empty") + + async with AsyncSessionLocal() as db: + before_call_id = await fetch_max_ai_call_id(db) + + capture = post_capture(token, args.message, source_message_id) + + async with AsyncSessionLocal() as db: + run_row = await wait_run(db, source_message_id) + run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing" + outbox_rows = await wait_outbox(db, source_message_id, run_status) + processing = await fetch_processing(db, source_message_id) + prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys) + + sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"] + ack = str(sent_rows[0].message_text or "").strip() if sent_rows else "" + final_reply = str(sent_rows[-1].message_text or "").strip() if sent_rows else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + passed, pass_rule = evaluate(args.idx, actual_scene, final_reply, run_status) + + payload = { + "generated_at": datetime.now().isoformat(), + "idx": args.idx, + "source_message_id": source_message_id, + "message": args.message, + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "ack": ack, + "final_reply": final_reply, + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "thinking_leak": has_thinking_leak(final_reply), + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "processing": { + "route": str(getattr(processing, "route", "") or ""), + "intent": str(getattr(processing, "intent", "") or ""), + "status": str(getattr(processing, "processing_status", "") or ""), + "duration_ms": int(getattr(processing, "duration_ms", 0) or 0), + "error_message": str(getattr(processing, "error_message", "") or ""), + "reply_source": str(getattr(processing, "reply_source", "") or ""), + }, + "outbox": [ + { + "id": int(r.id), + "status": str(r.status or ""), + "attempts": int(r.attempts or 0), + "created_at": str(r.created_at or ""), + "text": str(r.message_text or ""), + } + for r in outbox_rows + ], + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": pass_rule, + } + print(json.dumps(payload, ensure_ascii=False)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/AGENTS.md b/AGENTS.md index e42c471b..7ad4204c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ frontend/ # React 前端 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` +- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 ## 9. 新增代码的参考模式 diff --git a/CLAUDE.md b/CLAUDE.md index b0cfcce6..e225ed50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ backend/ │ ├── __init__.py # app 包入口(根目录轻量化后保留) │ ├── api/ # 路由层(含 endpoints) │ ├── core/ # 配置、日志、核心能力 -│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/probe/warmup) +│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup) │ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal) │ ├── dependencies/ # 鉴权/租户等依赖注入 │ ├── middleware/ # 请求中间件(审计上下文注入等) @@ -54,7 +54,8 @@ backend/ │ ├── 20260311_add_operation_log_audit_fields.py # operation_logs 审计字段扩展(trace/path/caller) │ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items) │ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2) -│ └── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 +│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 +│ └── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 ├── scripts/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) @@ -64,7 +65,8 @@ backend/ └── (tests 已移除) ``` - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) +- 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环) - 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`) - 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化) - 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入) @@ -73,10 +75,10 @@ backend/ - 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) - 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai) - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) -- 新增:`backend/app/services/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) -- 新增:`backend/app/services/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) +- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) +- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) -- 新增:`backend/app/services/model_routing/service.py`(函数级模型路由配置服务) +- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) - 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务) - 新增:`backend/app/api/model_routing.py`(模型路由配置接口) @@ -91,44 +93,50 @@ backend/ - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架) - 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor.py`(AI PMO typed 执行层组合入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_common.py`(TypedExecutor 共享常量/依赖) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py`(TypedExecutor 解析与批量分析逻辑) -- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_discovery.py`(TypedExecutor discover 候选上下文逻辑) -- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_execute.py`(TypedExecutor execute/create/source-onboard 落库逻辑) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_runtime.py`(AI PMO kernel 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor.py`(AI PMO typed 执行层组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py`(TypedExecutor 共享常量/依赖) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py`(TypedExecutor 解析与批量分析逻辑) +- 新增:`backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/`(TypedExecutor discover 候选上下文逻辑) +- 新增:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/`(TypedExecutor execute/create/source-onboard 落库逻辑) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_kernel_runtime.py`(AI PMO kernel 组合入口) - 新增:`backend/app/services/ai_pmo_kernel_common.py`(Kernel 共享常量/依赖) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress.py`(Kernel ingress 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py`(Kernel 入站主流程编排) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model.py`(Kernel world-model 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model_core.py`(Kernel world-model 核心构建与缓存) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护) -- 新增:`backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py`(Kernel 组合摘要构建器) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent.py`(Kernel intent 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_rules.py`(Kernel 规则意图与槽位守卫) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_llm.py`(Kernel LLM 意图识别与仲裁) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_resolve.py`(Kernel 最终意图解析入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup.py`(Kernel followup 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_records.py`(followup 决策/计划/回执写入) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_routing.py`(followup 候选定位与恢复分发) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal.py`(project_signal followup 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_actions.py`(project_signal commit/refresh/cancel 动作执行) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_resume.py`(project_signal 目标工作项恢复闭环) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_verification.py`(followup 执行校验与工具函数) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure.py`(Kernel closure 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_helpers.py`(Kernel 闭环通用归一化工具) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放) -- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装) -- 移除:`backend/app/services/ai_pmo_kernel_service.py`(顶层兼容壳清理,统一改为 `backend/app/services/ai_pmo_kernel/` 子包) -- 新增:`backend/app/services/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行) -- 新增:`backend/app/services/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力) -- 新增:`backend/app/services/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分) -- 新增:`backend/app/services/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/ingress.py`(Kernel ingress 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/`(Kernel 组合摘要构建器) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py`(Kernel intent 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py`(Kernel 规则意图与槽位守卫) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py`(Kernel LLM 意图识别与仲裁) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`(Kernel 最终意图解析入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/followup.py`(Kernel followup 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_records.py`(followup 决策/计划/回执写入) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py`(followup 候选定位与恢复分发) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal.py`(project_signal followup 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_actions.py`(project_signal commit/refresh/cancel 动作执行) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py`(project_signal 目标工作项恢复闭环) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py`(followup 执行校验与工具函数) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/closure.py`(Kernel closure 组合入口) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_helpers.py`(Kernel 闭环通用归一化工具) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/`(新增 `closure/followup/ingress/intent/world_model` 分组目录与 `__init__.py` 包声明,降低同层文件密度) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/`(新增组合入口分组目录,根目录仅保留 runtime/agent/domain 等真正入口) +- 更新:`backend/app/services/ai_pmo/`(新增 AI PMO 领域分组目录,统一收敛 `ai_pmo_cadence/ai_pmo_decision/ai_pmo_intent/ai_pmo_kernel/ai_pmo_kernel_common_core/ai_pmo_kernel_ingress_flow/ai_pmo_kernel_world_model_aggregates/ai_pmo_kernel_world_model_core/ai_pmo_typed_executor_discovery/ai_pmo_typed_executor_execute`) +- 更新:`backend/app/services/model/`(新增模型能力分组目录,统一收敛 `model_capability/model_context_bundle/model_routing/prompt_profile`) +- 更新:`backend/app/services/integrations/`(新增集成能力分组目录,统一收敛 `integration_core/feishu_integration_service/feishu_long_connection_core/feishu_outbox/feishu_signal_pipeline/wecom`) +- 更新:`backend/app/services/signal/`(新增信号能力分组目录,统一收敛 `signal_capture_service/signal_planner/signal_source_orchestration/signal_source_repair/symbol_index`) +- 移除:`backend/app/services/ai_pmo_kernel_service.py`(顶层兼容壳清理,统一改为 `backend/app/services/ai_pmo/ai_pmo_kernel/` 子包) +- 新增:`backend/app/services/integrations/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行) +- 新增:`backend/app/services/integrations/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力) +- 新增:`backend/app/services/integrations/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分) +- 新增:`backend/app/services/integrations/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现) - 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`) - 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录) - 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由) @@ -145,13 +153,13 @@ backend/ - 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review) - 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行) - 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`) -- 新增:`backend/app/services/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分) +- 新增:`backend/app/services/signal/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分) - 更新:`backend/app/services/signal_capture.py`(降级为兼容入口) - 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分) - 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口) - 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分) - 更新:`backend/app/services/context_cache.py`(降级为兼容入口) -- 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 part_01..part_04 管理) +- 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 `part_01..part_04` + `part_scenes_01..part_scenes_06` 管理) - 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle`) - 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块) - 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块) @@ -171,24 +179,25 @@ backend/ - 新增:`backend/app/__init__.py`、`backend/app/deps.py`(根目录仅保留 `__init__.py / main.py / deps.py`) - 更新:`backend/app/services/` 顶层兼容壳批量清理(如 `ai_service.py/llm.py/scheduler_center.py/signal_capture.py/...`),统一改为直接引用对应子包(`ai_service_core/llm_service/scheduler_center_core/signal_capture_service/...`) - 更新:移除 `backend/app/services/alignment_orchestrator.py` 空文件(无引用) -- 新增:`backend/app/services/ai_pmo_kernel_world_model_aggregates/`、`backend/app/services/ai_pmo_kernel_world_model_core/`、`backend/app/services/ai_pmo_kernel_common_core/`(kernel world-model/common 拆分) -- 更新:`backend/app/services/ai_pmo_kernel_runtime.py`、`backend/app/services/ai_agent_kernel.py`、`backend/app/services/ai_pmo_domain_workflow.py` 及 `backend/app/services/ai_pmo_kernel_mixins_*.py` → `backend/app/services/ai_pmo_kernel/`(AI PMO kernel 子包化,清理 services 根目录散落文件) +- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/`、`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`、`backend/app/services/ai_pmo/ai_pmo_kernel_common_core/`(kernel world-model/common 拆分) +- 更新:`backend/app/services/ai_pmo_kernel_runtime.py`、`backend/app/services/ai_agent_kernel.py`、`backend/app/services/ai_pmo_domain_workflow.py` 及 `backend/app/services/ai_pmo_kernel_mixins_*.py` → `backend/app/services/ai_pmo/ai_pmo_kernel/`(AI PMO kernel 子包化,清理 services 根目录散落文件) - 更新:`backend/app/services/ai_orchestration_run_service.py` → `backend/app/services/ai_orchestration_run/service.py`(编排运行账本服务子包化) -- 更新:`backend/app/services/ai_pmo_reply_renderer.py`、`backend/app/services/ai_pmo_review_service.py`、`backend/app/services/ai_pmo_typed_executor.py`、`backend/app/services/ai_pmo_typed_executor_common.py`、`backend/app/services/ai_pmo_typed_executor_mixins_parse.py`、`backend/app/services/ai_pmo_workflow_runtime.py` → `backend/app/services/ai_pmo_kernel/`(AI PMO 执行与回执运行时收敛到 kernel 子包) -- 更新:`backend/app/services/cost_optimization_service.py`、`backend/app/services/model_lifecycle_service.py` → `backend/app/services/model_capability/`(模型能力相关服务收敛) -- 更新:`backend/app/services/signal_strategic_analysis_service.py` → `backend/app/services/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 分析写回服务归并) +- 新增:`backend/app/services/ai_orchestration_run/ingress_worker.py`(AI PMO 入站消息异步消费、重试与最终回执发送) +- 更新:`backend/app/services/ai_pmo_reply_renderer.py`、`backend/app/services/ai_pmo_review_service.py`、`backend/app/services/ai_pmo_typed_executor.py`、`backend/app/services/ai_pmo_typed_executor_common.py`、`backend/app/services/ai_pmo_typed_executor_mixins_parse.py`、`backend/app/services/ai_pmo_workflow_runtime.py` → `backend/app/services/ai_pmo/ai_pmo_kernel/`(AI PMO 执行与回执运行时收敛到 kernel 子包) +- 更新:`backend/app/services/cost_optimization_service.py`、`backend/app/services/model_lifecycle_service.py` → `backend/app/services/model/model_capability/`(模型能力相关服务收敛) +- 更新:`backend/app/services/signal_strategic_analysis_service.py` → `backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 分析写回服务归并) - 移除:`backend/app/services/llm_client.py`(全仓库无引用,按废弃文件清理) -- 更新:`backend/app/services/analyzer.py`、`backend/app/services/fetcher.py` → `backend/app/services/signal_capture_service/impact_analyzer.py`、`backend/app/services/signal_capture_service/external_fetcher.py`(信号接入分析/抓取能力归并) -- 更新:`backend/app/services/plane.py` → `backend/app/services/integration_core/plane_service.py`(Plane 兼容服务归并到 integration_core) -- 更新:`backend/app/services/lark.py` → `backend/app/services/integration_core/lark_service.py`(Lark 兼容服务归并到 integration_core) +- 更新:`backend/app/services/analyzer.py`、`backend/app/services/fetcher.py` → `backend/app/services/signal/signal_capture_service/impact_analyzer.py`、`backend/app/services/signal/signal_capture_service/external_fetcher.py`(信号接入分析/抓取能力归并) +- 更新:`backend/app/services/plane.py` → `backend/app/services/integrations/integration_core/plane_service.py`(Plane 兼容服务归并到 integration_core) +- 更新:`backend/app/services/lark.py` → `backend/app/services/integrations/integration_core/lark_service.py`(Lark 兼容服务归并到 integration_core) - 更新:`backend/app/services/workload_alert_service.py` → `backend/app/services/workload/workload_alert_service.py`(负载告警服务归并到 workload 子包) - 更新:`backend/app/services/task_persistence.py`(兼容壳移除,统一直接引用 `backend/app/services/task_persistence_core/`) - 更新:`backend/app/services/project_member_service.py` → `backend/app/services/project_member/service.py`(项目成员分配服务子包化) - 更新:`backend/app/services/recipe_service.py` → `backend/app/services/recipe/service.py`(食谱服务子包化) -- 更新:`backend/app/services/model_routing_service.py` → `backend/app/services/model_routing/service.py`(模型路由服务子包化) -- 更新:`backend/app/services/prompt_profile_service.py` → `backend/app/services/prompt_profile/service.py`(Prompt 画像服务子包化) -- 更新:`backend/app/services/feishu_outbox_service.py` → `backend/app/services/feishu_outbox/service.py`(飞书 outbox 服务子包化) -- 更新:`backend/app/services/wecom_integration.py` → `backend/app/services/wecom/service.py`(企业微信集成服务子包化) +- 更新:`backend/app/services/model_routing_service.py` → `backend/app/services/model/model_routing/service.py`(模型路由服务子包化) +- 更新:`backend/app/services/prompt_profile_service.py` → `backend/app/services/model/prompt_profile/service.py`(Prompt 画像服务子包化) +- 更新:`backend/app/services/feishu_outbox_service.py` → `backend/app/services/integrations/feishu_outbox/service.py`(飞书 outbox 服务子包化) +- 更新:`backend/app/services/wecom_integration.py` → `backend/app/services/integrations/wecom/service.py`(企业微信集成服务子包化) - 更新:`backend/app/services/skill_score_service.py` → `backend/app/services/skill/skill_score_service.py`(技能评分服务归并到 skill 子包) - 更新:`backend/app/services/auth.py`、`backend/app/services/audit_query_service.py`、`backend/app/services/notification_service.py`、`backend/app/services/operation_catalog_service.py`、`backend/app/services/operation_log_service.py` → `backend/app/services/*/service.py`(同名服务子包化,`services` 根目录不再保留 `.py` 文件) - 更新:`backend/app/services/` 根目录 `.py` 已清零(全部收敛到子包),仅保留目录化服务结构 @@ -374,6 +383,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 双模型上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 - 所有 LLM 调用禁止模型级兜底:禁止高阶升级、Safety 引擎、应急直连、stub 假成功;模型异常、超时或结构化输出不合法时必须显式报错 +- 禁止模型兜底回复文案:模型失败、超时、空响应、结构化解析失败时,必须返回真实错误原因;禁止输出“系统处理中/请稍后重试”等固定文案冒充成功结果 +- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 +- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 外部趋势 > mixed > AI PMO 入口 > 默认 RAG +- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落 +- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式 - 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用 - AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿 - Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 @@ -403,6 +417,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 - AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块 - SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 +- SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间” +- SSTC AI 拆解接口(`POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown`)执行任务标题去重(同次 + 历史任务),返回 `skipped_duplicates` 明确跳过计数 +- SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids` - 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增数据库表:参考 `alembic/versions/` 最新 migration @@ -416,26 +433,33 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 -- 当前 LLM 主链默认直连引擎,不再依赖 OpenClaw 网关;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准 -- LLM 非结构化响应在 `content` 为空且存在 `reasoning_content` 时,可按需回退读取 `reasoning_content`(开关:`LLM_REASONING_CONTENT_FALLBACK`,默认关闭) +- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准 +- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退 - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - 写操作后主动 invalidate,动态层不缓存 -- AI PMO 意图识别默认 `rule-first`:`AI_PMO_INTENT_LLM_ENABLED=true` 时,LLM 仅在规则低置信或 `default_query_fallback` 场景作为末位兜底;模型超时/不可用/结构化解析失败时回退规则链路,不中断主流程 +- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路 - 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取 - Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM - Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)` - AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准 - 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 -- 双 Agent 路由治理:默认直连 `GLM`,复杂意图仍使用 `classify_intent_complex`,不再经过 OpenClaw 控制平面 +- 双 Agent 路由治理:低/高层函数均可由 OpenClaw 规则命中到指定 provider/model(含 `classify_intent_ai_pmo_kernel`) - 双模型 DDL 治理图谱的模型来源优先读取显式模型配置;缺失时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` - 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分 - AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行 - 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作 -- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认 +- 飞书 `humanized_reply` 与 RAG 问答 Prompt 已显式禁止思考过程外显;回复链路会过滤以“让我先/让我尝试/我来看看/我先查/让我看看/我需要先”开头的前置段落 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 - 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` +- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 +- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 +- `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化 +- `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合 + +## 12. 技术债务清单 +- [ ] `mixin_auto.py` `_safe_int` NameError:之前被 `simulation_capacity_limit` 门禁遮住未暴露,需单独任务修复 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json index 0818c1d1..cd365b48 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -29,6 +29,16 @@ "api_env": "OPENAI_API_KEY", "function_exact": "classify_intent_complex" }, + { + "name": "engine_low_intent_kernel_exact", + "enabled": true, + "priority": 310, + "tier": "low", + "provider": "glm", + "model": "GLM-4.7", + "api_env": "GLM_API_KEY", + "function_exact": "classify_intent_ai_pmo_kernel" + }, { "name": "engine_low_execute_glm_flash", "enabled": true, diff --git a/backend/alembic/versions/20260316_add_ai_orchestration_ingress_async_fields.py b/backend/alembic/versions/20260316_add_ai_orchestration_ingress_async_fields.py new file mode 100644 index 00000000..b11e670a --- /dev/null +++ b/backend/alembic/versions/20260316_add_ai_orchestration_ingress_async_fields.py @@ -0,0 +1,165 @@ +"""add ai orchestration ingress async fields + +Revision ID: add_ai_orchestration_ingress_async_fields +Revises: add_signal_planner_fields +Create Date: 2026-03-16 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_ai_orchestration_ingress_async_fields" +down_revision = "add_signal_planner_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name)) + + +def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "ai_orchestration_runs" + if not _table_exists(bind, table_name): + return + + with op.batch_alter_table(table_name) as batch_op: + if _column_exists(bind, table_name, "project_id"): + batch_op.alter_column("project_id", existing_type=sa.BigInteger(), nullable=True) + if not _column_exists(bind, table_name, "source_system"): + batch_op.add_column(sa.Column("source_system", sa.String(length=32), nullable=True)) + if not _column_exists(bind, table_name, "source_message_id"): + batch_op.add_column(sa.Column("source_message_id", sa.String(length=128), nullable=True)) + if not _column_exists(bind, table_name, "sender"): + batch_op.add_column(sa.Column("sender", sa.String(length=128), nullable=True)) + if not _column_exists(bind, table_name, "source_sender_open_id"): + batch_op.add_column(sa.Column("source_sender_open_id", sa.String(length=128), nullable=True)) + if not _column_exists(bind, table_name, "channel_ref"): + batch_op.add_column(sa.Column("channel_ref", sa.String(length=128), nullable=True)) + if not _column_exists(bind, table_name, "chat_type"): + batch_op.add_column(sa.Column("chat_type", sa.String(length=32), nullable=True)) + if not _column_exists(bind, table_name, "captured_at"): + batch_op.add_column(sa.Column("captured_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "message_text"): + batch_op.add_column(sa.Column("message_text", sa.Text(), nullable=True)) + if not _column_exists(bind, table_name, "project_hint_id"): + batch_op.add_column(sa.Column("project_hint_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True)) + if not _column_exists(bind, table_name, "retry_count"): + batch_op.add_column(sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0")) + if not _column_exists(bind, table_name, "max_retries"): + batch_op.add_column(sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3")) + if not _column_exists(bind, table_name, "next_retry_at"): + batch_op.add_column(sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "last_error"): + batch_op.add_column(sa.Column("last_error", sa.Text(), nullable=True)) + if not _column_exists(bind, table_name, "last_error_at"): + batch_op.add_column(sa.Column("last_error_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "ack_sent_at"): + batch_op.add_column(sa.Column("ack_sent_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "result_notified_at"): + batch_op.add_column(sa.Column("result_notified_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "work_item_id"): + batch_op.add_column( + sa.Column( + "work_item_id", + sa.Integer(), + sa.ForeignKey("work_items.id", ondelete="SET NULL"), + nullable=True, + ) + ) + + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_system", ["source_system"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_message_id", ["source_message_id"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id", ["source_sender_open_id"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_channel_ref", ["channel_ref"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_captured_at", ["captured_at"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_project_hint_id", ["project_hint_id"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_next_retry_at", ["next_retry_at"]) + _create_index_if_missing(bind, table_name, "ix_ai_orchestration_runs_work_item_id", ["work_item_id"]) + _create_index_if_missing( + bind, + table_name, + "ix_ai_orchestration_runs_run_type_status_next_retry", + ["run_type", "status", "next_retry_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "ai_orchestration_runs" + if not _table_exists(bind, table_name): + return + + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_run_type_status_next_retry") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_work_item_id") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_next_retry_at") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_project_hint_id") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_captured_at") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_channel_ref") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_sender_open_id") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_message_id") + _drop_index_if_exists(bind, table_name, "ix_ai_orchestration_runs_source_system") + + with op.batch_alter_table(table_name) as batch_op: + if _column_exists(bind, table_name, "work_item_id"): + batch_op.drop_column("work_item_id") + if _column_exists(bind, table_name, "result_notified_at"): + batch_op.drop_column("result_notified_at") + if _column_exists(bind, table_name, "ack_sent_at"): + batch_op.drop_column("ack_sent_at") + if _column_exists(bind, table_name, "last_error_at"): + batch_op.drop_column("last_error_at") + if _column_exists(bind, table_name, "last_error"): + batch_op.drop_column("last_error") + if _column_exists(bind, table_name, "next_retry_at"): + batch_op.drop_column("next_retry_at") + if _column_exists(bind, table_name, "max_retries"): + batch_op.drop_column("max_retries") + if _column_exists(bind, table_name, "retry_count"): + batch_op.drop_column("retry_count") + if _column_exists(bind, table_name, "project_hint_id"): + batch_op.drop_column("project_hint_id") + if _column_exists(bind, table_name, "message_text"): + batch_op.drop_column("message_text") + if _column_exists(bind, table_name, "captured_at"): + batch_op.drop_column("captured_at") + if _column_exists(bind, table_name, "chat_type"): + batch_op.drop_column("chat_type") + if _column_exists(bind, table_name, "channel_ref"): + batch_op.drop_column("channel_ref") + if _column_exists(bind, table_name, "source_sender_open_id"): + batch_op.drop_column("source_sender_open_id") + if _column_exists(bind, table_name, "sender"): + batch_op.drop_column("sender") + if _column_exists(bind, table_name, "source_message_id"): + batch_op.drop_column("source_message_id") + if _column_exists(bind, table_name, "source_system"): + batch_op.drop_column("source_system") diff --git a/backend/app/api/ai_pmo_api/shared.py b/backend/app/api/ai_pmo_api/shared.py index 3885e1bf..5b9dd920 100644 --- a/backend/app/api/ai_pmo_api/shared.py +++ b/backend/app/api/ai_pmo_api/shared.py @@ -16,8 +16,8 @@ from ...core.database import AsyncSessionLocal, 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 import AIPMOCadenceService -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) logger = get_logger(__name__) diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py index 8824a5ff..65df3d53 100644 --- a/backend/app/api/ai_steward_api/shared.py +++ b/backend/app/api/ai_steward_api/shared.py @@ -23,10 +23,10 @@ from ...services.blocker_detection_core import get_blocker_detection_service from ...services.cognitive_conflict_core import get_cognitive_conflict_service from ...services.daily_briefing_core import get_daily_briefing_service from ...services.event_workflow_core import WorkflowEventType, get_event_workflow_engine -from ...services.feishu_outbox import FeishuOutboxService -from ...services.signal_capture_service import ProactiveSignalCaptureService -from ...services.signal_source_orchestration import SignalSourceOrchestrationService -from ...services.signal_source_repair import ( +from ...services.integrations.feishu_outbox import FeishuOutboxService +from ...services.signal.signal_capture_service import ProactiveSignalCaptureService +from ...services.signal.signal_source_orchestration import SignalSourceOrchestrationService +from ...services.signal.signal_source_repair import ( SignalSourceRepairMissingSignalError, SignalSourceRepairService, SignalSourceRepairTimeoutError, diff --git a/backend/app/api/feishu_api/config_routes.py b/backend/app/api/feishu_api/config_routes.py index 8a30281c..d06fe595 100644 --- a/backend/app/api/feishu_api/config_routes.py +++ b/backend/app/api/feishu_api/config_routes.py @@ -11,8 +11,8 @@ from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id from ...models import Integration, User from ...services.auth import has_any_role -from ...services.feishu_integration_service import get_feishu_service -from ...services.feishu_long_connection_core import get_feishu_long_connection_manager +from ...services.integrations.feishu_integration_service import get_feishu_service +from ...services.integrations.feishu_long_connection_core import get_feishu_long_connection_manager from .schemas import FeishuConfigRequest router = APIRouter() diff --git a/backend/app/api/feishu_api/long_connection_routes.py b/backend/app/api/feishu_api/long_connection_routes.py index 1221f730..1449b347 100644 --- a/backend/app/api/feishu_api/long_connection_routes.py +++ b/backend/app/api/feishu_api/long_connection_routes.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException from ...dependencies.auth import get_current_user from ...models import User from ...services.auth import has_any_role -from ...services.feishu_long_connection_core import get_feishu_long_connection_manager +from ...services.integrations.feishu_long_connection_core import get_feishu_long_connection_manager router = APIRouter() diff --git a/backend/app/api/feishu_api/message_routes.py b/backend/app/api/feishu_api/message_routes.py index d5690b83..4ee19873 100644 --- a/backend/app/api/feishu_api/message_routes.py +++ b/backend/app/api/feishu_api/message_routes.py @@ -9,7 +9,7 @@ from ...core.database import get_db from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id from ...models import User -from ...services.feishu_integration_service import get_feishu_service +from ...services.integrations.feishu_integration_service import get_feishu_service from .schemas import FeishuSendCardRequest, FeishuSendMessageRequest router = APIRouter() diff --git a/backend/app/api/feishu_api/test_routes.py b/backend/app/api/feishu_api/test_routes.py index 44d9e315..75e38fd8 100644 --- a/backend/app/api/feishu_api/test_routes.py +++ b/backend/app/api/feishu_api/test_routes.py @@ -9,8 +9,8 @@ from ...core.database import get_db from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id from ...models import User -from ...services.feishu_integration_service import get_feishu_service -from ...services.feishu_long_connection_core import get_feishu_long_connection_manager +from ...services.integrations.feishu_integration_service import get_feishu_service +from ...services.integrations.feishu_long_connection_core import get_feishu_long_connection_manager from .helpers import build_long_connection_failure router = APIRouter() diff --git a/backend/app/api/knowledge_graph.py b/backend/app/api/knowledge_graph.py index 19ec0317..10a6d164 100644 --- a/backend/app/api/knowledge_graph.py +++ b/backend/app/api/knowledge_graph.py @@ -15,7 +15,7 @@ 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.model_context_bundle import ModelContextBundleService +from ..services.model.model_context_bundle import ModelContextBundleService from ..services.project_knowledge_graph_service import ProjectKnowledgeGraphService router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"]) diff --git a/backend/app/api/model_capability_api/shared.py b/backend/app/api/model_capability_api/shared.py index 669d6d49..4febbf53 100644 --- a/backend/app/api/model_capability_api/shared.py +++ b/backend/app/api/model_capability_api/shared.py @@ -21,9 +21,9 @@ from ...schemas.model_capability import ( ValidateMatchRequest, ) from ...services.ai_service_core import get_ai_service -from ...services.model_capability.cost_optimization_service import CostOptimizationService +from ...services.model.model_capability.cost_optimization_service import CostOptimizationService from ...services.experiment_sync import ExperimentSyncService -from ...services.model_capability import ModelCapabilityService -from ...services.model_capability.model_lifecycle_service import ModelLifecycleService +from ...services.model.model_capability import ModelCapabilityService +from ...services.model.model_capability.model_lifecycle_service import ModelLifecycleService router = APIRouter(prefix="/model-capability", tags=["model-capability"]) diff --git a/backend/app/api/model_routing.py b/backend/app/api/model_routing.py index b7012125..36cb2579 100644 --- a/backend/app/api/model_routing.py +++ b/backend/app/api/model_routing.py @@ -14,7 +14,7 @@ from ..core.database import get_db from ..dependencies import get_current_user from ..models import User from ..services.auth import has_any_role -from ..services.model_routing import ModelRoutingService +from ..services.model.model_routing import ModelRoutingService from ..services.operation_log_service import log_operation router = APIRouter(prefix="/model-routing", tags=["model_routing"]) diff --git a/backend/app/api/projects_api/__init__.py b/backend/app/api/projects_api/__init__.py index 5357738b..0cb9ddfc 100644 --- a/backend/app/api/projects_api/__init__.py +++ b/backend/app/api/projects_api/__init__.py @@ -4,7 +4,9 @@ from fastapi import APIRouter from . import routes_list, routes_mutation, routes_signals -router = APIRouter(prefix="/projects", tags=["projects"]) +# routes_* already use the shared router with prefix "/projects"; +# keep this package router unprefixed to avoid "/projects/projects/*". +router = APIRouter(tags=["projects"]) for _module in [routes_list, routes_mutation, routes_signals]: router.include_router(_module.router) diff --git a/backend/app/api/projects_api/helpers.py b/backend/app/api/projects_api/helpers.py index dc972960..93e32d5d 100644 --- a/backend/app/api/projects_api/helpers.py +++ b/backend/app/api/projects_api/helpers.py @@ -152,7 +152,7 @@ async def _cleanup_project_dependencies( .values(task_id=None) ) - # 非空外键:必须先删依赖记录。 + # 强关联依赖:删除项目时同步清理关联运行/执行记录。 await db.execute( delete(AIWorkerExecution).where( and_( @@ -263,4 +263,3 @@ async def _cleanup_project_dependencies( ) .values(current_project_id=None) ) - diff --git a/backend/app/api/projects_api/shared.py b/backend/app/api/projects_api/shared.py index e57fa2ce..af68f03a 100644 --- a/backend/app/api/projects_api/shared.py +++ b/backend/app/api/projects_api/shared.py @@ -29,7 +29,7 @@ from ...models import ( UserProject, WorkItem, ) -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.context_cache_service import context_cache_service from ...services.operation_log_service import log_operation from ...services.project_member import ( diff --git a/backend/app/api/prompt_profiles.py b/backend/app/api/prompt_profiles.py index 38be13d9..5b13b164 100644 --- a/backend/app/api/prompt_profiles.py +++ b/backend/app/api/prompt_profiles.py @@ -14,7 +14,7 @@ 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.prompt_profile import prompt_profile_service +from ..services.model.prompt_profile import prompt_profile_service router = APIRouter(prefix="/ai-steward/prompt-profiles", tags=["AI Steward"]) diff --git a/backend/app/api/rag_api/shared.py b/backend/app/api/rag_api/shared.py index ad9a96cf..520037f8 100644 --- a/backend/app/api/rag_api/shared.py +++ b/backend/app/api/rag_api/shared.py @@ -28,6 +28,6 @@ from ...schemas.rag import ( SymbolSyncRequest, ) from ...services.adaptive_rag_service import AdaptiveRAGService -from ...services.symbol_index import SymbolIndexService +from ...services.signal.symbol_index import SymbolIndexService router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) diff --git a/backend/app/api/scheduler.py b/backend/app/api/scheduler.py index 30aadb8f..ec4c698e 100644 --- a/backend/app/api/scheduler.py +++ b/backend/app/api/scheduler.py @@ -10,9 +10,9 @@ from ..core.database import get_db from ..dependencies import require_manager_user from ..models import KnowledgeBaseDoc, Project, User from ..services.adaptive_rag_service import AdaptiveRAGService -from ..services.ai_pmo_cadence import AIPMOCadenceService +from ..services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ..services.project_knowledge_graph_service import ProjectKnowledgeGraphService -from ..services.symbol_index import SymbolIndexService +from ..services.signal.symbol_index import SymbolIndexService from ..services import workload as workload_service router = APIRouter(prefix="/scheduler", tags=["scheduler"]) diff --git a/backend/app/api/signals_api/ingest_fetch.py b/backend/app/api/signals_api/ingest_fetch.py index e9c3419b..87800cd1 100644 --- a/backend/app/api/signals_api/ingest_fetch.py +++ b/backend/app/api/signals_api/ingest_fetch.py @@ -12,11 +12,11 @@ from ...core.database import get_db from ...dependencies.tenant import get_tenant_id from ...models import Signal from ...schemas import SignalCreate, SignalPriority, classify_signal_priority -from ...services.signal_capture_service.impact_analyzer import analyze_impact -from ...services.signal_capture_service.external_fetcher import fetch_hn_stories -from ...services.integration_core.lark_service import LarkService +from ...services.signal.signal_capture_service.impact_analyzer import analyze_impact +from ...services.signal.signal_capture_service.external_fetcher import fetch_hn_stories +from ...services.integrations.integration_core.lark_service import LarkService from ...services.llm_service import llm_service -from ...services.integration_core.plane_service import plane_service +from ...services.integrations.integration_core.plane_service import plane_service router = APIRouter() lark_service = LarkService() diff --git a/backend/app/api/skill_tags_api/routes.py b/backend/app/api/skill_tags_api/routes.py index 483d4e92..9d04ac87 100644 --- a/backend/app/api/skill_tags_api/routes.py +++ b/backend/app/api/skill_tags_api/routes.py @@ -10,7 +10,7 @@ from ...core.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 as skill_service -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.auth import has_any_role from ...utils.timezone import now_cn_naive from .contracts import ( diff --git a/backend/app/api/sstc_api/charters_ai_alignment.py b/backend/app/api/sstc_api/charters_ai_alignment.py index 8e6dd76c..f2714ec1 100644 --- a/backend/app/api/sstc_api/charters_ai_alignment.py +++ b/backend/app/api/sstc_api/charters_ai_alignment.py @@ -190,6 +190,9 @@ async def confirm_alignment( row.comments = body.comments member_ids = await svc.get_project_member_ids(pid) + if not member_ids: + # 无项目成员时至少以当前操作者作为最小确认集合,避免状态永久卡在“对齐中”。 + member_ids = [int(current_user.id)] confirmed_map: set[int] = set() rows_result = await db.execute( select(AlignmentRecord).where( @@ -253,4 +256,3 @@ async def confirm_alignment( "member_count": len(member_ids), } ) - diff --git a/backend/app/api/sstc_api/charters_detail_update.py b/backend/app/api/sstc_api/charters_detail_update.py index de6cca50..e342aeef 100644 --- a/backend/app/api/sstc_api/charters_detail_update.py +++ b/backend/app/api/sstc_api/charters_detail_update.py @@ -58,6 +58,20 @@ async def get_charter_detail( ) ) alignments = list(align_result.scalars().all()) + alignment_user_name_map: dict[int, str] = {} + alignment_user_ids = sorted({int(item.user_id) for item in alignments if item.user_id is not None}) + if alignment_user_ids: + user_result = await db.execute( + select(User.id, User.name, User.username).where( + and_( + User.tenant_id == tenant_id, + User.id.in_(alignment_user_ids), + ) + ) + ) + for user_id, name, username in user_result.all(): + uid = int(user_id) + alignment_user_name_map[uid] = str(name or username or f"用户 {uid}") source_signal = _select_source_signal(charter, signal_rows) return success_response( @@ -69,7 +83,13 @@ async def get_charter_detail( "signals": [_serialize_charter_signal(signal) for signal in signal_rows], "source_signal": _serialize_source_signal(source_signal) if source_signal else None, "tasks": [_serialize_charter_task(task) for task in tasks], - "alignment_records": [_serialize_alignment_record(record) for record in alignments], + "alignment_records": [ + _serialize_alignment_record( + record, + alignment_user_name_map.get(int(record.user_id)), + ) + for record in alignments + ], "status": charter.status, "version": charter.version, } @@ -173,4 +193,3 @@ async def update_charter( update_payload = _build_charter_summary_payload(charter) update_payload["new_version_created"] = False return success_response(update_payload) - diff --git a/backend/app/api/sstc_api/releases_list_create.py b/backend/app/api/sstc_api/releases_list_create.py index 4760afbb..e8003d43 100644 --- a/backend/app/api/sstc_api/releases_list_create.py +++ b/backend/app/api/sstc_api/releases_list_create.py @@ -54,20 +54,49 @@ async def create_release_plan( charter_id=int(body.charter_id), ) - if not body.task_ids: + normalized_task_ids = sorted({int(task_id) for task_id in body.task_ids if int(task_id) > 0}) + if not normalized_task_ids: raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务") + release_result = await db.execute( + select(TruthNode.id, TruthNode.content).where( + and_( + TruthNode.project_id == pid, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "release", + ) + ) + ) + linked_task_ids: set[int] = set() + for _release_id, raw_content in release_result.all(): + content = _as_dict(raw_content) + for task_id in content.get("task_ids", []) or []: + if isinstance(task_id, int): + linked_task_ids.add(int(task_id)) + continue + if isinstance(task_id, str) and task_id.strip().isdigit(): + linked_task_ids.add(int(task_id.strip())) + duplicated_task_ids = sorted(set(normalized_task_ids) & linked_task_ids) + if duplicated_task_ids: + raise HTTPException( + status_code=409, + detail={ + "message": "部分任务已关联到其他发布计划", + "task_ids": duplicated_task_ids, + }, + ) + task_result = await db.execute( select(Task).where( and_( - Task.id.in_(body.task_ids), + Task.id.in_(normalized_task_ids), Task.project_id == pid, Task.tenant_id == tenant_id, ) ) ) tasks = list(task_result.scalars().all()) - if len(tasks) != len(body.task_ids): + if len(tasks) != len(normalized_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: @@ -108,7 +137,7 @@ async def create_release_plan( entity_type="release", entity_id="new", action="create", - after_data={"title": release_node.title, "task_ids": body.task_ids}, + after_data={"title": release_node.title, "task_ids": normalized_task_ids}, ) await db.commit() await db.refresh(release_node) @@ -120,4 +149,3 @@ async def create_release_plan( "content": _as_dict(release_node.content), } ) - diff --git a/backend/app/api/sstc_api/shared.py b/backend/app/api/sstc_api/shared.py index 8379d548..dcd762ef 100644 --- a/backend/app/api/sstc_api/shared.py +++ b/backend/app/api/sstc_api/shared.py @@ -43,11 +43,11 @@ from ...schemas.sstc import ( ) from ...services.skill import skill_score_service from ...services import workload as workload_service -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.ai_service_core import AIService from ...services.auth import has_any_role from ...services.budget_enforcement import BudgetEnforcementService -from ...services.feishu_integration_service import get_feishu_service +from ...services.integrations.feishu_integration_service import get_feishu_service from ...services.llm_service import llm_service from ...services.operation_log_service import log_operation from ...services.skill import recommend_personnel diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py index 46101c4a..592485a1 100644 --- a/backend/app/api/sstc_api/tasks_ai_breakdown.py +++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from .shared import * from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project @@ -8,6 +10,13 @@ from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_projec router = APIRouter(tags=["sstc"]) +_TITLE_NORMALIZE_RE = re.compile(r"[\s\-_]+") + + +def _task_title_key(value: str) -> str: + text = str(value or "").strip().lower() + return _TITLE_NORMALIZE_RE.sub("", text) + @router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") async def ai_breakdown_tasks( project_id: str, @@ -45,13 +54,38 @@ async def ai_breakdown_tasks( except Exception as exc: raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc + existing_task_title_result = await db.execute( + select(Task.title).where( + and_( + Task.truth_node_id == cid, + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + existing_title_keys = { + _task_title_key(str(title)) + for title in existing_task_title_result.scalars().all() + if str(title or "").strip() + } + seen_title_keys = set(existing_title_keys) + created = [] + skipped_duplicates = 0 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 + title_key = _task_title_key(title) + if not title_key: + continue + if title_key in seen_title_keys: + skipped_duplicates += 1 + continue + seen_title_keys.add(title_key) + 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( @@ -82,7 +116,7 @@ async def ai_breakdown_tasks( user_id=int(current_user.id), function_name="sstc_ai_breakdown", prompt=prompt, - response=f"created_count={len(created)}", + response=f"created_count={len(created)},skipped_duplicates={skipped_duplicates}", tokens_used=prompt_tokens + completion_tokens, cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), status="success", @@ -96,7 +130,7 @@ async def ai_breakdown_tasks( entity_type="task", entity_id=str(cid), action="ai_breakdown", - after_data={"created_count": len(created)}, + after_data={"created_count": len(created), "skipped_duplicates": skipped_duplicates}, ) await db.commit() for task in created: @@ -112,10 +146,10 @@ async def ai_breakdown_tasks( return success_response( { "created_count": len(created), + "skipped_duplicates": skipped_duplicates, "items": [ {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} for t in created ], } ) - diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 6fb6d687..5ea31d76 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -9,7 +9,7 @@ from ..dependencies import get_current_user, require_manager_user from ..models import Task, User, UserProject from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse from ..services.llm_service import llm_service -from ..services.integration_core.plane_service import plane_service +from ..services.integrations.integration_core.plane_service import plane_service from ..services.skill import recommend_personnel router = APIRouter(prefix="/tasks", tags=["tasks"]) diff --git a/backend/app/api/users_api/shared.py b/backend/app/api/users_api/shared.py index d5d260e1..99a7ecdd 100644 --- a/backend/app/api/users_api/shared.py +++ b/backend/app/api/users_api/shared.py @@ -37,7 +37,7 @@ from ...schemas import ( UserUpdate, ) from ...services import skill as skill_service -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.auth import AuthService from ...services.operation_log_service import log_operation from ...utils.timezone import now_cn_naive diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py index 15496e86..12ee89e6 100644 --- a/backend/app/api/webhooks.py +++ b/backend/app/api/webhooks.py @@ -11,7 +11,7 @@ from sqlalchemy.future import select from ..core.database import get_db from ..models import Signal from ..services.llm_service import llm_service -from ..services.integration_core.plane_service import plane_service +from ..services.integrations.integration_core.plane_service import plane_service from ..utils.timezone import now_cn_aware router = APIRouter(prefix="/webhooks", tags=["webhooks"]) diff --git a/backend/app/api/wecom_api/config.py b/backend/app/api/wecom_api/config.py index 033f4447..20dc8b66 100644 --- a/backend/app/api/wecom_api/config.py +++ b/backend/app/api/wecom_api/config.py @@ -11,7 +11,7 @@ from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id from ...models import Integration, User from ...services.auth import has_any_role -from ...services.wecom import get_wecom_service +from ...services.integrations.wecom import get_wecom_service from .schemas import WeComConfigRequest router = APIRouter() diff --git a/backend/app/api/wecom_api/messaging.py b/backend/app/api/wecom_api/messaging.py index 5f40aea1..038d3498 100644 --- a/backend/app/api/wecom_api/messaging.py +++ b/backend/app/api/wecom_api/messaging.py @@ -9,7 +9,7 @@ from ...core.database import get_db from ...dependencies.auth import get_current_user from ...dependencies.tenant import get_tenant_id from ...models import User -from ...services.wecom import get_wecom_service +from ...services.integrations.wecom import get_wecom_service from .schemas import WeComSendMessageRequest router = APIRouter() diff --git a/backend/app/api/wecom_api/webhook.py b/backend/app/api/wecom_api/webhook.py index 4900645c..7f08415d 100644 --- a/backend/app/api/wecom_api/webhook.py +++ b/backend/app/api/wecom_api/webhook.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from ...core.database import get_db -from ...services.wecom import get_wecom_service +from ...services.integrations.wecom import get_wecom_service router = APIRouter() diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 1c4dce84..4c9f0005 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -116,5 +116,11 @@ __all__ = [ "PROMPT_WORKLOAD_OPTIMIZATION", "PROMPT_ANALYZE_FOOD_CALORIES", "PROMPT_SIGNAL_PLANNER", + "PROMPT_FEISHU_DIRECT_QA", + "PROMPT_FEISHU_MIXED_QA", + "PROMPT_PROJECT_STATUS_QA", + "PROMPT_MEMBER_WORKLOAD_QA", + "PROMPT_AI_PMO_QA", + "PROMPT_EXTERNAL_TREND_QA", "build_default_role_prompt_bundle", ] diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 9f657114..3419ac31 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -4,6 +4,12 @@ from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRAT from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_RAG_INTENT_CLASSIFICATION from .part_03 import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER +from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA +from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA +from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA +from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA +from .part_scenes_05 import PROMPT_AI_PMO_QA +from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA __all__ = [ "PROMPT_IMPACT_ANALYSIS", @@ -42,4 +48,10 @@ __all__ = [ "PROMPT_WORKLOAD_OPTIMIZATION", "PROMPT_ANALYZE_FOOD_CALORIES", "PROMPT_SIGNAL_PLANNER", + "PROMPT_FEISHU_DIRECT_QA", + "PROMPT_FEISHU_MIXED_QA", + "PROMPT_PROJECT_STATUS_QA", + "PROMPT_MEMBER_WORKLOAD_QA", + "PROMPT_AI_PMO_QA", + "PROMPT_EXTERNAL_TREND_QA", ] diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index fbb21ee6..5dd15b7a 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -188,6 +188,14 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """ 8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 +【输出规范】 +- 严禁输出任何思考过程或推理步骤 +- 严禁以以下句式开头: + "让我先/让我尝试/我来看看/ + 我先查/让我看看/我需要先" +- 直接输出最终回复内容 +- 不解释你的推理过程 + 只输出最终回复正文。 """.strip() diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index c5e8c3ec..0f747348 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -156,17 +156,15 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的 AI PMO 意图识别模块。你只做分类,不做执行。 +你是 PMFlow 的 AI PMO 意图识别模块。只做分类,不做执行。 输入: - message_text: {message_text} - session_last_intent: {session_last_intent} - session_project_id: {session_project_id} - world_model_project_id: {world_model_project_id} -- candidate_intents_json: {candidate_intents_json} -- candidate_routes_json: {candidate_routes_json} -输出 JSON: +只输出一个 JSON 对象(不要解释、不要 Markdown): {{ "family": "query|project_create|project_signal|signal_source", "action": "answer|create|discover|onboard", @@ -178,22 +176,16 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ "reasoning": "一句话简述判定原因" }} -规则: -1. family/action 必须和 candidate_routes_json 可映射,不能自造。 -2. 兼容字段 intent 必须从 candidate_intents_json 内选择,不能自造。 -3. 若 family=project_signal 且 action=discover,requires_confirmation 必须为 true。 -4. requested_count 仅在 project_signal 路由有效,范围 1-5;无法判断时返回 1。 -5. 若可直接回答,优先 family=query action=answer。 -6. “直接新增信号”返回 family=project_signal action=create;“先分析候选让我确认”返回 family=project_signal action=discover。 -7. “新增/接入信号源”返回 family=signal_source action=onboard。 -8. “新增项目”返回 family=project_create action=create。 -2. 若消息是短确认语气,优先结合 session_last_intent 判定。 -9. project_id 无法确定时返回 null,不要猜测无依据项目。 -10. 噪音/寒暄/无关消息返回 family=query action=answer,intent=query.read_only,reasoning 包含 smalltalk_ignore。 -5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -11. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project_create.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 project_signal.create。 -12. 只输出 JSON,不要额外文本。 -13. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 family=query action=answer,intent=query.read_only,并在 reasoning 包含 ddl_api_required。 +快速规则(按优先级): +1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。 +2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。 +3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。 +4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 +5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。 +6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 +7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 +8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 +9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index 75ddb687..6ae50fb4 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -14,6 +14,8 @@ PROMPT_RAG_ANSWER = """ 9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。 10. 若字段缺失,显式列出缺失字段名,不要假设。 11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。 +12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。 + 若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]" 当前项目ID:{project_id} 问题:{question} @@ -85,7 +87,7 @@ PROMPT_DECOMPOSE_TASKS = """ 宪章内容: {charter_content} -请识别每个任务所需的 AI 开发技能标签(从系统技能中匹配:Python, TypeScript, FastAPI, React, SQLAlchemy, LangChain, LLM调优, RAG构建, 向量数据库, Ollama, Docker, Git 等)。 +请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。 输出 JSON 格式: {{ diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py new file mode 100644 index 00000000..e4b3022a --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +PROMPT_FEISHU_DIRECT_QA = """ +你是 PMFlow 的项目管理助手。 +用户通过飞书直接提问。 + +规则: +1. 只基于给定上下文回答 +2. 证据不足直接说"信息不足,缺少:XXX" +3. 不输出思考过程,直接给答案 +4. 回答简洁,适合飞书消息阅读(不超过200字) +5. 涉及项目数据优先用数字说话 + +当前项目ID:{project_id} +问题:{question} +System Snapshot:{system_snapshot} +User Snapshot:{user_snapshot} +最近会话:{conversation} +检索上下文:{context_blocks} +""".strip() + +__all__ = ["PROMPT_FEISHU_DIRECT_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_02.py b/backend/app/config/ai_prompts_catalog/part_scenes_02.py new file mode 100644 index 00000000..75a83cbd --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_02.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +PROMPT_FEISHU_MIXED_QA = """ +你是 PMFlow 的项目管理助手。 +用户消息同时包含问题和需求。 + +处理规则: +1. 先回答问题部分(简洁,不超过100字) +2. 确认已收到需求部分 +3. 格式: + [回答] xxx + [已收到需求] xxx,已入库处理 + +禁止: +- 输出思考过程 +- 把需求部分当问题回答 +- 合并两部分混着说 + +当前项目ID:{project_id} +消息:{message} +问题部分:{question_part} +需求部分:{signal_part} +System Snapshot:{system_snapshot} +检索上下文:{context_blocks} +""".strip() + +__all__ = ["PROMPT_FEISHU_MIXED_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_03.py b/backend/app/config/ai_prompts_catalog/part_scenes_03.py new file mode 100644 index 00000000..78977a30 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_03.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +PROMPT_PROJECT_STATUS_QA = """ +你是 PMFlow 的项目状态语义检索助手。 +用户关注项目进度、状态、完成率。 + +规则: +1. 只基于给定上下文回答 +2. 禁止输出思考过程 +3. 用数字量化回答: + 完成率X%,已完成X个任务,进行中X个,待办X个 +4. 涉及多个项目时用列表格式 +5. 证据不足直接说: + 信息不足,缺少:XXX + +当前项目ID:{project_id} +问题:{question} +System Snapshot:{system_snapshot} +User Snapshot:{user_snapshot} +检索上下文:{context_blocks} +""".strip() + +__all__ = ["PROMPT_PROJECT_STATUS_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_04.py b/backend/app/config/ai_prompts_catalog/part_scenes_04.py new file mode 100644 index 00000000..43f22ad5 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_04.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +PROMPT_MEMBER_WORKLOAD_QA = """ +你是 PMFlow 的团队协作助手。 +用户询问团队成员或负载相关信息。 + +规则: +1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据 +2. 禁止默认返回项目列表 +3. 涉及负载用百分比表示 +4. 推荐负责人时说明推荐理由 +5. 禁止输出思考过程 +6. 不超过150字 + +问题:{question} +System Snapshot:{system_snapshot} +User Snapshot:{user_snapshot} +检索上下文:{context_blocks} +""".strip() + +__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_05.py b/backend/app/config/ai_prompts_catalog/part_scenes_05.py new file mode 100644 index 00000000..f8bbfe41 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_05.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +PROMPT_AI_PMO_QA = """ +你是 PMFlow 的 AI 项目管理官。 +用户通过指令入口提问。 + +规则: +1. 你既是执行者也是回答者 +2. 问题涉及操作时先回答再提示可用指令 +3. 只基于给定上下文回答 +4. 禁止输出思考过程 +5. 回答后附可操作建议: + "你可以继续:[操作A]/[操作B]" + +问题:{question} +当前项目:{project_id} +World Model:{world_model} +检索上下文:{context_blocks} +""".strip() + +__all__ = ["PROMPT_AI_PMO_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py new file mode 100644 index 00000000..3f4d565c --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +PROMPT_EXTERNAL_TREND_QA = """ +你是 PMFlow 的市场情报助手。 +用户询问外部行业或市场信息。 + +规则: +1. 若有外部搜索结果优先使用 +2. 若无外部数据明确说: + "当前未接入实时数据,以下基于训练数据(截止XXXX年)" +3. 结合项目实际给出应用建议: + "对你们[项目名]的影响是..." +4. 禁止输出思考过程 +5. 分段输出: + [行业动态] xxx + [对项目的影响] xxx + [建议] xxx + +问题:{question} +项目上下文:{project_context} +外部搜索结果:{external_results} +""".strip() + +__all__ = ["PROMPT_EXTERNAL_TREND_QA"] diff --git a/backend/app/core/startup/ai_pmo_ingress_async.py b/backend/app/core/startup/ai_pmo_ingress_async.py new file mode 100644 index 00000000..36976475 --- /dev/null +++ b/backend/app/core/startup/ai_pmo_ingress_async.py @@ -0,0 +1,30 @@ +"""AI PMO ingress async queue polling loop.""" + +from __future__ import annotations + +import asyncio +import traceback + +from ..logging import get_logger +from ...services.ai_orchestration_run.ingress_worker import dispatch_pending_ingress_runs + + +logger = get_logger(__name__) + + +async def ai_pmo_ingress_drain_loop() -> None: + while True: + try: + dispatched = await dispatch_pending_ingress_runs(limit=20) + await asyncio.sleep(0.3 if dispatched > 0 else 1.0) + except asyncio.CancelledError: + break + except Exception as exc: + logger.error( + "ai_pmo_ingress_drain_failed", + run_id=getattr(exc, "run_id", None), + error_type=type(exc).__name__, + error=str(exc), + stack_trace=traceback.format_exc(), + ) + await asyncio.sleep(1.0) diff --git a/backend/app/core/startup/feishu_outbox.py b/backend/app/core/startup/feishu_outbox.py index 9d0f4de5..7df150f7 100644 --- a/backend/app/core/startup/feishu_outbox.py +++ b/backend/app/core/startup/feishu_outbox.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from ..logging import get_logger from ..database import engine -from ...services.feishu_integration_service import dispatch_pending_feishu_replies +from ...services.integrations.feishu_integration_service import dispatch_pending_feishu_replies from .db_guard import normalize_dialect logger = get_logger(__name__) diff --git a/backend/app/core/startup/world_model_warmup.py b/backend/app/core/startup/world_model_warmup.py index 2857e078..0a6f17e4 100644 --- a/backend/app/core/startup/world_model_warmup.py +++ b/backend/app/core/startup/world_model_warmup.py @@ -12,7 +12,7 @@ from ..logging import get_logger from ..database import AsyncSessionLocal from .db_guard import is_missing_table_error from ...models import Project, WorkItem -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...utils.timezone import now_cn_aware logger = get_logger(__name__) diff --git a/backend/app/main.py b/backend/app/main.py index 42bab875..5216931e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,13 +15,14 @@ from .core.logging import configure_logging, get_logger from .core.response import success_response from .core.database import AsyncSessionLocal, engine from .core.startup.db_guard import check_database_dialect_guard +from .core.startup.ai_pmo_ingress_async import ai_pmo_ingress_drain_loop from .core.startup.feishu_outbox import feishu_outbox_drain_loop from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log from .core.startup.world_model_warmup import warmup_world_models from .middleware import AuditContextMiddleware from .services.ai_orchestration_run import AIOrchestrationRunService from .services.context_cache_service import register_context_cache_invalidation_hooks -from .services.feishu_long_connection_core import init_feishu_long_connection_manager +from .services.integrations.feishu_long_connection_core import init_feishu_long_connection_manager from .services.scheduler_center_core import ( AIPMOCadenceScheduler, KnowledgeGraphIncrementalScheduler, @@ -85,6 +86,7 @@ async def lifespan(_: FastAPI): await feishu_lc.start() outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop") + ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop") wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup") startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") @@ -97,6 +99,12 @@ async def lifespan(_: FastAPI): except asyncio.CancelledError: pass + ingress_task.cancel() + try: + await ingress_task + except asyncio.CancelledError: + pass + if not wm_warmup_task.done(): wm_warmup_task.cancel() with suppress(asyncio.CancelledError): diff --git a/backend/app/models/ai_orchestration_run.py b/backend/app/models/ai_orchestration_run.py index 5afbb23c..15f091fa 100644 --- a/backend/app/models/ai_orchestration_run.py +++ b/backend/app/models/ai_orchestration_run.py @@ -20,7 +20,7 @@ class AIOrchestrationRun(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) run_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) - project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True) + project_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=True, index=True) signal_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("signals.id"), nullable=True, index=True) response_mode: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="fast", index=True) status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="queued", index=True) @@ -32,6 +32,28 @@ class AIOrchestrationRun(Base): summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) planner_diagnostics: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + source_system: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) + source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + sender: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True) + source_sender_open_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + channel_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + chat_type: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) + captured_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) + message_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + project_hint_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=True, index=True) + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + next_retry_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + last_error_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + ack_sent_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + result_notified_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + work_item_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("work_items.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) created_by_user_id: Mapped[Optional[int]] = mapped_column( BigInteger, ForeignKey("users.id"), diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py index a8481598..9b4162f5 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -5,6 +5,28 @@ from .shared import * class AdaptiveRAGAnswerEntryMixin: + @staticmethod + def _should_enforce_scene_prompt_only( + *, + asked_via: str, + message_intent: Optional[str], + ) -> bool: + intent = str(message_intent or "").strip().lower() + if intent == "mixed": + return True + channel = str(asked_via or "").strip().lower() + return channel in { + "feishu", + "lark", + "feishu_group", + "feishu_dm", + "pmflow_manual", + "/ai-pmo/process-text", + "ai-pmo-process-text", + "ai_pmo", + "ai_pmo_ui", + } + async def answer_question( self, *, @@ -19,6 +41,8 @@ class AdaptiveRAGAnswerEntryMixin: thinking_mode: str = THINKING_MODE_NORMAL, action_mode: str = ACTION_MODE_AUTO, sync_requirement: bool = True, + message_intent: Optional[str] = None, + mixed_signal_part: Optional[str] = None, ) -> dict[str, Any]: started = time.perf_counter() clean_q = str(question or "").strip() @@ -30,6 +54,10 @@ class AdaptiveRAGAnswerEntryMixin: 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() + enforce_scene_prompt_only = self._should_enforce_scene_prompt_only( + asked_via=asked_via, + message_intent=message_intent, + ) deep_retrieval_steps = DEEP_RETRIEVAL_STEPS deep_vector_top_k = DEEP_VECTOR_TOP_K if asked_via_lower == "feishu": @@ -166,7 +194,7 @@ class AdaptiveRAGAnswerEntryMixin: user_id=asked_by, ) - if not deep_thinking: + if not deep_thinking and not enforce_scene_prompt_only: deterministic_result = await self._run_deterministic_query( question=effective_q, project_id=project_id, @@ -191,7 +219,7 @@ class AdaptiveRAGAnswerEntryMixin: ) direct_answer = None - if not deep_thinking: + if not deep_thinking and not enforce_scene_prompt_only: direct_answer = self._build_snapshot_direct_answer( effective_q, system_snapshot=system_snapshot, @@ -217,7 +245,7 @@ class AdaptiveRAGAnswerEntryMixin: return direct_result is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) - if not deep_thinking and not is_snapshot_direct_question: + if not deep_thinking and not enforce_scene_prompt_only 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( @@ -260,5 +288,6 @@ class AdaptiveRAGAnswerEntryMixin: system_snapshot=system_snapshot, user_snapshot=user_snapshot, started=started, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, ) - diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index 037b6ae4..c6c361f4 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -30,6 +30,8 @@ class AdaptiveRAGAnswerRetrievalMixin: system_snapshot: dict[str, Any], user_snapshot: Optional[dict[str, Any]], started: float, + message_intent: Optional[str] = None, + mixed_signal_part: Optional[str] = None, ) -> dict[str, Any]: domains = self._route_domains(effective_q) if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: @@ -76,6 +78,9 @@ class AdaptiveRAGAnswerRetrievalMixin: 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, + asked_via=asked_via, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, ) used_sources = self._dedupe_sources(retrieved) if not used_sources: @@ -134,6 +139,9 @@ class AdaptiveRAGAnswerRetrievalMixin: thinking_mode=THINKING_MODE_DEEP, context_block_limit=DEEP_CONTEXT_BLOCKS, answer_max_tokens=DEEP_ANSWER_MAX_TOKENS, + asked_via=asked_via, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, ) deep_sources = self._dedupe_sources(deep_retrieved) if not deep_sources: @@ -284,4 +292,3 @@ class AdaptiveRAGAnswerRetrievalMixin: missing_fields=missing_fields, thinking_mode=active_thinking_mode, ) - diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index 7ee74008..19d1c41f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -5,6 +5,161 @@ from .shared import * class AdaptiveRAGDeepGuardAnswerMixin: + @staticmethod + def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool: + channel = str(asked_via or "").strip().lower() + return channel in { + "pmflow_manual", + "/ai-pmo/process-text", + "ai-pmo-process-text", + "ai_pmo", + "ai_pmo_ui", + } + + @staticmethod + def _contains_any_keyword(text: str, keywords: tuple[str, ...]) -> bool: + return any(token in text for token in keywords) + + @staticmethod + def _format_mixed_two_block_output( + *, + answer_text: str, + question_part: str, + signal_part: Optional[str], + ) -> str: + raw = str(answer_text or "").strip() + lines = [line.strip() for line in raw.splitlines() if str(line).strip()] + answer_block = "" + signal_block = "" + for line in lines: + if line.startswith("[回答]"): + answer_block = line.replace("[回答]", "", 1).strip() + continue + if line.startswith("[已收到需求]"): + signal_block = line.replace("[已收到需求]", "", 1).strip() + + if not answer_block: + if lines: + answer_block = re.sub(r"^\[[^\]]+\]\s*", "", lines[0]).strip() + if not answer_block: + answer_block = str(question_part or "").strip() or "当前信息不足,请补充问题细节。" + answer_block = answer_block[:140] + + if not signal_block: + signal_block = str(signal_part or "").strip() or "需求信息待补充" + signal_block = re.sub(r"[,,]?\s*已入库处理。?$", "", signal_block).strip() or "需求信息待补充" + + return f"[回答] {answer_block}\n[已收到需求] {signal_block},已入库处理" + + def _select_scene_prompt( + self, + *, + question: str, + project_id: Optional[int], + asked_via: str, + message_intent: Optional[str], + mixed_signal_part: Optional[str], + conversation_text: str, + context_blocks_text: str, + system_snapshot: Optional[dict[str, Any]], + user_snapshot: Optional[dict[str, Any]], + system_snapshot_text: str, + user_snapshot_text: str, + relevance_max: float, + ) -> tuple[str, str]: + normalized_question = re.sub(r"\s+", "", str(question or "").lower()) + member_keywords = ("谁", "成员", "负责人", "负载") + trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态") + + if self._contains_any_keyword(normalized_question, member_keywords): + return ( + "member_workload", + PROMPT_MEMBER_WORKLOAD_QA.format( + question=question, + system_snapshot=system_snapshot_text or "{}", + user_snapshot=user_snapshot_text or "{}", + context_blocks=context_blocks_text or "无", + ).strip(), + ) + + if self._contains_any_keyword(normalized_question, trend_keywords): + project_context_text = json.dumps( + { + "project_id": project_id if project_id is not None else "unknown", + "system_snapshot": system_snapshot or {}, + "user_snapshot": user_snapshot or {}, + }, + ensure_ascii=False, + )[:16000] + return ( + "external_trend", + PROMPT_EXTERNAL_TREND_QA.format( + question=question, + project_context=project_context_text or "{}", + external_results=context_blocks_text or "无", + ).strip(), + ) + + if str(message_intent or "").strip().lower() == "mixed": + safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充" + merged_message = f"{question}\n{safe_signal_part}".strip() + return ( + "mixed", + PROMPT_FEISHU_MIXED_QA.format( + project_id=project_id if project_id is not None else "unknown", + message=merged_message, + question_part=question, + signal_part=safe_signal_part, + system_snapshot=system_snapshot_text or "{}", + context_blocks=context_blocks_text or "无", + ).strip(), + ) + + if self._is_ai_pmo_process_entry(asked_via): + world_model_text = json.dumps( + { + "system_snapshot": system_snapshot or {}, + "user_snapshot": user_snapshot or {}, + }, + ensure_ascii=False, + )[:18000] + return ( + "ai_pmo", + PROMPT_AI_PMO_QA.format( + question=question, + project_id=project_id if project_id is not None else "unknown", + world_model=world_model_text or "{}", + context_blocks=context_blocks_text or "无", + ).strip(), + ) + + normalized_asked_via = str(asked_via or "").strip().lower() + if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: + return ( + "feishu_direct", + PROMPT_FEISHU_DIRECT_QA.format( + project_id=project_id if project_id is not None else "unknown", + question=question, + system_snapshot=system_snapshot_text or "{}", + user_snapshot=user_snapshot_text or "{}", + conversation=conversation_text or "无", + context_blocks=context_blocks_text or "无", + ).strip(), + ) + + return ( + "default", + PROMPT_RAG_ANSWER.format( + project_id=project_id if project_id is not None else "unknown", + question=question, + conversation=conversation_text or "无", + context_blocks=context_blocks_text or "无", + 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(), + ) + def _build_snapshot_direct_answer( self, question: str, @@ -122,6 +277,9 @@ class AdaptiveRAGDeepGuardAnswerMixin: thinking_mode: str = THINKING_MODE_NORMAL, context_block_limit: Optional[int] = None, answer_max_tokens: Optional[int] = None, + asked_via: str = "pmflow", + message_intent: Optional[str] = None, + mixed_signal_part: Optional[str] = None, ) -> str: active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP @@ -149,18 +307,24 @@ class AdaptiveRAGDeepGuardAnswerMixin: for row in conversation[-10:]: history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}") + conversation_text = chr(10).join(history_lines) if history_lines else "无" + context_blocks_text = chr(10).join(context_blocks) if context_blocks else "无" 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", + scene_key, prompt = self._select_scene_prompt( 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() + project_id=project_id, + asked_via=asked_via, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, + conversation_text=conversation_text, + context_blocks_text=context_blocks_text, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + system_snapshot_text=system_snapshot_text, + user_snapshot_text=user_snapshot_text, + relevance_max=relevance_max, + ) if deep_thinking: prompt = ( f"{prompt}\n\n" @@ -191,6 +355,12 @@ class AdaptiveRAGDeepGuardAnswerMixin: text = str(answer or "").strip() if not text: return "我未能从当前上下文生成有效答案,请补充问题细节后重试。" + if scene_key == "mixed": + return self._format_mixed_two_block_output( + answer_text=text, + question_part=question, + signal_part=mixed_signal_part, + )[:3200] enforced = self._enforce_feature_evidence_in_answer( question=question, answer_text=text, @@ -198,4 +368,3 @@ class AdaptiveRAGDeepGuardAnswerMixin: system_snapshot=system_snapshot, ) return str(enforced or text)[:3200] - diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py index 81d55c95..c1a3d7ca 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py @@ -168,14 +168,14 @@ class AdaptiveRAGDeepGuardRulesMixin: "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" ) - @staticmethod - def _is_snapshot_direct_question(question: str) -> bool: + @classmethod + def _is_snapshot_direct_question(cls, question: str) -> bool: text = str(question or "").strip().lower() if not text: return False - if AdaptiveRAGService._is_correction_query(text): + if cls._is_correction_query(text): return False - if AdaptiveRAGService._is_engine_process_query(text): + if cls._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) @@ -226,4 +226,3 @@ class AdaptiveRAGDeepGuardRulesMixin: if is_enumeration and "项目" in anchor_question: return f"{anchor_question}。请列出对应项目的名称和ID。" return f"{anchor_question}。补充问题:{current}" - diff --git a/backend/app/services/adaptive_rag_service/mixin_ingest.py b/backend/app/services/adaptive_rag_service/mixin_ingest.py index 4b948792..4d3e497c 100644 --- a/backend/app/services/adaptive_rag_service/mixin_ingest.py +++ b/backend/app/services/adaptive_rag_service/mixin_ingest.py @@ -71,7 +71,9 @@ class AdaptiveRAGIngestMixin: project_id: Optional[int] = None, ) -> None: async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) + from .service import AdaptiveRAGService as AdaptiveRAGServiceRuntime + + service = AdaptiveRAGServiceRuntime(session, tenant_id=tenant_id) try: await service.ingest_feishu_message( content=content, @@ -93,7 +95,9 @@ class AdaptiveRAGIngestMixin: project_id: Optional[int] = None, ) -> None: async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) + from .service import AdaptiveRAGService as AdaptiveRAGServiceRuntime + + service = AdaptiveRAGServiceRuntime(session, tenant_id=tenant_id) try: await service.process_knowledge_doc_content( doc_id=doc_id, @@ -138,7 +142,9 @@ class AdaptiveRAGIngestMixin: project_id: Optional[int] = None, ) -> None: async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) + from .service import AdaptiveRAGService as AdaptiveRAGServiceRuntime + + service = AdaptiveRAGServiceRuntime(session, tenant_id=tenant_id) try: await service.refresh_url_doc(doc_id=doc_id, project_id=project_id) except Exception: @@ -216,7 +222,9 @@ class AdaptiveRAGIngestMixin: content: str, ) -> None: async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) + from .service import AdaptiveRAGService as AdaptiveRAGServiceRuntime + + service = AdaptiveRAGServiceRuntime(session, tenant_id=tenant_id) try: await service.upsert_pmflow_record( record_type=record_type, @@ -227,4 +235,3 @@ class AdaptiveRAGIngestMixin: ) except Exception: await session.rollback() - diff --git a/backend/app/services/adaptive_rag_service/shared_imports.py b/backend/app/services/adaptive_rag_service/shared_imports.py index c74473d2..5f173957 100644 --- a/backend/app/services/adaptive_rag_service/shared_imports.py +++ b/backend/app/services/adaptive_rag_service/shared_imports.py @@ -27,7 +27,16 @@ import httpx from sqlalchemy import and_, bindparam, delete, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession -from ...config.ai_prompts import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_RAG_INTENT_CLASSIFICATION +from ...config.ai_prompts import ( + PROMPT_AI_PMO_QA, + PROMPT_EXTERNAL_TREND_QA, + PROMPT_FEISHU_DIRECT_QA, + PROMPT_FEISHU_MIXED_QA, + PROMPT_MEMBER_WORKLOAD_QA, + PROMPT_RAG_ANSWER, + PROMPT_RAG_DB_PROJECT_QA, + PROMPT_RAG_INTENT_CLASSIFICATION, +) from ...core.database import AsyncSessionLocal from ...models import ( KnowledgeBaseChunk, @@ -50,8 +59,8 @@ from ...models import ( ) from ..context_cache_service import context_cache_service from ..llm_service import LLMService -from ..signal_source_orchestration import SignalSourceOrchestrationService -from ..symbol_index import SymbolIndexService +from ..signal.signal_source_orchestration import SignalSourceOrchestrationService +from ..signal.symbol_index import SymbolIndexService from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py index 9a238887..3773ab4f 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_02.py +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -138,6 +138,9 @@ ENTITY_ALIAS_MAP: dict[str, str] = { TS_SYMBOL_RE = re.compile( r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)" ) + + +@dataclass class RetrievedItem: source: str content: str diff --git a/backend/app/services/adaptive_rag_service/shared_utils.py b/backend/app/services/adaptive_rag_service/shared_utils.py index 101c4dc8..33487358 100644 --- a/backend/app/services/adaptive_rag_service/shared_utils.py +++ b/backend/app/services/adaptive_rag_service/shared_utils.py @@ -15,3 +15,5 @@ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: return max(minimum, min(maximum, value)) +__all__ = ["_env_int"] + diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py new file mode 100644 index 00000000..06134254 --- /dev/null +++ b/backend/app/services/ai_orchestration_run/ingress_worker.py @@ -0,0 +1,265 @@ +"""AI PMO ingress async worker for queued Feishu messages.""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any, Optional + +from ...core.database import AsyncSessionLocal +from ...core.logging import get_logger +from ...utils.timezone import now_cn_naive +from ..integrations.feishu_outbox.service import FeishuOutboxService +from ..signal.signal_capture_service import ProactiveSignalCaptureService +from .service import AIOrchestrationRunService, INGRESS_RUN_TYPE + + +logger = get_logger(__name__) + +FINAL_FAILURE_TEXT = "已自动重试3次仍失败,请重新发送或换个说法。" +INGRESS_HEARTBEAT_INTERVAL_SECONDS = 15 + + +class IngressDispatchError(RuntimeError): + """Raised when dispatching a claimed ingress run fatally fails.""" + + def __init__(self, *, run_id: Optional[int], cause: Exception): + self.run_id = int(run_id) if run_id is not None else None + self.cause = cause + super().__init__(str(cause)) + + +def _safe_int(value: Any) -> Optional[int]: + try: + parsed = int(value) + except Exception: + return None + return parsed + + +def _build_final_reply_text(result: dict[str, Any]) -> str: + if not isinstance(result, dict): + return "" + if str(result.get("reason") or "").strip().lower() == "duplicate_filtered": + return "" + if str(result.get("classification") or "").strip().lower() == "duplicate": + return "" + direct = str(result.get("direct_reply_text") or "").strip() + if direct: + return direct[:500] + status = str(result.get("status") or "").strip().lower() + if status in {"captured", "answered"}: + return "处理完成。" + return "" + + +async def _touch_ingress_run_heartbeat(*, run_id: int) -> None: + """Refresh updated_at so stale-recovery does not treat active runs as dead.""" + async with AsyncSessionLocal() as db: + run_service = AIOrchestrationRunService(db) + row = await run_service.get_run_by_id(run_id=run_id) + if row is None: + await db.commit() + return + if str(row.run_type or "") != INGRESS_RUN_TYPE: + await db.commit() + return + if str(row.status or "").lower() in {"failed", "succeeded", "cancelled", "interrupted", "completed"}: + await db.commit() + return + row.updated_at = now_cn_naive() + await db.flush() + await db.commit() + + +async def _finalize_ingress_failure_with_fresh_session( + *, + run_id: int, + tenant_id: int, + channel_ref: str, + source_message_id: Optional[str], + trace_id: str, + error_message: str, +) -> None: + """Failure finalize path must not depend on the potentially committed caller session.""" + async with AsyncSessionLocal() as failure_db: + run_service = AIOrchestrationRunService(failure_db) + _failed_row, should_notify = await run_service.mark_ingress_failed_or_retrying( + run_id=run_id, + error_message=error_message, + ) + if should_notify and channel_ref: + await FeishuOutboxService.enqueue( + failure_db, + tenant_id=tenant_id, + chat_id=channel_ref, + text=FINAL_FAILURE_TEXT, + source_message_id=source_message_id, + dedupe_seed=f"{trace_id}:final_failed", + trace_id=trace_id or None, + ) + await run_service.mark_ingress_result_notified(run_id=run_id) + await failure_db.commit() + + +async def _claim_next_ingress_run_id() -> Optional[int]: + async with AsyncSessionLocal() as db: + run_service = AIOrchestrationRunService(db) + row = await run_service.claim_next_ingress_run() + if row is None: + await db.commit() + return None + run_id = int(row.id) + await db.commit() + return run_id + + +async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: + async with AsyncSessionLocal() as db: + run_service = AIOrchestrationRunService(db) + row = await run_service.get_run_by_id(run_id=run_id) + if row is None: + await db.commit() + return + if str(row.run_type or "") != INGRESS_RUN_TYPE: + await db.commit() + return + if str(row.status or "") != "running": + await db.commit() + return + + # Copy all values that may be needed after downstream calls. + # Downstream code may commit/rollback and expire ORM attributes. + run_id_value = int(row.id) + tenant_id_value = int(row.tenant_id) + trace_id_value = str(row.trace_id or "").strip() + channel_ref_value = str(row.channel_ref or "").strip() + message_text_value = str(row.message_text or "").strip() + sender_value = str(row.sender or "unknown").strip() or "unknown" + source_sender_open_id_value = str(row.source_sender_open_id or "").strip() or None + captured_at_value = row.captured_at + source_message_id_value = str(row.source_message_id or "").strip() or None + chat_type_value = str(row.chat_type or "").strip() or None + + try: + capture_service = ProactiveSignalCaptureService(db) + stop_heartbeat = asyncio.Event() + + async def _heartbeat_loop() -> None: + while not stop_heartbeat.is_set(): + try: + await _touch_ingress_run_heartbeat(run_id=run_id_value) + except Exception as heartbeat_exc: + logger.warning( + "ingress_async_heartbeat_failed", + run_id=run_id_value, + trace_id=trace_id_value, + error=str(heartbeat_exc), + ) + try: + await asyncio.wait_for(stop_heartbeat.wait(), timeout=INGRESS_HEARTBEAT_INTERVAL_SECONDS) + except asyncio.TimeoutError: + continue + + heartbeat_task = asyncio.create_task(_heartbeat_loop()) + try: + # Explicitly touch once before dispatching downstream LLM-heavy flow. + await _touch_ingress_run_heartbeat(run_id=run_id_value) + result = await capture_service.capture_from_feishu( + group_chat_id=channel_ref_value, + message_content=message_text_value, + sender=sender_value, + tenant_id=tenant_id_value, + proposer_open_id=source_sender_open_id_value, + captured_at=captured_at_value, + mentioned_bot_names=None, + feishu_message_id=source_message_id_value, + chat_type=chat_type_value, + force_sync=True, + ) + finally: + stop_heartbeat.set() + with contextlib.suppress(Exception): + await heartbeat_task + # Explicitly touch once after downstream call returns. + with contextlib.suppress(Exception): + await _touch_ingress_run_heartbeat(run_id=run_id_value) + status = str(result.get("status") or "").strip().lower() if isinstance(result, dict) else "" + if status == "error" or not isinstance(result, dict): + raise RuntimeError(str((result or {}).get("reason") or "ingress_async_unhandled_result")) + + work_item_id = _safe_int((result or {}).get("work_item_id")) + summary = str((result or {}).get("direct_reply_text") or "异步处理完成。").strip()[:1000] + await run_service.mark_ingress_succeeded( + run_id=run_id_value, + result_payload=result if isinstance(result, dict) else {}, + work_item_id=work_item_id, + summary=summary, + ) + + final_reply_text = _build_final_reply_text(result) + if final_reply_text and channel_ref_value: + await FeishuOutboxService.enqueue( + db, + tenant_id=tenant_id_value, + chat_id=channel_ref_value, + text=final_reply_text, + source_message_id=source_message_id_value, + dedupe_seed=f"{trace_id_value}:final", + trace_id=trace_id_value or None, + ) + await run_service.mark_ingress_result_notified(run_id=run_id_value) + await db.commit() + except Exception as exc: + await _finalize_ingress_failure_with_fresh_session( + run_id=run_id_value, + tenant_id=tenant_id_value, + channel_ref=channel_ref_value, + source_message_id=source_message_id_value, + trace_id=trace_id_value, + error_message=str(exc), + ) + logger.warning( + "ingress_async_run_failed", + run_id=run_id_value, + trace_id=trace_id_value, + error=str(exc), + ) + + +async def _mark_run_failed_greenlet_spawn(*, run_id: int) -> None: + """Persist fatal async session/greenlet failure so run is not silently skipped.""" + async with AsyncSessionLocal() as db: + run_service = AIOrchestrationRunService(db) + row = await run_service.get_run_by_id(run_id=int(run_id)) + if row is None: + await db.commit() + return + now_at = now_cn_naive() + row.status = "failed" + row.openclaw_status = "failed" + row.next_retry_at = None + row.last_error = "greenlet_spawn_error" + row.last_error_at = now_at + row.completed_at = now_at + row.summary = "异步处理失败(greenlet_spawn_error)" + row.worker_failed = max(1, int(row.worker_failed or 0) + 1) + await db.flush() + await db.commit() + + +async def dispatch_pending_ingress_runs(limit: int = 20) -> int: + fetch_limit = max(1, min(int(limit), 100)) + dispatched = 0 + for _ in range(fetch_limit): + run_id = await _claim_next_ingress_run_id() + if run_id is None: + break + try: + await _dispatch_claimed_ingress_run(run_id=int(run_id)) + except Exception as exc: + if "greenlet_spawn" in str(exc): + await _mark_run_failed_greenlet_spawn(run_id=int(run_id)) + raise IngressDispatchError(run_id=int(run_id), cause=exc) from exc + dispatched += 1 + return dispatched diff --git a/backend/app/services/ai_orchestration_run/service.py b/backend/app/services/ai_orchestration_run/service.py index c6be4539..05e8f7a4 100644 --- a/backend/app/services/ai_orchestration_run/service.py +++ b/backend/app/services/ai_orchestration_run/service.py @@ -2,20 +2,40 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +import hashlib +from datetime import datetime, timedelta from typing import Any, Optional -from sqlalchemy import and_, select +from sqlalchemy import and_, or_, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from ...core.database import AsyncSessionLocal from ...models import AIOrchestrationRun -from ...utils.timezone import now_cn_naive +from ...utils.timezone import CN_TZ, now_cn_naive + + def _utc_now_naive() -> datetime: return now_cn_naive() +def _normalize_naive_datetime(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if not isinstance(value, datetime): + return None + if value.tzinfo is None: + return value + return value.astimezone(CN_TZ).replace(tzinfo=None) + + +INGRESS_RUN_TYPE = "ingress_feishu_message" +INGRESS_RETRY_SCHEDULE_SECONDS: tuple[int, ...] = (10, 30, 90) +INGRESS_RUNNING_STALE_MINUTES = 30 + + class AIOrchestrationRunService: def __init__(self, db: AsyncSession) -> None: self.db = db @@ -26,7 +46,7 @@ class AIOrchestrationRunService: tenant_id: int, trace_id: str, run_type: str, - project_id: int, + project_id: Optional[int], signal_id: Optional[int], response_mode: str, status: str, @@ -66,6 +86,287 @@ class AIOrchestrationRunService: await self.db.flush() return row + @staticmethod + def build_ingress_trace_id( + *, + source_system: str, + source_message_id: str, + ) -> str: + normalized_source = str(source_system or "").strip().lower()[:24] or "unknown" + normalized_message_id = str(source_message_id or "").strip()[:96] + if normalized_message_id: + raw = f"{normalized_source}:{normalized_message_id}" + else: + digest = hashlib.sha1(f"{normalized_source}:{_utc_now_naive().isoformat()}".encode("utf-8"), usedforsecurity=False).hexdigest() + raw = f"{normalized_source}:{digest[:40]}" + return raw[:64] + + async def get_run_by_id(self, *, run_id: int) -> Optional[AIOrchestrationRun]: + return ( + await self.db.execute( + select(AIOrchestrationRun).where(AIOrchestrationRun.id == int(run_id)) + ) + ).scalar_one_or_none() + + async def enqueue_ingress_run( + self, + *, + tenant_id: int, + source_system: str, + source_message_id: str, + sender: str, + message_text: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + chat_type: Optional[str], + captured_at: Optional[datetime], + project_hint_id: Optional[int], + max_retries: int = 3, + ) -> tuple[AIOrchestrationRun, bool]: + trace_id = self.build_ingress_trace_id( + source_system=source_system, + source_message_id=source_message_id, + ) + existing = await self.get_run(tenant_id=tenant_id, trace_id=trace_id) + if existing is not None: + return existing, False + + result_payload = { + "queue_mode": "async_ingress", + "source_system": str(source_system or "").strip()[:32], + "source_message_id": str(source_message_id or "").strip()[:128], + "queued_at": _utc_now_naive().isoformat(), + } + row = AIOrchestrationRun( + tenant_id=int(tenant_id), + trace_id=trace_id, + run_type=INGRESS_RUN_TYPE, + project_id=int(project_hint_id) if project_hint_id is not None else None, + signal_id=None, + response_mode="fast", + status="queued", + planner_status=None, + openclaw_status=None, + worker_total=1, + worker_completed=0, + worker_failed=0, + summary="消息已入异步处理队列。", + planner_diagnostics=None, + result_payload=result_payload, + created_by_user_id=None, + started_at=None, + completed_at=None, + source_system=str(source_system or "").strip()[:32] or None, + source_message_id=str(source_message_id or "").strip()[:128] or None, + sender=str(sender or "").strip()[:128] or "unknown", + source_sender_open_id=str(source_sender_open_id or "").strip()[:128] or None, + channel_ref=str(channel_ref or "").strip()[:128] or None, + chat_type=str(chat_type or "").strip()[:32] or None, + captured_at=_normalize_naive_datetime(captured_at), + message_text=str(message_text or "").strip()[:8000] or "", + project_hint_id=int(project_hint_id) if project_hint_id is not None else None, + retry_count=0, + max_retries=max(0, min(int(max_retries), 5)), + next_retry_at=_utc_now_naive(), + last_error=None, + last_error_at=None, + ack_sent_at=None, + result_notified_at=None, + work_item_id=None, + ) + self.db.add(row) + try: + await self.db.flush() + except IntegrityError: + await self.db.rollback() + existing = await self.get_run(tenant_id=tenant_id, trace_id=trace_id) + if existing is not None: + return existing, False + raise + return row, True + + async def claim_next_ingress_run(self, *, now: Optional[datetime] = None) -> Optional[AIOrchestrationRun]: + now_at = now or _utc_now_naive() + stale_before = now_at - timedelta(minutes=INGRESS_RUNNING_STALE_MINUTES) + stale_rows = ( + await self.db.execute( + select(AIOrchestrationRun) + .where( + and_( + AIOrchestrationRun.run_type == INGRESS_RUN_TYPE, + AIOrchestrationRun.status == "running", + AIOrchestrationRun.updated_at <= stale_before, + ) + ) + .with_for_update(skip_locked=True) + ) + ).scalars().all() + for stale in stale_rows: + if int(stale.retry_count or 0) < int(stale.max_retries or 0): + delay = INGRESS_RETRY_SCHEDULE_SECONDS[min(int(stale.retry_count or 0), len(INGRESS_RETRY_SCHEDULE_SECONDS) - 1)] + stale.retry_count = int(stale.retry_count or 0) + 1 + stale.status = "retrying" + stale.next_retry_at = now_at + timedelta(seconds=int(delay)) + else: + stale.status = "failed" + stale.completed_at = now_at + stale.next_retry_at = None + stale.last_error = "stale_running_recovered" + stale.last_error_at = now_at + + candidate_id = ( + await self.db.execute( + select(AIOrchestrationRun.id) + .where( + and_( + AIOrchestrationRun.run_type == INGRESS_RUN_TYPE, + or_( + and_( + AIOrchestrationRun.status.in_(("queued", "retrying")), + or_( + AIOrchestrationRun.next_retry_at.is_(None), + AIOrchestrationRun.next_retry_at <= now_at, + ), + ), + and_( + AIOrchestrationRun.status == "failed", + AIOrchestrationRun.last_error == "greenlet_spawn_error", + AIOrchestrationRun.retry_count < AIOrchestrationRun.max_retries, + or_( + AIOrchestrationRun.next_retry_at.is_(None), + AIOrchestrationRun.next_retry_at <= now_at, + ), + ), + ), + ) + ) + .order_by(AIOrchestrationRun.id.asc()) + .with_for_update(skip_locked=True) + .limit(1) + ) + ).scalar_one_or_none() + if candidate_id is None: + return None + + row = await self.get_run_by_id(run_id=int(candidate_id)) + if row is None: + return None + if str(row.status or "") == "failed" and str(row.last_error or "") == "greenlet_spawn_error": + row.retry_count = min( + int(row.max_retries or 0), + int(row.retry_count or 0) + 1, + ) + row.status = "running" + row.openclaw_status = "running" + row.started_at = row.started_at or now_at + row.next_retry_at = None + row.last_error = None + row.last_error_at = None + await self.db.flush() + return row + + async def mark_ingress_ack_sent( + self, + *, + tenant_id: int, + trace_id: str, + ) -> Optional[AIOrchestrationRun]: + row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id) + if row is None: + return None + row.ack_sent_at = _utc_now_naive() + await self.db.flush() + return row + + async def mark_ingress_succeeded( + self, + *, + run_id: int, + result_payload: dict[str, Any], + work_item_id: Optional[int], + summary: Optional[str] = None, + ) -> Optional[AIOrchestrationRun]: + row = await self.get_run_by_id(run_id=run_id) + if row is None: + return None + now_at = _utc_now_naive() + row.status = "succeeded" + row.openclaw_status = "succeeded" + row.worker_total = max(1, int(row.worker_total or 1)) + row.worker_completed = max(1, int(row.worker_completed or 0)) + row.worker_failed = max(0, int(row.worker_failed or 0)) + row.result_payload = result_payload if isinstance(result_payload, dict) else {} + if work_item_id is not None: + row.work_item_id = int(work_item_id) + row.summary = (str(summary or "").strip()[:1000] or "异步处理完成。") + row.completed_at = now_at + row.next_retry_at = None + row.last_error = None + row.last_error_at = None + await self.db.flush() + return row + + async def mark_ingress_failed_or_retrying( + self, + *, + run_id: int, + error_message: str, + ) -> tuple[Optional[AIOrchestrationRun], bool]: + # Failure path must not reuse a possibly committed/invalid caller session. + async with AsyncSessionLocal() as new_db: + row = ( + await new_db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.id == int(run_id)) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + if row is None: + await new_db.commit() + return None, False + + now_at = _utc_now_naive() + normalized_error = str(error_message or "").strip()[:1000] or "unknown_error" + retry_count = int(row.retry_count or 0) + max_retries = max(0, int(row.max_retries or 0)) + should_retry = retry_count < max_retries + if should_retry: + delay = INGRESS_RETRY_SCHEDULE_SECONDS[min(retry_count, len(INGRESS_RETRY_SCHEDULE_SECONDS) - 1)] + row.retry_count = retry_count + 1 + row.status = "retrying" + row.openclaw_status = "retrying" + row.next_retry_at = now_at + timedelta(seconds=int(delay)) + row.completed_at = None + row.worker_failed = max(0, int(row.worker_failed or 0)) + else: + row.status = "failed" + row.openclaw_status = "failed" + row.next_retry_at = None + row.completed_at = now_at + row.worker_failed = max(1, int(row.worker_failed or 0) + 1) + row.last_error = normalized_error + row.last_error_at = now_at + row.summary = ( + f"异步处理失败(已重试{int(row.retry_count or 0)}次)" + if should_retry + else f"异步处理失败(自动重试{max_retries}次后终止)" + ) + await new_db.flush() + await new_db.commit() + return row, (not should_retry) + + async def mark_ingress_result_notified( + self, + *, + run_id: int, + ) -> Optional[AIOrchestrationRun]: + row = await self.get_run_by_id(run_id=run_id) + if row is None: + return None + row.result_notified_at = _utc_now_naive() + await self.db.flush() + return row + async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]: return ( await self.db.execute( @@ -130,6 +431,7 @@ class AIOrchestrationRunService: select(AIOrchestrationRun).where( and_( AIOrchestrationRun.status.in_(("queued", "running")), + AIOrchestrationRun.run_type != INGRESS_RUN_TYPE, AIOrchestrationRun.started_at.is_not(None), AIOrchestrationRun.started_at < threshold, ) @@ -160,7 +462,7 @@ class AIOrchestrationRunService: return { "trace_id": str(row.trace_id or ""), "run_type": str(row.run_type or ""), - "project_id": int(row.project_id), + "project_id": int(row.project_id) if row.project_id is not None else None, "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 ""), @@ -174,6 +476,18 @@ class AIOrchestrationRunService: "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, + "source_system": str(row.source_system or ""), + "source_message_id": str(row.source_message_id or ""), + "sender": str(row.sender or ""), + "channel_ref": str(row.channel_ref or ""), + "project_hint_id": int(row.project_hint_id) if row.project_hint_id is not None else None, + "retry_count": int(row.retry_count or 0), + "max_retries": int(row.max_retries or 0), + "next_retry_at": row.next_retry_at.isoformat() if row.next_retry_at else None, + "last_error": str(row.last_error or ""), + "work_item_id": int(row.work_item_id) if row.work_item_id is not None else None, + "ack_sent_at": row.ack_sent_at.isoformat() if row.ack_sent_at else None, + "result_notified_at": row.result_notified_at.isoformat() if row.result_notified_at else None, "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/__init__.py b/backend/app/services/ai_pmo/__init__.py new file mode 100644 index 00000000..a880b216 --- /dev/null +++ b/backend/app/services/ai_pmo/__init__.py @@ -0,0 +1 @@ +"""AI PMO domain service namespace.""" diff --git a/backend/app/services/ai_pmo_cadence/__init__.py b/backend/app/services/ai_pmo/ai_pmo_cadence/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/__init__.py diff --git a/backend/app/services/ai_pmo_cadence/mixin_builders.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/mixin_builders.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py diff --git a/backend/app/services/ai_pmo_cadence/mixin_program_view.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_program_view.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/mixin_program_view.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/mixin_program_view.py diff --git a/backend/app/services/ai_pmo_cadence/mixin_queries_a.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_queries_a.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/mixin_queries_a.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/mixin_queries_a.py diff --git a/backend/app/services/ai_pmo_cadence/mixin_queries_b.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_queries_b.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/mixin_queries_b.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/mixin_queries_b.py diff --git a/backend/app/services/ai_pmo_cadence/mixin_snapshots.py b/backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/mixin_snapshots.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/mixin_snapshots.py diff --git a/backend/app/services/ai_pmo_cadence/service.py b/backend/app/services/ai_pmo/ai_pmo_cadence/service.py similarity index 100% rename from backend/app/services/ai_pmo_cadence/service.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/service.py diff --git a/backend/app/services/ai_pmo_cadence/shared.py b/backend/app/services/ai_pmo/ai_pmo_cadence/shared.py similarity index 93% rename from backend/app/services/ai_pmo_cadence/shared.py rename to backend/app/services/ai_pmo/ai_pmo_cadence/shared.py index e184e9ed..7ad44cf1 100644 --- a/backend/app/services/ai_pmo_cadence/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_cadence/shared.py @@ -8,7 +8,7 @@ from typing import Any, Optional from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import ( +from ....models import ( Budget, Notification, PortfolioSnapshot, @@ -22,7 +22,7 @@ from ...models import ( UserWorkload, WorkItem, ) -from ...utils.timezone import now_cn_naive +from ....utils.timezone import now_cn_naive def utc_now_naive() -> datetime: diff --git a/backend/app/services/ai_pmo_decision/__init__.py b/backend/app/services/ai_pmo/ai_pmo_decision/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_decision/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_decision/__init__.py diff --git a/backend/app/services/ai_pmo_decision/engine.py b/backend/app/services/ai_pmo/ai_pmo_decision/engine.py similarity index 100% rename from backend/app/services/ai_pmo_decision/engine.py rename to backend/app/services/ai_pmo/ai_pmo_decision/engine.py diff --git a/backend/app/services/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py similarity index 79% rename from backend/app/services/ai_pmo_decision/policy.py rename to backend/app/services/ai_pmo/ai_pmo_decision/policy.py index 6a485be2..c60effb0 100644 --- a/backend/app/services/ai_pmo_decision/policy.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os + from .shared import ( CanonicalIntent, INTENT_QUERY, @@ -25,6 +27,12 @@ class ExecutionPolicyBrain: intent: CanonicalIntent, simulation: dict[str, object], ) -> dict[str, object]: + warn_threshold = self._env_float("CAPACITY_WARN_THRESHOLD", 1.0) + block_threshold = self._env_float("CAPACITY_BLOCK_THRESHOLD", 1.5) + if block_threshold < warn_threshold: + block_threshold = warn_threshold + projected_capacity_ratio = float(simulation.get("projected_capacity_peak_ratio") or 0.0) + capacity_warning: dict[str, object] | None = None normalized = re.sub(r"\s+", "", str(message_text or "")).lower() for gate_code, tokens in self.policy_gate_rules: if any(token.lower().replace(" ", "") in normalized for token in tokens): @@ -84,7 +92,7 @@ class ExecutionPolicyBrain: }, } - if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0: + if projected_capacity_ratio >= block_threshold: return { "auto_execute": False, "policy_gate_hit": True, @@ -92,9 +100,17 @@ class ExecutionPolicyBrain: "approval_requirement": { "required": True, "reason": "simulation_capacity_limit", - "message": "执行前仿真显示产能会超载,自动执行被拦截。", + "message": "执行前仿真显示产能严重超载,自动执行被拦截。", }, } + if projected_capacity_ratio >= warn_threshold: + capacity_warning = { + "enabled": True, + "severity": "warning", + "peak_ratio": round(projected_capacity_ratio, 4), + "warn_threshold": round(warn_threshold, 4), + "block_threshold": round(block_threshold, 4), + } if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0: return { @@ -125,4 +141,15 @@ class ExecutionPolicyBrain: "policy_gate_hit": False, "policy_gate_code": None, "approval_requirement": {"required": False}, + "capacity_warning": capacity_warning, } + + @staticmethod + def _env_float(name: str, default: float) -> float: + raw = str(os.getenv(name, "")).strip() + if not raw: + return float(default) + try: + return float(raw) + except (TypeError, ValueError): + return float(default) diff --git a/backend/app/services/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py similarity index 100% rename from backend/app/services/ai_pmo_decision/shared.py rename to backend/app/services/ai_pmo/ai_pmo_decision/shared.py diff --git a/backend/app/services/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py similarity index 100% rename from backend/app/services/ai_pmo_decision/simulation.py rename to backend/app/services/ai_pmo/ai_pmo_decision/simulation.py diff --git a/backend/app/services/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py similarity index 100% rename from backend/app/services/ai_pmo_decision/strategic.py rename to backend/app/services/ai_pmo/ai_pmo_decision/strategic.py diff --git a/backend/app/services/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py similarity index 100% rename from backend/app/services/ai_pmo_decision/tactical.py rename to backend/app/services/ai_pmo/ai_pmo_decision/tactical.py diff --git a/backend/app/services/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_intent/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_intent/__init__.py diff --git a/backend/app/services/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py similarity index 99% rename from backend/app/services/ai_pmo_intent/intent_resolver.py rename to backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py index 1d369c3c..26a6a408 100644 --- a/backend/app/services/ai_pmo_intent/intent_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py @@ -202,7 +202,7 @@ class IntentResolver: return True return bool( re.search( - r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目", + r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目", normalized, re.IGNORECASE, ) @@ -212,4 +212,3 @@ class IntentResolver: def _build_signal_title(text: str, *, default: str) -> str: raw = str(text or "").strip() return raw or default - diff --git a/backend/app/services/ai_pmo_intent/models.py b/backend/app/services/ai_pmo/ai_pmo_intent/models.py similarity index 88% rename from backend/app/services/ai_pmo_intent/models.py rename to backend/app/services/ai_pmo/ai_pmo_intent/models.py index 1691afe5..12594d3e 100644 --- a/backend/app/services/ai_pmo_intent/models.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/models.py @@ -10,6 +10,7 @@ def _safe_int(value: Any) -> Optional[int]: except (TypeError, ValueError): return None +@dataclass class CanonicalIntent: name: str confidence: float @@ -18,6 +19,7 @@ class CanonicalIntent: signal_summary: str metadata: dict[str, Any] +@dataclass class WorkflowRoute: family: str action: str @@ -30,3 +32,5 @@ class WorkflowRoute: def key(self) -> str: return f"{self.family}.{self.action}" + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py similarity index 68% rename from backend/app/services/ai_pmo_intent/route_resolver.py rename to backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py index 135f32bc..43e146f1 100644 --- a/backend/app/services/ai_pmo_intent/route_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py @@ -45,49 +45,11 @@ class RouteResolver: return INTENT_FOLLOWUP_CONTINUE return INTENT_QUERY - @staticmethod - def _parse_chinese_count_token(token: str) -> Optional[int]: - cleaned = str(token or "").strip() - if not cleaned: - return None - map_digit = { - "零": 0, - "一": 1, - "二": 2, - "两": 2, - "三": 3, - "四": 4, - "五": 5, - "六": 6, - "七": 7, - "八": 8, - "九": 9, - "十": 10, - } - if cleaned in map_digit: - return map_digit[cleaned] - if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit: - return 10 + map_digit[cleaned[1]] - if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit: - return map_digit[cleaned[0]] * 10 - if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit: - return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]] - return None - @classmethod def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int: metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None if metadata_count is not None and metadata_count > 0: return min(max(metadata_count, 1), 5) - normalized_text = str(message_text or "") - digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE) - if digit_match: - return min(max(int(digit_match.group(1)), 1), 5) - chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text) - if chinese_match: - parsed = cls._parse_chinese_count_token(chinese_match.group(1)) - if parsed is not None and parsed > 0: - return min(max(parsed, 1), 5) return 1 @staticmethod @@ -95,22 +57,20 @@ class RouteResolver: explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: return True - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - has_discover_hint = any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS) - has_signal_hint = any(token in normalized for token in ("信号", "候选", "分析", "拆分")) - return has_discover_hint and has_signal_hint + explicit_action = str(metadata.get("workflow_action") or metadata.get("action") or "").strip().lower() if isinstance(metadata, dict) else "" + return explicit_action == ROUTE_ACTION_DISCOVER @staticmethod - def parse_followup_action(*, message_text: str, canonical_intent: str) -> str: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS): - return ROUTE_ACTION_REFRESH - if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS): - return ROUTE_ACTION_CANCEL - if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS): - return ROUTE_ACTION_COMMIT + def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str: + explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower() + if explicit_action in { + ROUTE_ACTION_CONTINUE, + ROUTE_ACTION_CONFIRM, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_REFRESH, + }: + return explicit_action if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return ROUTE_ACTION_COMMIT return ROUTE_ACTION_CONTINUE @@ -123,15 +83,17 @@ class RouteResolver: message_text: str, ) -> WorkflowRoute: metadata = dict(canonical_intent.metadata or {}) - route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) + explicit_route = cls.normalize_route_key(str(metadata.get("workflow_route") or "")) + route_key = explicit_route if explicit_route != ROUTE_QUERY_ANSWER or str(metadata.get("workflow_route") or "").strip() else "" + if not route_key: + route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) if canonical_intent.name == INTENT_SIGNAL_CREATE: - route_key = ( - ROUTE_PROJECT_SIGNAL_DISCOVER - if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata) - else ROUTE_PROJECT_SIGNAL_CREATE - ) + if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata): + route_key = ROUTE_PROJECT_SIGNAL_DISCOVER + elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}: + route_key = ROUTE_PROJECT_SIGNAL_CREATE elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - action = cls.parse_followup_action(message_text=message_text, canonical_intent=canonical_intent.name) + action = cls.parse_followup_action(metadata=metadata, canonical_intent=canonical_intent.name) if action == ROUTE_ACTION_CONFIRM: route_key = ROUTE_FOLLOWUP_CONFIRM else: @@ -215,4 +177,3 @@ class RouteResolver: requires_confirmation=requires_confirmation, metadata=metadata, ) - diff --git a/backend/app/services/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py similarity index 97% rename from backend/app/services/ai_pmo_intent/shared.py rename to backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 63732811..6df8a788 100644 --- a/backend/app/services/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -6,8 +6,8 @@ import re from dataclasses import dataclass from typing import Any, Optional -from ...utils.project_command_utils import extract_project_create_title -from ..signal_source_orchestration import SignalSourceOrchestrationService +from ....utils.project_command_utils import extract_project_create_title +from ...signal.signal_source_orchestration import SignalSourceOrchestrationService INTENT_QUERY = "query.read_only" INTENT_PROJECT_CREATE = "project.create" @@ -209,4 +209,3 @@ WRITE_INTENTS = { } INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} - diff --git a/backend/app/services/ai_pmo_kernel/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py similarity index 68% rename from backend/app/services/ai_pmo_kernel/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py index ff2667aa..7b90a183 100644 --- a/backend/app/services/ai_pmo_kernel/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py @@ -7,7 +7,14 @@ from .ai_agent_kernel import ( select_intent_route, ) from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow -from .ai_pmo_kernel_runtime import AIPMOKernelService + + +def __getattr__(name: str): + if name == "AIPMOKernelService": + from .ai_pmo_kernel_runtime import AIPMOKernelService + + return AIPMOKernelService + raise AttributeError(name) __all__ = [ "AIPMOKernelService", diff --git a/backend/app/services/ai_pmo_kernel/ai_agent_kernel.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py similarity index 100% rename from backend/app/services/ai_pmo_kernel/ai_agent_kernel.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_domain_workflow.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_domain_workflow.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py index 6dc6bfee..317aeacc 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_domain_workflow.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py @@ -6,7 +6,7 @@ from typing import Any, Optional from sqlalchemy.ext.asyncio import AsyncSession -from ..signal_source_orchestration import SignalSourceOrchestrationService +from ...signal.signal_source_orchestration import SignalSourceOrchestrationService class SignalSourceDomainWorkflow: diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_kernel_runtime.py similarity index 86% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_runtime.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_kernel_runtime.py index dd1aecb7..326f98fc 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_runtime.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_kernel_runtime.py @@ -8,11 +8,11 @@ for _name, _value in vars(_common).items(): if not _name.startswith("__"): globals()[_name] = _value -from .ai_pmo_kernel_mixins_closure import KernelClosureMixin -from .ai_pmo_kernel_mixins_followup import KernelFollowupMixin -from .ai_pmo_kernel_mixins_ingress import KernelIngressMixin -from .ai_pmo_kernel_mixins_intent import KernelIntentMixin -from .ai_pmo_kernel_mixins_world_model import KernelWorldModelMixin +from .compositions.closure import KernelClosureMixin +from .compositions.followup import KernelFollowupMixin +from .compositions.ingress import KernelIngressMixin +from .compositions.intent import KernelIntentMixin +from .compositions.world_model import KernelWorldModelMixin class AIPMOKernelService( @@ -54,4 +54,3 @@ class AIPMOKernelService( return async_sessionmaker(bind=bind, expire_on_commit=False, class_=AsyncSession) except Exception: return None - diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py similarity index 100% rename from backend/app/services/ai_pmo_kernel/ai_pmo_reply_renderer.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_review_service.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_review_service.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_review_service.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_review_service.py index 1e70065e..bf589f61 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_review_service.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_review_service.py @@ -7,7 +7,7 @@ from typing import Any from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem +from ....models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem def _safe_int(value: Any) -> int | None: diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor.py similarity index 100% rename from backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py similarity index 82% rename from backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_common.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index 4193d2bc..4ecd2740 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -11,9 +11,9 @@ from typing import Any, Optional from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...config.ai_prompts import PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS -from ...core.logging import get_logger -from ...models import ( +from ....config.ai_prompts import PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS +from ....core.logging import get_logger +from ....models import ( Experiment, Project, ProjectSignalLink, @@ -23,8 +23,8 @@ from ...models import ( UserSkillBinding, UserWorkload, ) -from ...utils.timezone import now_cn_naive -from ..adaptive_rag_service import AdaptiveRAGService +from ....utils.timezone import now_cn_naive +from ...adaptive_rag_service import AdaptiveRAGService from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from ..ai_pmo_intent import ( INTENT_PROJECT_CREATE, @@ -40,10 +40,10 @@ from ..ai_pmo_intent import ( IntentResolver, RouteResolver, ) -from ..ai_steward_service import AIStewardService -from ..llm_service import llm_service -from ..model_context_bundle import ModelContextBundleService -from ..signal_planner import SignalPlannerService +from ...ai_steward_service import AIStewardService +from ...llm_service import llm_service +from ...model.model_context_bundle import ModelContextBundleService +from ...signal.signal_planner import SignalPlannerService logger = get_logger(__name__) @@ -75,5 +75,3 @@ def _safe_int(value: Any) -> Optional[int]: return int(value) except (TypeError, ValueError): return None - - diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py similarity index 100% rename from backend/app/services/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_workflow_runtime.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py index 12d54927..e423845b 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_workflow_runtime.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING -from ...models import ConversationSession, IngestEvent, Project, WorkItem -from ...schemas.world_model import WorldModel +from ....models import ConversationSession, IngestEvent, Project, WorkItem +from ....schemas.world_model import WorldModel from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, ROUTE_ACTION_DISCOVER, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/__init__.py new file mode 100644 index 00000000..7a3aec4f --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/__init__.py @@ -0,0 +1,15 @@ +"""Kernel composition mixins grouped by domain.""" + +from .closure import KernelClosureMixin +from .followup import KernelFollowupMixin +from .ingress import KernelIngressMixin +from .intent import KernelIntentMixin +from .world_model import KernelWorldModelMixin + +__all__ = [ + "KernelClosureMixin", + "KernelFollowupMixin", + "KernelIngressMixin", + "KernelIntentMixin", + "KernelWorldModelMixin", +] diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/closure.py similarity index 51% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/compositions/closure.py index dfe8e169..48aa38cc 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/closure.py @@ -2,9 +2,9 @@ from __future__ import annotations -from .ai_pmo_kernel_mixins_closure_data import KernelClosureDataMixin -from .ai_pmo_kernel_mixins_closure_helpers import KernelClosureHelpersMixin -from .ai_pmo_kernel_mixins_closure_payload import KernelClosurePayloadMixin +from ..mixins.closure.ai_pmo_kernel_mixins_closure_data import KernelClosureDataMixin +from ..mixins.closure.ai_pmo_kernel_mixins_closure_helpers import KernelClosureHelpersMixin +from ..mixins.closure.ai_pmo_kernel_mixins_closure_payload import KernelClosurePayloadMixin class KernelClosureMixin( diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/followup.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/followup.py new file mode 100644 index 00000000..b53d32e4 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/followup.py @@ -0,0 +1,23 @@ +"""AI PMO kernel followup mixin composition.""" + +from __future__ import annotations + +from ..mixins.followup.ai_pmo_kernel_mixins_followup_project_signal import ( + KernelFollowupProjectSignalMixin, +) +from ..mixins.followup.ai_pmo_kernel_mixins_followup_records import KernelFollowupRecordsMixin +from ..mixins.followup.ai_pmo_kernel_mixins_followup_routing import KernelFollowupRoutingMixin +from ..mixins.followup.ai_pmo_kernel_mixins_followup_verification import ( + KernelFollowupVerificationMixin, +) + + +class KernelFollowupMixin( + KernelFollowupRecordsMixin, + KernelFollowupRoutingMixin, + KernelFollowupProjectSignalMixin, + KernelFollowupVerificationMixin, +): + """Compose followup concerns into a single kernel mixin.""" + + pass diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/ingress.py similarity index 52% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/compositions/ingress.py index 0fc079fc..08f26867 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/ingress.py @@ -2,9 +2,9 @@ from __future__ import annotations -from ..ai_pmo_kernel_ingress_flow import KernelIngressFlowMixin -from .ai_pmo_kernel_mixins_ingress_render import KernelIngressRenderMixin -from .ai_pmo_kernel_mixins_ingress_storage import KernelIngressStorageMixin +from ...ai_pmo_kernel_ingress_flow import KernelIngressFlowMixin +from ..mixins.ingress.ai_pmo_kernel_mixins_ingress_render import KernelIngressRenderMixin +from ..mixins.ingress.ai_pmo_kernel_mixins_ingress_storage import KernelIngressStorageMixin class KernelIngressMixin( diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py similarity index 52% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py index 87ee2a5f..37051ec0 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py @@ -2,9 +2,9 @@ from __future__ import annotations -from .ai_pmo_kernel_mixins_intent_llm import KernelIntentLLMMixin -from .ai_pmo_kernel_mixins_intent_resolve import KernelIntentResolveMixin -from .ai_pmo_kernel_mixins_intent_rules import KernelIntentRulesMixin +from ..mixins.intent.ai_pmo_kernel_mixins_intent_llm import KernelIntentLLMMixin +from ..mixins.intent.ai_pmo_kernel_mixins_intent_resolve import KernelIntentResolveMixin +from ..mixins.intent.ai_pmo_kernel_mixins_intent_rules import KernelIntentRulesMixin class KernelIntentMixin( diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py similarity index 53% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py index 306bc70f..4e9f5952 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py @@ -2,9 +2,11 @@ from __future__ import annotations -from ..ai_pmo_kernel_world_model_aggregates import KernelWorldModelAggregatesMixin -from ..ai_pmo_kernel_world_model_core import KernelWorldModelCoreMixin -from .ai_pmo_kernel_mixins_world_model_session import KernelWorldModelSessionMixin +from ...ai_pmo_kernel_world_model_aggregates import KernelWorldModelAggregatesMixin +from ...ai_pmo_kernel_world_model_core import KernelWorldModelCoreMixin +from ..mixins.world_model.ai_pmo_kernel_mixins_world_model_session import ( + KernelWorldModelSessionMixin, +) class KernelWorldModelMixin( diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/__init__.py new file mode 100644 index 00000000..f7019d08 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/__init__.py @@ -0,0 +1 @@ +"""AI PMO kernel mixin groups.""" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/__init__.py new file mode 100644 index 00000000..2c29b535 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/__init__.py @@ -0,0 +1 @@ +"""Closure-oriented kernel mixins.""" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_data.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_data.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py index 34cf7b72..c8a2adae 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_data.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_helpers.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_helpers.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_helpers.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_helpers.py index 542cdc0d..e7e82003 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_payload.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_payload.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py index 798a0beb..413697a4 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_payload.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/__init__.py new file mode 100644 index 00000000..312f05c8 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/__init__.py @@ -0,0 +1 @@ +"""Followup-oriented kernel mixins.""" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal.py similarity index 100% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_actions.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_actions.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_actions.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_actions.py index b48ad7d5..c5472665 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_actions.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_actions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_resume.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py index ab367700..e50196ed 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_project_signal_resume.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_records.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_records.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_records.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_records.py index ded6b3c3..c70c9d0f 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_records.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_records.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_routing.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_routing.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py index 98b9c92e..a9ee459f 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_routing.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_verification.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index 3a97429b..a78fa435 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/__init__.py new file mode 100644 index 00000000..1c1f140a --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/__init__.py @@ -0,0 +1 @@ +"""Ingress-oriented kernel mixins.""" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py similarity index 93% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_render.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index 9950ee77..5dbc9782 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -23,6 +23,16 @@ class KernelIngressRenderMixin: ) -> dict[str, Any]: linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else [] direct_reply_text = str(render.get("text") or "").strip() + mixed_sidecar_reply = ( + str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip() + if isinstance(intent.metadata, dict) + else "" + ) + if mixed_sidecar_reply: + if direct_reply_text and direct_reply_text != mixed_sidecar_reply: + direct_reply_text = f"{mixed_sidecar_reply}\n\n{direct_reply_text}" + else: + direct_reply_text = mixed_sidecar_reply if _looks_like_upstream_timeout_message(direct_reply_text): logger.warning( "ai_pmo_upstream_timeout_reply_detected", @@ -169,4 +179,3 @@ class KernelIngressRenderMixin: "status": str(project.status or ""), "owner_id": _safe_int(project.owner_id), } - diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py similarity index 99% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_storage.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py index 36055cbe..2e2cc63d 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_storage.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/__init__.py new file mode 100644 index 00000000..ae5970f8 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/__init__.py @@ -0,0 +1 @@ +"""Intent-oriented kernel mixins.""" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py similarity index 79% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_llm.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py index 6682a219..583fe361 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_llm.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -10,6 +10,8 @@ for _name, _value in vars(_common).items(): class KernelIntentLLMMixin: + _STAGE2_INTENT_FUNCTION_NAME = "classify_intent_complex" + async def _resolve_intent_with_llm( self, *, @@ -18,17 +20,6 @@ class KernelIntentLLMMixin: world_model: dict[str, Any], rule_intent: Optional[CanonicalIntent] = None, ) -> Optional[CanonicalIntent]: - project_snapshot = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} - project_names = [ - str(project_snapshot.get("title") or "").strip(), - str(project_snapshot.get("name") or "").strip(), - ] - route_decision = select_intent_route(message_text, project_names=project_names) - correction_decision = kernel_resolve_correction(message_text) - forced_low_tier = _should_force_low_tier_intent_route( - message_text=message_text, - correction_action=correction_decision.action, - ) prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format( message_text=str(message_text or "")[:1200], session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "", @@ -48,6 +39,10 @@ class KernelIntentLLMMixin: ).strip() total_budget_seconds = _intent_llm_total_budget_seconds() stage_started_at = time.perf_counter() + stage1_timeout_cap_seconds = max( + 6.0, + float(os.getenv("AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS", "8") or 8.0), + ) async def _invoke_intent_stage( *, @@ -62,6 +57,19 @@ class KernelIntentLLMMixin: llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0)) if llm_timeout_seconds <= 0.0: return None + max_tokens_default = 260 if route_tier == "low" else 360 + max_tokens_env_key = ( + "AI_PMO_INTENT_LLM_MAX_TOKENS_LOW" + if route_tier == "low" + else "AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH" + ) + try: + llm_max_tokens = int( + str(os.getenv(max_tokens_env_key, str(max_tokens_default)) or max_tokens_default).strip() + ) + except Exception: + llm_max_tokens = max_tokens_default + llm_max_tokens = max(120, min(llm_max_tokens, 2800)) logger.info( "ai_pmo_intent_llm_route", tenant_id=self.tenant_id, @@ -69,9 +77,7 @@ class KernelIntentLLMMixin: function_name=function_name, route_tier=route_tier, timeout_seconds=llm_timeout_seconds, - forced_low_tier=forced_low_tier, - correction_action=correction_decision.action, - correction_keywords=correction_decision.matched_keywords, + max_tokens=llm_max_tokens, route_reasons=route_reasons, total_budget_seconds=total_budget_seconds, ) @@ -84,7 +90,7 @@ class KernelIntentLLMMixin: function_name=function_name, use_prompt_profile=False, temperature=0.0, - max_tokens=260, + max_tokens=llm_max_tokens, response_mode="fast", ), timeout=llm_timeout_seconds, @@ -147,8 +153,8 @@ class KernelIntentLLMMixin: route_function=function_name, gate_action="unresolved", gate_code="intent_only", - correction_action=correction_decision.action, - correction_keywords=correction_decision.matched_keywords, + correction_action="none", + correction_keywords=[], complexity_reasons=route_reasons, ) return self._build_canonical_intent( @@ -164,11 +170,10 @@ class KernelIntentLLMMixin: "route_tier": route_tier, "route_complex": route_tier == "high", "route_reasons": route_reasons, - "forced_low_tier": forced_low_tier, "timeout_seconds": llm_timeout_seconds, "total_budget_seconds": total_budget_seconds, - "correction_action": correction_decision.action, - "correction_keywords": correction_decision.matched_keywords, + "correction_action": "none", + "correction_keywords": [], "decision_trace": decision_trace, "workflow_route": structured_route.key if structured_route is not None else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER), "workflow_family": structured_route.family if structured_route is not None else RouteResolver._split_route_key( @@ -190,25 +195,39 @@ class KernelIntentLLMMixin: }, ) - low_stage_reasons = ["stage1_low_first", *list(route_decision.reasons or [])] - if forced_low_tier: - low_stage_reasons.append("correction_forced_low_tier") + low_stage_reasons = ["llm_only_stage1"] low_intent = await _invoke_intent_stage( function_name=INTENT_LLM_FUNCTION_NAME, route_tier="low", route_reasons=low_stage_reasons, stage_label="stage1_low", + timeout_cap_seconds=stage1_timeout_cap_seconds, ) - if forced_low_tier: - return low_intent - should_escalate, escalate_reasons = _should_escalate_to_high_intent_route( - route_is_complex=bool(route_decision.is_complex), - low_intent=low_intent, - rule_intent=rule_intent, - ) + escalate_reasons: list[str] = [] + if low_intent is None: + escalate_reasons.append("stage1_empty") + elif float(low_intent.confidence or 0.0) < _intent_llm_stage2_confidence_threshold(): + escalate_reasons.append("stage1_low_confidence") + if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""): + escalate_reasons.append("stage1_rule_conflict") + should_escalate = bool(escalate_reasons) if not should_escalate: return low_intent + stage2_enabled = str(os.getenv("AI_PMO_INTENT_STAGE2_ENABLED", "true") or "true").strip().lower() in BOOL_TRUE_VALUES + if low_intent is not None and not stage2_enabled: + logger.info( + "ai_pmo_intent_stage2_skipped", + tenant_id=self.tenant_id, + reason="stage2_disabled", + escalate_reasons=escalate_reasons, + ) + return self._copy_intent_with_metadata( + low_intent, + stage2_skipped=True, + stage2_skip_reason="stage2_disabled", + stage2_reasons=escalate_reasons, + ) elapsed_seconds = time.perf_counter() - stage_started_at remaining_budget_seconds = total_budget_seconds - elapsed_seconds @@ -231,9 +250,9 @@ class KernelIntentLLMMixin: ) return None - high_stage_reasons = [*list(route_decision.reasons or []), *escalate_reasons, "stage2_high_retry"] + high_stage_reasons = [*escalate_reasons, "stage2_high_retry"] high_intent = await _invoke_intent_stage( - function_name="classify_intent_complex", + function_name=self._STAGE2_INTENT_FUNCTION_NAME, route_tier="high", route_reasons=high_stage_reasons, stage_label="stage2_high", @@ -256,4 +275,3 @@ class KernelIntentLLMMixin: stage2_reasons=escalate_reasons, ) return None - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py new file mode 100644 index 00000000..0fd48b78 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -0,0 +1,51 @@ +"""AI PMO kernel intent resolve mixin.""" + +from __future__ import annotations + +from .... import ai_pmo_kernel_common_core as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value + + +class KernelIntentResolveMixin: + async def _resolve_intent( + self, + *, + message_text: str, + session: Optional[ConversationSession], + world_model: dict[str, Any], + ) -> tuple[CanonicalIntent, str]: + if self._is_short_confirmation_message( + message_text=message_text, + session=session, + ) and session is not None: + inherited = self._build_short_confirm_intent( + message_text=message_text, + session=session, + world_model=world_model, + ) + logger.info( + "ai_pmo_short_confirm_inherited", + tenant_id=self.tenant_id, + sender=str(getattr(session, "sender", "") or ""), + inherited_from=str(getattr(session, "last_intent", "") or ""), + inherited_intent=str(inherited.name or ""), + project_id=_safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")), + ) + return inherited, "session_inherited" + + # 模型唯一入口:不再使用规则/正则意图分流。 + if not self._intent_llm_enabled(): + raise RuntimeError("intent_model_only_mode_requires_llm_enabled") + + llm_intent = await self._resolve_intent_with_llm( + message_text=message_text, + session=session, + world_model=world_model, + rule_intent=None, + ) + if llm_intent is None: + raise RuntimeError("intent_model_only_no_valid_llm_intent") + return llm_intent, "llm_only" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py similarity index 71% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_rules.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index 273df9b0..36d430db 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_rules.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -10,6 +10,42 @@ for _name, _value in vars(_common).items(): class KernelIntentRulesMixin: + CONTEXTUAL_CONFIRM_HINTS = ( + "来吧", + "那就", + "就这么办", + "就按这个", + "可以", + "好的", + "不错", + "开始吧", + "行", + "没问题", + "同意", + "确认", + ) + CONTEXTUAL_DEICTIC_HINTS = ( + "这个", + "这个项目", + "这条", + "它", + "按这个", + "照这个", + ) + CONTEXTUAL_WRITE_ACTION_HINTS = ( + "创建", + "新建", + "新增", + "增加", + "添加", + "执行", + "开始", + "继续处理", + "继续执行", + "推进", + "落地", + ) + @staticmethod def _intent_llm_enabled() -> bool: return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True) @@ -57,6 +93,80 @@ class KernelIntentRulesMixin: metadata = intent.metadata if isinstance(intent.metadata, dict) else {} return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback" + @staticmethod + def _has_question_structure(*, normalized: str) -> bool: + if not normalized: + return False + return ( + "?" in normalized + or "?" in normalized + or any(token in normalized for token in ("怎么", "如何", "为何", "为什么", "是什么", "有哪些", "吗", "呢", "是否", "是不是")) + ) + + @classmethod + def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if not normalized: + return False + if cls._has_question_structure(normalized=normalized): + return False + has_write_action = any(token in normalized for token in cls.CONTEXTUAL_WRITE_ACTION_HINTS) + if not has_write_action: + return False + has_confirm = any(token in normalized for token in cls.CONTEXTUAL_CONFIRM_HINTS) + has_deictic = any(token in normalized for token in cls.CONTEXTUAL_DEICTIC_HINTS) + return has_confirm or has_deictic + + def _recover_session_followup_write_intent( + self, + *, + message_text: str, + session: Optional[ConversationSession], + world_model: dict[str, Any], + rule_intent: CanonicalIntent, + ) -> Optional[CanonicalIntent]: + if session is None: + return None + if rule_intent.name != INTENT_QUERY: + return None + if not self._within_recent_window( + getattr(session, "last_active_at", None), + window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, + ): + return None + if not (self._is_default_rule_query(rule_intent) or float(rule_intent.confidence or 0.0) <= 0.7): + return None + if not self._looks_like_contextual_write_confirmation(message_text=message_text): + return None + + inherited_name = str(getattr(session, "last_intent", "") or "").strip() + inherited_text = str(getattr(session, "last_message_text", "") or "").strip() + if inherited_name not in WRITE_INTENTS and inherited_text: + inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) + inferred = self.intent_resolver.resolve(text=inherited_text, project_id=inherited_project_id) + if inferred.name in WRITE_INTENTS: + inherited_name = inferred.name + + if inherited_name not in WRITE_INTENTS: + return None + + project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")) + if project_id is not None and int(project_id) <= 0: + project_id = None + + canonical_text = inherited_text or message_text + return self._build_canonical_intent( + intent_name=inherited_name, + message_text=canonical_text, + project_id=project_id, + confidence=0.84, + metadata={ + "context_source": "session_write_recovered", + "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(), + "trigger_message": re.sub(r"\s+", " ", str(message_text or "")).strip()[:120], + }, + ) + @staticmethod def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: if intent.name != INTENT_PROJECT_CREATE: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/__init__.py new file mode 100644 index 00000000..04db0d45 --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/__init__.py @@ -0,0 +1 @@ +"""World-model-oriented kernel mixins.""" diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py similarity index 98% rename from backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model_session.py rename to backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py index d4d9de5c..cbe121b4 100644 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model_session.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai_pmo_kernel_common_core as _common +from .... import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): diff --git a/backend/app/services/ai_pmo_kernel_common_core/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_common_core/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_common_core/__init__.py diff --git a/backend/app/services/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py similarity index 94% rename from backend/app/services/ai_pmo_kernel_common_core/constants.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py index 1ea320da..b891b50e 100644 --- a/backend/app/services/ai_pmo_kernel_common_core/constants.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py @@ -52,9 +52,9 @@ POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60 INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0 -INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0 -INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0 -INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0 +INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 55.0 +INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 45.0 +INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 70.0 INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72 INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0 diff --git a/backend/app/services/ai_pmo_kernel_common_core/env.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py similarity index 95% rename from backend/app/services/ai_pmo_kernel_common_core/env.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py index 9c87a897..17274eb3 100644 --- a/backend/app/services/ai_pmo_kernel_common_core/env.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py @@ -48,7 +48,7 @@ def intent_llm_timeout_seconds(*, function_name: str) -> float: else: default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS configured = env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout) - return min(max(float(configured), 12.0), 120.0) + return min(max(float(configured), 12.0), 300.0) def intent_llm_total_budget_seconds() -> float: @@ -56,7 +56,7 @@ def intent_llm_total_budget_seconds() -> float: "AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS, ) - return min(max(float(configured), 20.0), 120.0) + return min(max(float(configured), 20.0), 300.0) def intent_llm_stage2_confidence_threshold() -> float: diff --git a/backend/app/services/ai_pmo_kernel_common_core/helpers.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py similarity index 97% rename from backend/app/services/ai_pmo_kernel_common_core/helpers.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py index 023869fa..017d0dfd 100644 --- a/backend/app/services/ai_pmo_kernel_common_core/helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py @@ -108,3 +108,6 @@ def _normalize_signal_id_list(value: Any) -> list[int]: seen.add(signal_id) ids.append(signal_id) return ids + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py similarity index 84% rename from backend/app/services/ai_pmo_kernel_common_core/imports.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index 0e3e7c78..fa2bc2cf 100644 --- a/backend/app/services/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -15,12 +15,12 @@ from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from ...config.ai_prompts import ( +from ....config.ai_prompts import ( PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, ) -from ...core.logging import get_logger -from ...models import ( +from ....core.logging import get_logger +from ....models import ( AIProcessingLog, Budget, ConversationSession, @@ -46,7 +46,7 @@ from ...models import ( UserWorkload, WorkItem, ) -from ...schemas.world_model import ( +from ....schemas.world_model import ( BlockedTask, BudgetSummary, CapacityItem, @@ -61,8 +61,8 @@ from ...schemas.world_model import ( TaskSummary, WorldModel, ) -from ...utils.timezone import now_cn_naive -from ..adaptive_rag_service import AdaptiveRAGService +from ....utils.timezone import now_cn_naive +from ...adaptive_rag_service import AdaptiveRAGService from ..ai_pmo_kernel import ( build_decision_trace, gate_execution as kernel_gate_execution, @@ -123,11 +123,11 @@ from ..ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer from ..ai_pmo_kernel.ai_pmo_review_service import AIPMOReviewService from ..ai_pmo_kernel.ai_pmo_typed_executor import TypedExecutor from ..ai_pmo_kernel.ai_pmo_workflow_runtime import ContextAssembler, WorkflowRegistry -from ..ai_steward_service import AIStewardService -from ..context_cache_service import ContextCacheService -from ..llm_service import llm_service -from ..model_context_bundle import ModelContextBundleService -from ..notification_service import NotificationService -from ..signal_planner import SignalPlannerService +from ...ai_steward_service import AIStewardService +from ...context_cache_service import ContextCacheService +from ...llm_service import llm_service +from ...model.model_context_bundle import ModelContextBundleService +from ...notification_service import NotificationService +from ...signal.signal_planner import SignalPlannerService logger = get_logger(__name__) diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/__init__.py diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py similarity index 79% rename from backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index e29fb2b6..293a0ee9 100644 --- a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -106,6 +106,17 @@ class KernelIngressFlowAutoMixin: }, execution_result=execution_result, ) + capacity_warning_text = self._build_capacity_warning_text( + policy=decision_payload["policy"] if isinstance(decision_payload.get("policy"), dict) else {}, + world_model=world_model, + execution_result=execution_result if isinstance(execution_result, dict) else {}, + ) + if capacity_warning_text: + base_text = str(render.get("text") or "").strip() + render["text"] = f"{base_text}\n\n{capacity_warning_text}" if base_text else capacity_warning_text + payload = render.get("payload") + if isinstance(payload, dict): + payload["capacity_warning_text"] = capacity_warning_text work_item.reply_template_key = str(render["template_key"]) direct_reply_required = await self._claim_reply_receipt( work_item_id=int(work_item.id), @@ -185,3 +196,41 @@ class KernelIngressFlowAutoMixin: log_perf_fn("_build_capture_result", stage_started_at) return capture_result + @staticmethod + def _build_capacity_warning_text( + *, + policy: dict[str, Any], + world_model: dict[str, Any], + execution_result: dict[str, Any], + ) -> Optional[str]: + warning = policy.get("capacity_warning") if isinstance(policy.get("capacity_warning"), dict) else {} + if not bool(warning.get("enabled")): + return None + + project_name = "当前项目" + world_project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} + execution_project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {} + for source in (world_project, execution_project): + candidate = str(source.get("title") or source.get("name") or "").strip() + if candidate: + project_name = candidate + break + + peak_ratio = 0.0 + try: + peak_ratio = float(warning.get("peak_ratio") or 0.0) + except (TypeError, ValueError): + peak_ratio = 0.0 + peak_percent = max(0.0, peak_ratio * 100.0) + if abs(peak_percent - round(peak_percent)) < 0.05: + percent_text = f"{int(round(peak_percent))}%" + else: + percent_text = f"{peak_percent:.1f}%" + + return ( + "⚠ 产能提示:\n" + f"当前{project_name}团队负载已达{percent_text}\n" + "建议:\n" + "· 为项目增加成员\n" + "· 或调整现有任务优先级" + ) diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py similarity index 73% rename from backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 309a03f8..39f873c1 100644 --- a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -3,6 +3,89 @@ from __future__ import annotations from .shared import * class KernelIngressFlowEntryMixin: + _MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问") + _MIXED_HINT_QUESTION = ("?", "?", "问下", "多少", "几个", "进展", "状态", "吗", "呢", "如何", "怎么", "有没有") + _MIXED_HINT_REQUIREMENT = ("创建", "新建", "新增", "安排", "执行", "处理", "接入", "录入", "添加") + + @classmethod + def _looks_like_mixed_candidate(cls, *, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + has_question = any(token in normalized for token in cls._MIXED_HINT_QUESTION) + has_requirement = any(token in normalized for token in cls._MIXED_HINT_REQUIREMENT) + if not (has_question and has_requirement): + return False + if any(token in normalized for token in cls._MIXED_HINT_CONNECTORS): + return True + return "问" in normalized and "创建" in normalized + + @staticmethod + def _supports_mixed_sidecar(*, source_system: str) -> bool: + return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"} + + async def _prepare_mixed_sidecar_context( + self, + *, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + message_text: str, + project_hint_id: Optional[int], + ) -> tuple[str, dict[str, Any]]: + if not self._supports_mixed_sidecar(source_system=source_system): + return str(message_text or "").strip(), {} + if not self._looks_like_mixed_candidate(text=message_text): + return str(message_text or "").strip(), {} + + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + try: + intent_result = await rag_service.classify_intent(str(message_text or "").strip()) + except Exception as exc: + logger.warning( + "ai_pmo_mixed_precheck_failed", + tenant_id=self.tenant_id, + source_system=source_system, + error=str(exc), + ) + return str(message_text or "").strip(), {} + + intent_label = str(intent_result.get("intent") or "").strip().lower() + if intent_label != "mixed": + return str(message_text or "").strip(), {} + + question_part = str(intent_result.get("question_part") or "").strip() or str(message_text or "").strip() + signal_part = str(intent_result.get("statement_part") or "").strip() or str(message_text or "").strip() + sidecar_answer = "" + try: + rag_result = await rag_service.answer_question( + question=question_part, + asked_by=None, + asked_via=source_system, + project_id=project_hint_id, + source_chat_id=channel_ref, + conversation_key=source_message_id, + message_intent="mixed", + mixed_signal_part=signal_part, + ) + sidecar_answer = str(rag_result.get("answer") or "").strip() + except Exception as exc: + logger.warning( + "ai_pmo_mixed_sidecar_answer_failed", + tenant_id=self.tenant_id, + source_system=source_system, + error=str(exc), + ) + + mixed_meta: dict[str, Any] = { + "mixed_intent": True, + "mixed_question_part": question_part[:500], + "mixed_signal_part": signal_part[:800], + } + if sidecar_answer: + mixed_meta["mixed_sidecar_reply"] = sidecar_answer[:3200] + return signal_part, mixed_meta + async def process_feishu_message( self, *, @@ -70,6 +153,18 @@ class KernelIngressFlowEntryMixin: operator = ingress_prepare["operator"] work_item = ingress_prepare["work_item"] session = ingress_prepare["session"] + mixed_meta: dict[str, Any] = {} + if not self._is_short_confirmation_message( + message_text=clean_text, + session=session, + ): + clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + message_text=clean_text, + project_hint_id=project_hint_id, + ) perf_work_item_id = int(work_item.id) try: stage_started_at = time.perf_counter() @@ -93,18 +188,11 @@ class KernelIngressFlowEntryMixin: message_text=clean_text, closure_payload=closure_payload, ) - if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text): - metadata = dict(intent.metadata or {}) - metadata["guard_reason"] = "negated_write_statement" - metadata["guarded_from_intent"] = intent.name - intent = CanonicalIntent( - INTENT_QUERY, - max(float(intent.confidence or 0.0), 0.9), - intent.project_id, - "", - clean_text[:300], - metadata, - ) + if mixed_meta: + intent.metadata = { + **(intent.metadata or {}), + **mixed_meta, + } workflow_route = self.route_resolver.from_canonical( canonical_intent=intent, message_text=clean_text, @@ -129,10 +217,7 @@ class KernelIngressFlowEntryMixin: world_model["context_source"] = intent_context_source work_item.context_snapshot = world_model resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id")) - fallback_intent = self.intent_resolver.resolve( - text=clean_text, - project_id=_safe_int(world_model.get("resolved_project_id")), - ) + fallback_intent = intent if intent.name == INTENT_IGNORE: return await self._finalize_ignored_ingress( work_item=work_item, @@ -273,4 +358,3 @@ class KernelIngressFlowEntryMixin: error=str(exc), ) return {"handled": False, "reason": "kernel_exception", "error": str(exc)} - diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/service.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/service.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_ingress_flow/service.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/service.py diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/shared.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/shared.py similarity index 78% rename from backend/app/services/ai_pmo_kernel_ingress_flow/shared.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/shared.py index 2b68a92a..8d1de1e5 100644 --- a/backend/app/services/ai_pmo_kernel_ingress_flow/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/shared.py @@ -9,3 +9,5 @@ for _name, _value in vars(_common).items(): globals()[_name] = _value +__all__ = [name for name in globals() if not name.startswith("__")] + diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/__init__.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/mixin_basics.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/mixin_basics.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/mixin_project.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/mixin_project.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/service.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/service.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/shared.py similarity index 79% rename from backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/shared.py index 90514154..b2ad655c 100644 --- a/backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/shared.py @@ -7,3 +7,6 @@ from .. import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): globals()[_name] = _value + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_core/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/__init__.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_cache.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_cache.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/service.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/service.py similarity index 100% rename from backend/app/services/ai_pmo_kernel_world_model_core/service.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/service.py diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/shared.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/shared.py similarity index 78% rename from backend/app/services/ai_pmo_kernel_world_model_core/shared.py rename to backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/shared.py index e404b302..df3098ab 100644 --- a/backend/app/services/ai_pmo_kernel_world_model_core/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/shared.py @@ -7,3 +7,6 @@ from .. import ai_pmo_kernel_common_core as _common for _name, _value in vars(_common).items(): if not _name.startswith("__"): globals()[_name] = _value + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/__init__.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/__init__.py diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_discover.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_discover.py diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/service.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/service.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/service.py diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/shared.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/shared.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_discovery/shared.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/shared.py diff --git a/backend/app/services/ai_pmo_typed_executor_execute/__init__.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/__init__.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_execute/__init__.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/__init__.py diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py similarity index 97% rename from backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 3c8e1791..c9db5e67 100644 --- a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -3,6 +3,7 @@ from __future__ import annotations from .shared import * # noqa: F401, F403 +from ...adaptive_rag_service import AdaptiveRAGService class ExecuteQueryProjectMixin: diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py similarity index 98% rename from backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py index f9f63a4c..de5b88c2 100644 --- a/backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py @@ -24,8 +24,7 @@ class ExecuteHelpersMixin: reason = reason[len(marker) :].strip() if not reason: reason = "unknown_error" - message = f"{marker}{reason}" - return message if "请稍后重试" in message else f"{message}。请稍后重试。" + return f"{marker}{reason}" @staticmethod def _build_signal_title_refine_error_message(error: Exception) -> str: diff --git a/backend/app/services/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_execute/service.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py diff --git a/backend/app/services/ai_pmo_typed_executor_execute/shared.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/shared.py similarity index 100% rename from backend/app/services/ai_pmo_typed_executor_execute/shared.py rename to backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/shared.py diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup.py b/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup.py deleted file mode 100644 index facc70ef..00000000 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup.py +++ /dev/null @@ -1,19 +0,0 @@ -"""AI PMO kernel followup mixin composition.""" - -from __future__ import annotations - -from .ai_pmo_kernel_mixins_followup_project_signal import KernelFollowupProjectSignalMixin -from .ai_pmo_kernel_mixins_followup_records import KernelFollowupRecordsMixin -from .ai_pmo_kernel_mixins_followup_routing import KernelFollowupRoutingMixin -from .ai_pmo_kernel_mixins_followup_verification import KernelFollowupVerificationMixin - - -class KernelFollowupMixin( - KernelFollowupRecordsMixin, - KernelFollowupRoutingMixin, - KernelFollowupProjectSignalMixin, - KernelFollowupVerificationMixin, -): - """Compose followup concerns into a single kernel mixin.""" - - pass diff --git a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_resolve.py deleted file mode 100644 index 5bebf3d4..00000000 --- a/backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_resolve.py +++ /dev/null @@ -1,66 +0,0 @@ -"""AI PMO kernel intent resolve mixin.""" - -from __future__ import annotations - -from .. import ai_pmo_kernel_common_core as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class KernelIntentResolveMixin: - async def _resolve_intent( - self, - *, - message_text: str, - session: Optional[ConversationSession], - world_model: dict[str, Any], - ) -> tuple[CanonicalIntent, str]: - project_id = _safe_int(world_model.get("resolved_project_id")) - raw_rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id) - rule_intent = self._apply_slot_guard(intent=raw_rule_intent, message_text=message_text) - # 查询/噪音场景优先走规则意图,避免额外触发 LLM 分类链路导致不必要失败重试。 - if rule_intent.name in {INTENT_QUERY, INTENT_IGNORE} and float(rule_intent.confidence or 0.0) >= 0.85: - return rule_intent, "rule_preferred" - if self._is_short_confirmation_message(message_text=message_text, session=session): - if session is None: - return rule_intent, "rule_fallback" - return ( - self._build_short_confirm_intent( - message_text=message_text, - session=session, - world_model=world_model, - ), - "session_inherited", - ) - - # 默认规则优先:仅在规则低置信/默认兜底时才触发 LLM。 - if not self._intent_llm_enabled(): - return rule_intent, "rule_fallback" - if not self._should_invoke_llm_last_resort(rule_intent=rule_intent): - return rule_intent, "rule_preferred" - - llm_intent = await self._resolve_intent_with_llm( - message_text=message_text, - session=session, - world_model=world_model, - rule_intent=rule_intent, - ) - if llm_intent is not None: - if self._intent_arbitration_enabled(): - selected_intent, source = self._arbitrate_intent( - rule_intent=rule_intent, - llm_intent=llm_intent, - message_text=message_text, - ) - else: - selected_intent, source = llm_intent, "llm" - return self._apply_slot_guard(intent=selected_intent, message_text=message_text), source - logger.info( - "ai_pmo_intent_rule_fallback", - tenant_id=self.tenant_id, - reason="intent_llm_empty_or_failed_or_timeout", - ) - return rule_intent, "rule_fallback" - diff --git a/backend/app/services/ai_service_core/mixin_business_tasks.py b/backend/app/services/ai_service_core/mixin_business_tasks.py index 91f129eb..767b1c28 100644 --- a/backend/app/services/ai_service_core/mixin_business_tasks.py +++ b/backend/app/services/ai_service_core/mixin_business_tasks.py @@ -3,6 +3,69 @@ from __future__ import annotations from .shared import * class AIServiceBusinessTasksMixin: + @staticmethod + def _default_available_skills() -> list[str]: + return [ + "Python", + "TypeScript", + "FastAPI", + "React", + "SQLAlchemy", + "LangChain", + "LLM调优", + "RAG构建", + "向量数据库", + "Ollama", + "Docker", + "Git", + ] + + async def _resolve_available_skills_text( + self, + *, + db: Optional[AsyncSession], + tenant_id: int, + ) -> str: + from ...models import SkillTag + + async def _query_skill_names(session: AsyncSession) -> list[str]: + rows = ( + await session.execute( + select(SkillTag.name) + .where(SkillTag.tenant_id == int(tenant_id)) + .order_by(SkillTag.sort_order.asc(), SkillTag.name.asc()) + ) + ).scalars().all() + names: list[str] = [] + seen: set[str] = set() + for raw in rows: + name = str(raw or "").strip() + if not name: + continue + lower_name = name.lower() + if lower_name in seen: + continue + seen.add(lower_name) + names.append(name) + return names + + names: list[str] = [] + if db is not None and hasattr(db, "execute"): + try: + names = await _query_skill_names(db) + except Exception: + names = [] + else: + async with AsyncSessionLocal() as session: + try: + names = await _query_skill_names(session) + except Exception: + names = [] + + if not names: + names = self._default_available_skills() + return ", ".join(names) + async def generate_charter_from_signal( self, signal_content: str, @@ -30,9 +93,15 @@ class AIServiceBusinessTasksMixin: from ...config.ai_prompts import PROMPT_DECOMPOSE_TASKS tid = _t2s(tenant_id) + tenant_id_int = _tenant_to_int(tenant_id) + available_skills = await self._resolve_available_skills_text( + db=db, + tenant_id=tenant_id_int, + ) prompt = PROMPT_DECOMPOSE_TASKS.format( charter_content=charter_content, project_id=_t2s(project_id), + available_skills=available_skills, ) text = await self._invoke_with_retry( prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter", @@ -62,4 +131,3 @@ class AIServiceBusinessTasksMixin: prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task", ) return self._parse_json(text) - diff --git a/backend/app/services/context_cache_service/shared.py b/backend/app/services/context_cache_service/shared.py index 5fbb98ba..394c734a 100644 --- a/backend/app/services/context_cache_service/shared.py +++ b/backend/app/services/context_cache_service/shared.py @@ -52,3 +52,5 @@ def _json_loads(raw: Optional[str], default: Any) -> Any: except Exception: return default + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/integrations/__init__.py b/backend/app/services/integrations/__init__.py new file mode 100644 index 00000000..4e1dd006 --- /dev/null +++ b/backend/app/services/integrations/__init__.py @@ -0,0 +1 @@ +"""Integrations domain service namespace.""" diff --git a/backend/app/services/feishu_integration_service/__init__.py b/backend/app/services/integrations/feishu_integration_service/__init__.py similarity index 100% rename from backend/app/services/feishu_integration_service/__init__.py rename to backend/app/services/integrations/feishu_integration_service/__init__.py diff --git a/backend/app/services/feishu_integration_service/mixin_config.py b/backend/app/services/integrations/feishu_integration_service/mixin_config.py similarity index 98% rename from backend/app/services/feishu_integration_service/mixin_config.py rename to backend/app/services/integrations/feishu_integration_service/mixin_config.py index 2233f993..0f080c31 100644 --- a/backend/app/services/feishu_integration_service/mixin_config.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_config.py @@ -30,7 +30,7 @@ class FeishuConfigMixin: @staticmethod async def _notify_outbox_ready(db: AsyncSession, *, outbox_id: Optional[int]) -> None: """仅在 PostgreSQL 下发送 outbox ready 事件。""" - if not FeishuIntegrationService._is_postgresql_session(db): + if not FeishuConfigMixin._is_postgresql_session(db): return try: payload = str(int(outbox_id))[:64] if outbox_id is not None else "" @@ -154,4 +154,3 @@ class FeishuConfigMixin: 'code': 1, 'msg': str(e) } - diff --git a/backend/app/services/feishu_integration_service/mixin_message_payload.py b/backend/app/services/integrations/feishu_integration_service/mixin_message_payload.py similarity index 96% rename from backend/app/services/feishu_integration_service/mixin_message_payload.py rename to backend/app/services/integrations/feishu_integration_service/mixin_message_payload.py index f21fc3db..e94c4d17 100644 --- a/backend/app/services/feishu_integration_service/mixin_message_payload.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_message_payload.py @@ -3,6 +3,16 @@ from __future__ import annotations from .shared import * class FeishuMessagePayloadMixin: + @staticmethod + def _strip_bot_mention_tokens(text: Optional[str]) -> str: + raw = str(text or "") + if not raw.strip(): + return "" + normalized = re.sub(r"]*>[^<]*", " ", raw, flags=re.IGNORECASE) + normalized = re.sub(r"@(?:pmflow|_user_1)\b", " ", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + @staticmethod def _build_attachment_placeholder_text(attachments: List[Dict[str, Any]]) -> str: if not attachments: @@ -256,4 +266,3 @@ class FeishuMessagePayloadMixin: await self.db.rollback() except Exception: pass - diff --git a/backend/app/services/feishu_integration_service/mixin_receive.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py similarity index 96% rename from backend/app/services/feishu_integration_service/mixin_receive.py rename to backend/app/services/integrations/feishu_integration_service/mixin_receive.py index fa286c27..27d1f788 100644 --- a/backend/app/services/feishu_integration_service/mixin_receive.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py @@ -37,7 +37,12 @@ class FeishuReceiveMixin: # 解析消息内容与附件元数据 message_payload = self._extract_message_payload(event) - message_content = str(message_payload.get("text") or "").strip() or None + raw_message_content = str(message_payload.get("text") or "").strip() or None + message_content = ( + self._strip_bot_mention_tokens(raw_message_content) + if raw_message_content + else "" + ).strip() or None message_attachments = ( message_payload.get("attachments") if isinstance(message_payload.get("attachments"), list) @@ -48,7 +53,7 @@ class FeishuReceiveMixin: trace_id=state_trace_id, message_id=message_id, event=event, - message_content=message_content or "", + message_content=raw_message_content or "", ) if not message_content: print( @@ -210,4 +215,3 @@ class FeishuReceiveMixin: 'scene': scene, }, } - diff --git a/backend/app/services/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_receive_routing.py rename to backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py diff --git a/backend/app/services/feishu_integration_service/mixin_reply_guard.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_reply_guard.py rename to backend/app/services/integrations/feishu_integration_service/mixin_reply_guard.py diff --git a/backend/app/services/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py similarity index 88% rename from backend/app/services/feishu_integration_service/mixin_reply_llm_core.py rename to backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 9b2a56bc..13583294 100644 --- a/backend/app/services/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -96,6 +96,7 @@ class FeishuReplyLLMCoreMixin: max_tokens=180, ) reply_text = self._clean_reply_text(raw_reply) + reply_text = self._strip_leading_thinking_paragraphs(reply_text) if not self._is_capability_question(message_content): reply_text = self._strip_identity_lead(reply_text) if (not reply_text) or self._looks_like_provider_error(reply_text): @@ -128,6 +129,40 @@ class FeishuReplyLLMCoreMixin: text = text[4:].strip() return text[:500] + @staticmethod + def _strip_leading_thinking_paragraphs(reply_text: str) -> str: + text = str(reply_text or "").strip() + if not text: + return "" + + # Prefer paragraph blocks; fall back to per-line blocks when needed. + paragraphs = [chunk.strip() for chunk in re.split(r"\n\s*\n", text) if str(chunk).strip()] + if len(paragraphs) <= 1: + lines = [line.strip() for line in text.splitlines() if str(line).strip()] + if len(lines) > 1: + paragraphs = lines + + thinking_prefixes = ( + "让我先", + "让我尝试", + "我来看看", + "我先查", + "让我看看", + "我需要先", + ) + + first_clean_idx: Optional[int] = None + for idx, paragraph in enumerate(paragraphs): + normalized = re.sub(r'^[\s>"\'“”‘’「」【】\(\)\[\]\-—::、\d\.]+', "", paragraph).strip() + if any(normalized.startswith(prefix) for prefix in thinking_prefixes): + continue + first_clean_idx = idx + break + + if first_clean_idx is None: + return "" + return "\n\n".join(paragraphs[first_clean_idx:]).strip()[:500] + @staticmethod def _is_capability_question(message_content: Optional[str]) -> bool: normalized = re.sub(r"\s+", "", str(message_content or "").lower()) @@ -256,4 +291,3 @@ class FeishuReplyLLMCoreMixin: f"我这边当前北京时间是 {today_text}。" "如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。" ) - diff --git a/backend/app/services/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py similarity index 99% rename from backend/app/services/feishu_integration_service/mixin_reply_outbox.py rename to backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index 84955b9b..33513502 100644 --- a/backend/app/services/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -227,7 +227,7 @@ class FeishuReplyOutboxMixin: send_error: Optional[str] = None try: async with AsyncSessionLocal() as db_send: - service = FeishuIntegrationService(db_send) + service = cls(db_send) await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) await service.send_message_to_group( chat_id=str(claimed.get("chat_id") or ""), @@ -294,4 +294,3 @@ class FeishuReplyOutboxMixin: return None raw = f"{source}{body}".encode("utf-8") return hashlib.md5(raw, usedforsecurity=False).hexdigest() - diff --git a/backend/app/services/feishu_integration_service/mixin_reply_templates.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_reply_templates.py rename to backend/app/services/integrations/feishu_integration_service/mixin_reply_templates.py diff --git a/backend/app/services/feishu_integration_service/mixin_sender_resolution.py b/backend/app/services/integrations/feishu_integration_service/mixin_sender_resolution.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_sender_resolution.py rename to backend/app/services/integrations/feishu_integration_service/mixin_sender_resolution.py diff --git a/backend/app/services/feishu_integration_service/mixin_trace.py b/backend/app/services/integrations/feishu_integration_service/mixin_trace.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_trace.py rename to backend/app/services/integrations/feishu_integration_service/mixin_trace.py diff --git a/backend/app/services/feishu_integration_service/mixin_transport.py b/backend/app/services/integrations/feishu_integration_service/mixin_transport.py similarity index 100% rename from backend/app/services/feishu_integration_service/mixin_transport.py rename to backend/app/services/integrations/feishu_integration_service/mixin_transport.py diff --git a/backend/app/services/feishu_integration_service/service.py b/backend/app/services/integrations/feishu_integration_service/service.py similarity index 100% rename from backend/app/services/feishu_integration_service/service.py rename to backend/app/services/integrations/feishu_integration_service/service.py diff --git a/backend/app/services/feishu_integration_service/shared.py b/backend/app/services/integrations/feishu_integration_service/shared.py similarity index 72% rename from backend/app/services/feishu_integration_service/shared.py rename to backend/app/services/integrations/feishu_integration_service/shared.py index de2debec..088890a8 100644 --- a/backend/app/services/feishu_integration_service/shared.py +++ b/backend/app/services/integrations/feishu_integration_service/shared.py @@ -28,7 +28,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from ...models import ( +from ....models import ( User, Project, Signal, @@ -37,13 +37,13 @@ from ...models import ( FeishuReplyOutbox, AIProcessingLog, ) -from ...core.database import AsyncSessionLocal -from ...config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY -from ..notification_service import NotificationService -from ..signal_capture_service import ProactiveSignalCaptureService -from ..llm_service import LLMService +from ....core.database import AsyncSessionLocal +from ....config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY +from ...notification_service import NotificationService +from ...signal.signal_capture_service import ProactiveSignalCaptureService +from ...llm_service import LLMService -from ...utils.timezone import now_cn_naive +from ....utils.timezone import now_cn_naive CN_TZ = timezone(timedelta(hours=8)) WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] OUTBOX_MAX_ATTEMPTS = 3 @@ -51,3 +51,4 @@ OUTBOX_STALE_SENDING_SECONDS = 120 _OUTBOX_PERF_T0_BY_ID: dict[int, float] = {} +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/feishu_long_connection_core/__init__.py b/backend/app/services/integrations/feishu_long_connection_core/__init__.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/__init__.py rename to backend/app/services/integrations/feishu_long_connection_core/__init__.py diff --git a/backend/app/services/feishu_long_connection_core/manager.py b/backend/app/services/integrations/feishu_long_connection_core/manager.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/manager.py rename to backend/app/services/integrations/feishu_long_connection_core/manager.py diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/mixin_lifecycle.py rename to backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle.py diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py rename to backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py rename to backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py diff --git a/backend/app/services/feishu_long_connection_core/mixin_runtime.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/mixin_runtime.py rename to backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py diff --git a/backend/app/services/feishu_long_connection_core/registry.py b/backend/app/services/integrations/feishu_long_connection_core/registry.py similarity index 100% rename from backend/app/services/feishu_long_connection_core/registry.py rename to backend/app/services/integrations/feishu_long_connection_core/registry.py diff --git a/backend/app/services/feishu_long_connection_core/shared.py b/backend/app/services/integrations/feishu_long_connection_core/shared.py similarity index 88% rename from backend/app/services/feishu_long_connection_core/shared.py rename to backend/app/services/integrations/feishu_long_connection_core/shared.py index 2b51ff21..47428364 100644 --- a/backend/app/services/feishu_long_connection_core/shared.py +++ b/backend/app/services/integrations/feishu_long_connection_core/shared.py @@ -14,9 +14,9 @@ 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 ...utils.timezone import CN_TZ, now_cn_aware +from ....core.logging import get_logger +from ....models import Integration +from ....utils.timezone import CN_TZ, now_cn_aware from ..feishu_integration_service import FeishuIntegrationService logger = get_logger(__name__) diff --git a/backend/app/services/feishu_outbox/__init__.py b/backend/app/services/integrations/feishu_outbox/__init__.py similarity index 100% rename from backend/app/services/feishu_outbox/__init__.py rename to backend/app/services/integrations/feishu_outbox/__init__.py diff --git a/backend/app/services/feishu_outbox/service.py b/backend/app/services/integrations/feishu_outbox/service.py similarity index 98% rename from backend/app/services/feishu_outbox/service.py rename to backend/app/services/integrations/feishu_outbox/service.py index 37d3a7a4..7a3614c9 100644 --- a/backend/app/services/feishu_outbox/service.py +++ b/backend/app/services/integrations/feishu_outbox/service.py @@ -9,7 +9,7 @@ from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from ...models import FeishuReplyOutbox +from ....models import FeishuReplyOutbox class FeishuOutboxService: diff --git a/backend/app/services/feishu_signal_pipeline/__init__.py b/backend/app/services/integrations/feishu_signal_pipeline/__init__.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/__init__.py rename to backend/app/services/integrations/feishu_signal_pipeline/__init__.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_core_base.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_core_base.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_extraction.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_extraction.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_followup_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_followup_a.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_followup_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_followup_b.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py similarity index 94% rename from backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py index 8bd01f23..b5327f73 100644 --- a/backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py @@ -156,8 +156,19 @@ class FeishuPipelineGateDispatchMixin: source_sender_open_id=source_sender_open_id, sender=sender, ) + intent_result: dict[str, Any] = {} + try: + intent_result = await rag_service.classify_intent(message) + except Exception: + intent_result = {} + mixed_intent = str(intent_result.get("intent") or "").strip().lower() == "mixed" + if mixed_intent: + # mixed 消息不能在 intent_gate.query 提前结束,交给默认链路完成“问答+需求入库”双轨处理。 + return None + + rag_question = str(intent_result.get("question_part") or "").strip() or message rag_result = await rag_service.answer_question( - question=message, + question=rag_question, asked_by=None, asked_via="feishu", project_id=inferred_project_id, @@ -267,4 +278,3 @@ class FeishuPipelineGateDispatchMixin: db_mutations=self._summarize_db_mutations(command_capture), ) return None - diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_project.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_gate_project.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_task.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_task.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_gate_task.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_task.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_intent_support.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_support.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_intent_support.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_support.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_item_processing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_item_processing.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_persistence_lookup.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_persistence_lookup.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py similarity index 78% rename from backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py index 15ae83c4..723f04a8 100644 --- a/backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py @@ -34,23 +34,45 @@ class FeishuPipelineProcessMessageDefaultMixin: 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._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, + intent_result: dict[str, Any] = {} + try: + intent_result = await rag_service.classify_intent(message) + except Exception: + intent_result = {} + intent = str(intent_result.get("intent") or "question").lower() + 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 == "mixed" or self._is_requirement_like_question(message): + mixed_signal_part = ( + rag_statement_part + or self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, + ) ) + 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, + message_intent="mixed", + mixed_signal_part=mixed_signal_part, + ) + sidecar_rag_result = mixed_rag_result + intent_for_response = "question_requirement" + message = mixed_signal_part force_signal_fastpath = True else: + 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, + ) return await self._finalize_direct_qa_capture_result( message=message, rag_result=rag_result, @@ -69,23 +91,37 @@ class FeishuPipelineProcessMessageDefaultMixin: 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._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, + mixed_signal_part = ( + rag_statement_part + or self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, + ) ) + 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, + message_intent="mixed", + mixed_signal_part=mixed_signal_part, + ) + sidecar_rag_result = mixed_rag_result + intent_for_response = "question_requirement" + message = mixed_signal_part force_signal_fastpath = True else: + 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, + ) return await self._finalize_direct_qa_capture_result( message=message, rag_result=rag_result, @@ -98,6 +134,13 @@ class FeishuPipelineProcessMessageDefaultMixin: ) if intent == "mixed": + mixed_signal_part = ( + rag_statement_part + or self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, + ) + ) mixed_rag_result = await rag_service.answer_question( question=rag_question_part, asked_by=None, @@ -105,9 +148,10 @@ class FeishuPipelineProcessMessageDefaultMixin: project_id=inferred_project_id, source_chat_id=source_chat_id, conversation_key=conversation_key, + message_intent="mixed", + mixed_signal_part=mixed_signal_part, ) - if rag_statement_part: - message = rag_statement_part + message = mixed_signal_part sidecar_rag_result = mixed_rag_result try: @@ -253,4 +297,3 @@ class FeishuPipelineProcessMessageDefaultMixin: process_started_at=process_started_at, ) raise - diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_routing_notify.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_routing_notify.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_serialization.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_serialization.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py diff --git a/backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_task_command_queue.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py rename to backend/app/services/integrations/feishu_signal_pipeline/mixin_task_command_queue.py diff --git a/backend/app/services/feishu_signal_pipeline/service.py b/backend/app/services/integrations/feishu_signal_pipeline/service.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/service.py rename to backend/app/services/integrations/feishu_signal_pipeline/service.py diff --git a/backend/app/services/feishu_signal_pipeline/shared.py b/backend/app/services/integrations/feishu_signal_pipeline/shared.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/shared.py rename to backend/app/services/integrations/feishu_signal_pipeline/shared.py diff --git a/backend/app/services/feishu_signal_pipeline/shared_core.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py similarity index 91% rename from backend/app/services/feishu_signal_pipeline/shared_core.py rename to backend/app/services/integrations/feishu_signal_pipeline/shared_core.py index 2e434a65..32ced794 100644 --- a/backend/app/services/feishu_signal_pipeline/shared_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py @@ -14,13 +14,13 @@ 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 ( +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 ( +from ....core.logging import get_logger +from ....models import ( FeishuItem, AIProcessingLog, FeishuReplyOutbox, @@ -33,27 +33,27 @@ from ...models import ( TruthNode, User, ) -from ..llm_service import LLMService -from ..adaptive_rag_service import AdaptiveRAGService -from ..ai_pmo_kernel import ( +from ...llm_service import LLMService +from ...adaptive_rag_service import AdaptiveRAGService +from ...ai_pmo.ai_pmo_kernel import ( build_decision_trace, gate_execution as kernel_gate_execution, resolve_correction as kernel_resolve_correction, ) -from ..context_cache_service import context_cache_service -from ..notification_service import NotificationService -from ..operation_log_service import log_operation -from ...utils.project_command_utils import ( +from ...context_cache_service import context_cache_service +from ...notification_service import NotificationService +from ...operation_log_service import log_operation +from ....utils.project_command_utils import ( extract_project_create_description, extract_project_create_title, normalize_project_title_candidate, ) -from ..sstc_service_core import ALIGNED_STATUSES -from ..task_persistence_core 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 ...sstc_service_core import ALIGNED_STATUSES +from ...task_persistence_core 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.timezone import CN_TZ, now_cn_aware +from ....utils.timezone import CN_TZ, now_cn_aware logger = get_logger(__name__) PROJECT_PRIORITY_ORDER = { @@ -214,4 +214,3 @@ DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 - diff --git a/backend/app/services/feishu_signal_pipeline/shared_schema_utils.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py similarity index 100% rename from backend/app/services/feishu_signal_pipeline/shared_schema_utils.py rename to backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py diff --git a/backend/app/services/integration_core/__init__.py b/backend/app/services/integrations/integration_core/__init__.py similarity index 100% rename from backend/app/services/integration_core/__init__.py rename to backend/app/services/integrations/integration_core/__init__.py diff --git a/backend/app/services/integration_core/lark_service.py b/backend/app/services/integrations/integration_core/lark_service.py similarity index 100% rename from backend/app/services/integration_core/lark_service.py rename to backend/app/services/integrations/integration_core/lark_service.py diff --git a/backend/app/services/integration_core/mixin_clients.py b/backend/app/services/integrations/integration_core/mixin_clients.py similarity index 100% rename from backend/app/services/integration_core/mixin_clients.py rename to backend/app/services/integrations/integration_core/mixin_clients.py diff --git a/backend/app/services/integration_core/mixin_health.py b/backend/app/services/integrations/integration_core/mixin_health.py similarity index 100% rename from backend/app/services/integration_core/mixin_health.py rename to backend/app/services/integrations/integration_core/mixin_health.py diff --git a/backend/app/services/integration_core/mixin_lifecycle.py b/backend/app/services/integrations/integration_core/mixin_lifecycle.py similarity index 100% rename from backend/app/services/integration_core/mixin_lifecycle.py rename to backend/app/services/integrations/integration_core/mixin_lifecycle.py diff --git a/backend/app/services/integration_core/mixin_sync.py b/backend/app/services/integrations/integration_core/mixin_sync.py similarity index 98% rename from backend/app/services/integration_core/mixin_sync.py rename to backend/app/services/integrations/integration_core/mixin_sync.py index ae524d50..53ea9b58 100644 --- a/backend/app/services/integration_core/mixin_sync.py +++ b/backend/app/services/integrations/integration_core/mixin_sync.py @@ -89,7 +89,7 @@ class IntegrationSyncMixin: health.latency_ms = latency if self.event_bus: - from ...core.events import Event, EventType + from ....core.events import Event, EventType await self.event_bus.publish( Event( @@ -112,7 +112,7 @@ class IntegrationSyncMixin: await self._record_failure(integration_type, str(exc)) if self.event_bus: - from ...core.events import Event, EventType + from ....core.events import Event, EventType await self.event_bus.publish( Event( diff --git a/backend/app/services/integration_core/plane_service.py b/backend/app/services/integrations/integration_core/plane_service.py similarity index 100% rename from backend/app/services/integration_core/plane_service.py rename to backend/app/services/integrations/integration_core/plane_service.py diff --git a/backend/app/services/integration_core/registry.py b/backend/app/services/integrations/integration_core/registry.py similarity index 100% rename from backend/app/services/integration_core/registry.py rename to backend/app/services/integrations/integration_core/registry.py diff --git a/backend/app/services/integration_core/schemas.py b/backend/app/services/integrations/integration_core/schemas.py similarity index 100% rename from backend/app/services/integration_core/schemas.py rename to backend/app/services/integrations/integration_core/schemas.py diff --git a/backend/app/services/integration_core/service.py b/backend/app/services/integrations/integration_core/service.py similarity index 100% rename from backend/app/services/integration_core/service.py rename to backend/app/services/integrations/integration_core/service.py diff --git a/backend/app/services/integration_core/shared.py b/backend/app/services/integrations/integration_core/shared.py similarity index 92% rename from backend/app/services/integration_core/shared.py rename to backend/app/services/integrations/integration_core/shared.py index e8df83dc..10bb0eca 100644 --- a/backend/app/services/integration_core/shared.py +++ b/backend/app/services/integrations/integration_core/shared.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional import httpx from pydantic import BaseModel, Field -from ...utils.timezone import now_cn_naive +from ....utils.timezone import now_cn_naive logger = logging.getLogger(__name__) diff --git a/backend/app/services/wecom/__init__.py b/backend/app/services/integrations/wecom/__init__.py similarity index 100% rename from backend/app/services/wecom/__init__.py rename to backend/app/services/integrations/wecom/__init__.py diff --git a/backend/app/services/wecom/service.py b/backend/app/services/integrations/wecom/service.py similarity index 98% rename from backend/app/services/wecom/service.py rename to backend/app/services/integrations/wecom/service.py index 62500eff..27ce94d9 100644 --- a/backend/app/services/wecom/service.py +++ b/backend/app/services/integrations/wecom/service.py @@ -20,10 +20,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import text -from ...models import ( +from ....models import ( Signal, Project, Integration ) -from ..signal_capture_service import ProactiveSignalCaptureService +from ...signal.signal_capture_service import ProactiveSignalCaptureService class WeChatWorkIntegrationService: diff --git a/backend/app/services/llm_service/mixin_cache_circuit.py b/backend/app/services/llm_service/mixin_cache_circuit.py index fcd65fdc..3eeca44b 100644 --- a/backend/app/services/llm_service/mixin_cache_circuit.py +++ b/backend/app/services/llm_service/mixin_cache_circuit.py @@ -3,6 +3,8 @@ from __future__ import annotations from .shared import * class LLMCacheCircuitMixin: + _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") @@ -160,4 +162,3 @@ class LLMCacheCircuitMixin: "last_error": str(exc)[:240], "updated_at_monotonic": now, } - diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index b7f78b65..86b36495 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -2,7 +2,31 @@ from __future__ import annotations from .shared import * + +class LLMEmptyContentError(ValueError): + """Raised when the model still returns empty content after one forced retry.""" + + class LLMChatOpenAIMixin: + @staticmethod + def _append_direct_answer_retry_instruction(messages: list[dict[str, str]]) -> None: + retry_instruction = "请直接输出答案" + for idx in range(len(messages) - 1, -1, -1): + row = messages[idx] + if not isinstance(row, dict): + continue + role = str(row.get("role") or "").strip().lower() + if role != "user": + continue + original = str(row.get("content") or "").strip() + if retry_instruction in original: + return + patched = dict(row) + patched["content"] = f"{original}\n\n{retry_instruction}" if original else retry_instruction + messages[idx] = patched + return + messages.append({"role": "user", "content": retry_instruction}) + async def _chat_completion_via_openai_compatible( self, *, @@ -28,9 +52,10 @@ class LLMChatOpenAIMixin: "Content-Type": "application/json", } current_max_tokens = max(int(max_tokens or 0), 1) - empty_content_retry_budget = 1 - http_status_retry_budget = 2 + http_status_retry_budget = self._http_status_retry_budget_for_function(function_label) http_status_retry_attempt = 0 + use_json_response_format = bool(structured_output) + direct_answer_retry_used = False while True: payload = { "model": model, @@ -38,6 +63,8 @@ class LLMChatOpenAIMixin: "temperature": temperature, "max_tokens": current_max_tokens, } + if use_json_response_format: + payload["response_format"] = {"type": "json_object"} if is_openclaw_gateway_request: payload["agents"] = { "defaults": { @@ -89,6 +116,19 @@ class LLMChatOpenAIMixin: http_status_retry_attempt += 1 await asyncio.sleep(retry_delay) continue + if use_json_response_format and status_code in {400, 422}: + lower_preview = body_preview.lower() + if "response_format" in lower_preview or "json_object" in lower_preview or "json schema" in lower_preview: + _runtime_log( + "LLM", + ( + "结构化参数降级重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"status={status_code}" + ), + ) + use_json_response_format = False + continue resp.raise_for_status() data = resp.json() choices = data.get("choices") or [] @@ -102,27 +142,7 @@ class LLMChatOpenAIMixin: response=data, structured_output=structured_output, ) - if ( - not text - and not structured_output - and self._reasoning_content_fallback_enabled(function_name=function_label) - ): - fallback_text = self._extract_reasoning_fallback_text( - message=message, - choice=first_choice, - response=data, - ) - if fallback_text: - text = fallback_text - _runtime_log( - "LLM", - ( - "空content回退reasoning_content:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"text_len={len(text)}" - ), - ) - if text or structured_output: + if text: break msg_keys = list(message.keys()) if isinstance(message, dict) else [] @@ -132,33 +152,23 @@ class LLMChatOpenAIMixin: choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300] except Exception: choice_preview = str(first_choice)[:300] - empty_error = ValueError( + empty_error = LLMEmptyContentError( "LLM 返回空内容: " f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" ) - can_retry_empty = ( - empty_content_retry_budget > 0 - and self._should_retry_empty_chat_response( - structured_output=structured_output, - message=message, - choice=first_choice, - current_max_tokens=current_max_tokens, + if not direct_answer_retry_used: + direct_answer_retry_used = True + self._append_direct_answer_retry_instruction(messages) + _runtime_log( + "LLM", + ( + "空content定向重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + "retry_with=请直接输出答案" + ), ) - ) - if not can_retry_empty: - raise empty_error - - empty_content_retry_budget -= 1 - next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens) - _runtime_log( - "LLM", - ( - "空内容提额重试:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"max_tokens={current_max_tokens}->{next_max_tokens}" - ), - ) - current_max_tokens = next_max_tokens + continue + raise empty_error if _looks_like_openclaw_timeout_message(text): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( @@ -190,4 +200,3 @@ class LLMChatOpenAIMixin: ), ) return ChatCompletionResult(text=text, raw_response=data) - diff --git a/backend/app/services/llm_service/mixin_generate_db_structured.py b/backend/app/services/llm_service/mixin_generate_db_structured.py index 79f7b8c9..612aec4f 100644 --- a/backend/app/services/llm_service/mixin_generate_db_structured.py +++ b/backend/app/services/llm_service/mixin_generate_db_structured.py @@ -97,7 +97,27 @@ class LLMGenerateDbStructuredMixin: ), ) if extraction.payload is None: - raise ValueError(str(extraction.error_code or "structured_schema_invalid")) + if self._should_attempt_model_repair(function_name): + repaired = await self._repair_structured_payload_with_model( + function_name=function_name, + provider=provider, + model=model, + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + system_prompt=prompt_profile.get("system_prompt") or "", + original_prompt=prompt_with_context, + extraction=extraction, + raw_response=completion.raw_response, + fallback_text=completion.text, + max_tokens=max_tokens, + ) + if isinstance(repaired, StructuredExtractionResult) and repaired.payload is not None: + extraction = repaired + response_text = repaired.raw_text or response_text + else: + raise ValueError(str(extraction.error_code or "structured_schema_invalid")) + else: + raise ValueError(str(extraction.error_code or "structured_schema_invalid")) 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" @@ -150,3 +170,110 @@ class LLMGenerateDbStructuredMixin: except Exception as log_exc: _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") + @staticmethod + def _build_structured_schema_hint(function_name: str) -> str: + fn = str(function_name or "").strip().lower() + schema = STRUCTURED_OBJECT_SCHEMAS.get(fn) + if not isinstance(schema, dict): + return "{}" + schema_hint = { + "required_keys": list(schema.get("required_keys") or []), + "type_checks": dict(schema.get("type_checks") or {}), + "optional_type_checks": dict(schema.get("optional_type_checks") or {}), + } + return json.dumps(schema_hint, ensure_ascii=False) + + @staticmethod + def _should_attempt_model_repair(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + return _env_bool("LLM_INTENT_STRUCTURED_REPAIR_ENABLED", True) + return True + + async def _repair_structured_payload_with_model( + self, + *, + function_name: str, + provider: str, + model: str, + api_key: str, + base_url: str, + system_prompt: str, + original_prompt: str, + extraction: StructuredExtractionResult, + raw_response: Any, + fallback_text: str, + max_tokens: int, + ) -> Optional[StructuredExtractionResult]: + source_text = str(extraction.raw_text or "").strip() + if source_text.startswith("[") and source_text.endswith("]"): + source_text = "" + if not source_text: + source_text = str(fallback_text or "").strip() + if not source_text: + return None + schema_hint = self._build_structured_schema_hint(function_name) + repair_prompt = ( + "首字符必须是 {,末字符必须是 }。\n" + "你是 JSON 修复器。把模型输出整理为满足目标 schema 的 JSON 对象。\n" + f"function_name: {function_name}\n" + f"schema_hint_json: {schema_hint}\n" + f"original_task_prompt: {str(original_prompt or '')[:2000]}\n" + f"model_output: {source_text[:2800]}\n" + "要求:\n" + "1. 只输出 JSON 对象。\n" + "2. 必须包含 required_keys。\n" + "3. 若字段缺失,填 null/空字符串/空数组,并保持类型合法。\n" + "4. 禁止输出解释、Markdown、代码块。" + ) + try: + repair_max_tokens = max(180, min(int(max_tokens or 0), 420)) + if str(function_name or "").strip().lower() in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + repair_max_tokens = min(repair_max_tokens, 220) + repair_completion = await self._chat_completion_raw( + provider=provider, + prompt=repair_prompt, + system_prompt=system_prompt, + api_key=api_key, + base_url=base_url, + model=model, + temperature=0.0, + max_tokens=repair_max_tokens, + function_name=function_name, + response_mode="fast", + ) + except Exception as exc: + _runtime_log( + "LLM结构化", + ( + "模型结构化修复失败:" + f"function={function_name}, provider={provider}, model={model}, " + f"error={type(exc).__name__}:{str(exc)[:180]}" + ), + ) + return None + repair_extraction = self._extract_structured_object_from_response( + function_name=function_name, + response=repair_completion.raw_response, + fallback_text=repair_completion.text, + provider=provider, + model=model, + ) + if repair_extraction.payload is None: + _runtime_log( + "LLM结构化", + ( + "模型结构化修复未产出有效 JSON:" + f"function={function_name}, source={repair_extraction.source_field or 'none'}, " + f"error={repair_extraction.error_code or 'unknown'}" + ), + ) + return None + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={repair_extraction.source_field or 'none'}, " + f"validation={repair_extraction.validation}, fallback=model_repair" + ), + ) + return repair_extraction diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py index ef1934a7..bf8e08bc 100644 --- a/backend/app/services/llm_service/mixin_guardrails_prompt.py +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -137,6 +137,43 @@ class LLMGuardrailsPromptMixin: configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) + @staticmethod + def _http_status_retry_budget_for_function(function_name: str) -> int: + fn = str(function_name or "").strip().lower() + is_intent_function = fn in { + "classify_intent_ai_pmo_kernel", + "classify_intent_complex", + "intent_structured_runtime_failover", + } + env_key = "LLM_INTENT_HTTP_RETRY_BUDGET" if is_intent_function else "LLM_HTTP_STATUS_RETRY_BUDGET" + default_budget = "0" if is_intent_function else "2" + raw = str(os.getenv(env_key, default_budget) or default_budget).strip() + try: + parsed = int(raw) + except Exception: + parsed = int(default_budget) + return max(0, min(parsed, 3)) + + @staticmethod + def _empty_chat_retry_budget(*, function_name: str, current_max_tokens: int) -> int: + fn = str(function_name or "").strip().lower() + current = max(int(current_max_tokens or 0), 1) + if current >= 2800: + return 0 + if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + raw_budget = str(os.getenv("LLM_INTENT_EMPTY_RETRY_BUDGET", "2") or "2").strip() + try: + parsed_budget = int(raw_budget) + except Exception: + parsed_budget = 2 + return max(0, min(parsed_budget, 3)) + if fn.startswith("adaptive_rag_answer_"): + # RAG 问答在 GLM 系列上经常先返回 reasoning_content,需至少升档两次才能拿到最终 content。 + return 2 + if any(token in fn for token in ("qa", "query", "reply")): + return 1 + return 1 + @staticmethod def _should_retry_empty_chat_response( *, @@ -145,8 +182,6 @@ class LLMGuardrailsPromptMixin: choice: Any, current_max_tokens: int, ) -> bool: - if structured_output: - return False if int(current_max_tokens or 0) >= 2800: return False finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else "" @@ -178,4 +213,3 @@ class LLMGuardrailsPromptMixin: 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) - diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_openclaw_routing.py index 65ea6a6f..99232de4 100644 --- a/backend/app/services/llm_service/mixin_openclaw_routing.py +++ b/backend/app/services/llm_service/mixin_openclaw_routing.py @@ -7,10 +7,10 @@ class LLMOpenclawRoutingMixin: return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) def _openclaw_routing_enabled(self) -> bool: - return False + return _env_bool("OPENCLAW_ROUTING_ENABLED", False) def _openclaw_control_plane_only(self) -> bool: - return False + return _env_bool("OPENCLAW_CONTROL_PLANE_ONLY", False) def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: runtime = dict(engine or {}) @@ -209,4 +209,3 @@ class LLMOpenclawRoutingMixin: return default_tier # mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。 return "high" if self._mixed_routing_enabled() else "active" - diff --git a/backend/app/services/llm_service/mixin_structured_candidates.py b/backend/app/services/llm_service/mixin_structured_candidates.py index 3a794b11..fda9aef6 100644 --- a/backend/app/services/llm_service/mixin_structured_candidates.py +++ b/backend/app/services/llm_service/mixin_structured_candidates.py @@ -9,6 +9,7 @@ class LLMStructuredCandidatesMixin: message: Any, choice: Any, response: Any, + include_reasoning: bool = True, ) -> list[tuple[str, Any]]: candidates: list[tuple[str, Any]] = [] if isinstance(message, dict): @@ -22,15 +23,16 @@ class LLMStructuredCandidatesMixin: 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"))) + if include_reasoning: + 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 @@ -71,9 +73,108 @@ class LLMStructuredCandidatesMixin: return value is None or isinstance(value, dict) return True + @staticmethod + def _coerce_number(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value or "").strip() + if not text: + return None + try: + return float(text) + except Exception: + return None + + @staticmethod + def _coerce_int(value: Any) -> Optional[int]: + number = LLMStructuredCandidatesMixin._coerce_number(value) + if number is None: + return None + try: + return int(number) + except Exception: + return None + + @staticmethod + def _coerce_bool(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(int(value)) + text = str(value or "").strip().lower() + if not text: + return None + if text in BOOL_TRUE_VALUES: + return True + if text in {"0", "false", "no", "off", "n"}: + return False + return None + + @classmethod + def _coerce_intent_structured_payload(cls, payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + confidence = cls._coerce_number(normalized.get("confidence")) + if confidence is None: + for alias in ("score", "probability", "confidence_score"): + confidence = cls._coerce_number(normalized.get(alias)) + if confidence is not None: + break + if confidence is not None: + normalized["confidence"] = confidence + + reasoning_value = normalized.get("reasoning") + if isinstance(reasoning_value, list): + text_items = [str(item).strip() for item in reasoning_value if str(item).strip()] + normalized["reasoning"] = ";".join(text_items) + elif isinstance(reasoning_value, dict): + normalized["reasoning"] = json.dumps(reasoning_value, ensure_ascii=False) + else: + reasoning_text = str(reasoning_value or "").strip() + if reasoning_text: + normalized["reasoning"] = reasoning_text + else: + for alias in ("reason", "rationale", "analysis", "explanation"): + alias_text = str(normalized.get(alias) or "").strip() + if alias_text: + normalized["reasoning"] = alias_text + break + + if "requires_confirmation" in normalized: + bool_value = cls._coerce_bool(normalized.get("requires_confirmation")) + if bool_value is not None: + normalized["requires_confirmation"] = bool_value + + for field in ("project_id", "requested_count"): + if field in normalized: + number = cls._coerce_int(normalized.get(field)) + normalized[field] = number if number is not None else None + + route_hint = str( + normalized.get("workflow_route") or normalized.get("route") or "" + ).strip().lower() + if route_hint and "." in route_hint: + family, action = route_hint.split(".", 1) + if not str(normalized.get("family") or "").strip(): + normalized["family"] = family + if not str(normalized.get("action") or "").strip(): + normalized["action"] = action + return normalized + + @classmethod + def _coerce_structured_payload(cls, *, function_name: str, payload: Any) -> Any: + fn = str(function_name or "").strip().lower() + if not isinstance(payload, dict): + return payload + if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + return cls._coerce_intent_structured_payload(payload) + return payload + 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) + payload = self._coerce_structured_payload(function_name=fn, payload=payload) if schema is None: return None if isinstance(payload, (dict, list)) else "structured_json_invalid" if not isinstance(payload, dict): @@ -93,4 +194,3 @@ class LLMStructuredCandidatesMixin: if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values): return "structured_schema_invalid" return None - diff --git a/backend/app/services/llm_service/mixin_structured_extract_object.py b/backend/app/services/llm_service/mixin_structured_extract_object.py index 131b0577..246181c2 100644 --- a/backend/app/services/llm_service/mixin_structured_extract_object.py +++ b/backend/app/services/llm_service/mixin_structured_extract_object.py @@ -1,5 +1,7 @@ from __future__ import annotations +import ast + from .shared import * class LLMStructuredExtractObjectMixin: @@ -21,7 +23,13 @@ class LLMStructuredExtractObjectMixin: last_nonempty_field = "" last_nonempty_text = "" - for field, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + last_invalid_result: Optional[StructuredExtractionResult] = None + for field, raw_value in self._iter_structured_candidates( + message=message, + choice=choice, + response=response, + include_reasoning=False, + ): text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) normalized = self._strip_code_fence(text) if normalized: @@ -35,19 +43,26 @@ class LLMStructuredExtractObjectMixin: 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._parse_loose_structured_candidate(candidate) + if parsed is None: + last_invalid_result = StructuredExtractionResult( + payload=None, + source_field=field, + provider=provider, + model=model, + error_code="structured_json_invalid", + raw_text=candidate[:4000], + validation="invalid", + ) + continue parsed = self._unwrap_structured_payload_once(parsed) + parsed = self._coerce_structured_payload( + function_name=function_name, + payload=parsed, + ) validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) if validation_error is not None: - return StructuredExtractionResult( + last_invalid_result = StructuredExtractionResult( payload=None, source_field=field, provider=provider, @@ -56,6 +71,7 @@ class LLMStructuredExtractObjectMixin: raw_text=candidate[:4000], validation="invalid", ) + continue return StructuredExtractionResult( payload=parsed if isinstance(parsed, dict) else None, source_field=field, @@ -72,10 +88,38 @@ class LLMStructuredExtractObjectMixin: else "" ) if fallback_candidate: + parsed: Any = None try: parsed = json.loads(fallback_candidate) except Exception: - return StructuredExtractionResult( + parsed = self._parse_loose_structured_candidate(fallback_candidate) + if parsed is not None: + parsed = self._unwrap_structured_payload_once(parsed) + parsed = self._coerce_structured_payload( + function_name=function_name, + payload=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", + ) + last_invalid_result = StructuredExtractionResult( + payload=None, + source_field="generated_text", + provider=provider, + model=model, + error_code=validation_error or "structured_schema_invalid", + raw_text=fallback_candidate[:4000], + validation="invalid", + ) + else: + last_invalid_result = StructuredExtractionResult( payload=None, source_field="generated_text", provider=provider, @@ -84,17 +128,9 @@ class LLMStructuredExtractObjectMixin: 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 last_invalid_result is not None: + return last_invalid_result if not last_nonempty_field and str(fallback_text or "").strip(): last_nonempty_field = "generated_text" @@ -110,6 +146,24 @@ class LLMStructuredExtractObjectMixin: validation="invalid", ) + @staticmethod + def _parse_loose_structured_candidate(candidate: str) -> Any: + text = str(candidate or "").strip() + if not text: + return None + try: + parsed = ast.literal_eval(text) + except Exception: + return None + if isinstance(parsed, str): + try: + return json.loads(parsed) + except Exception: + return None + if isinstance(parsed, (dict, list)): + return parsed + return None + @staticmethod def _classify_structured_exception(exc: Exception) -> str: if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)): @@ -122,4 +176,3 @@ class LLMStructuredExtractObjectMixin: if "status" in lowered or "http" in lowered: return "structured_http_error" return "structured_empty" - diff --git a/backend/app/services/llm_service/mixin_structured_parse.py b/backend/app/services/llm_service/mixin_structured_parse.py index c696b030..9e31dbfc 100644 --- a/backend/app/services/llm_service/mixin_structured_parse.py +++ b/backend/app/services/llm_service/mixin_structured_parse.py @@ -17,8 +17,8 @@ class LLMStructuredParseMixin: if raw.startswith("{") or raw.startswith("["): return raw - object_candidate = LLMService._extract_balanced_json_segment(raw, "{", "}") - array_candidate = LLMService._extract_balanced_json_segment(raw, "[", "]") + object_candidate = LLMStructuredParseMixin._extract_balanced_json_segment(raw, "{", "}") + array_candidate = LLMStructuredParseMixin._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: @@ -77,6 +77,10 @@ class LLMStructuredParseMixin: except Exception: return True parsed = self._unwrap_structured_payload_once(parsed) + parsed = self._coerce_structured_payload( + function_name=function_name, + payload=parsed, + ) validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) if validation_error is not None: return True @@ -103,7 +107,12 @@ class LLMStructuredParseMixin: structured_output: bool, ) -> str: if structured_output: - for _, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + for _, raw_value in self._iter_structured_candidates( + message=message, + choice=choice, + response=response, + include_reasoning=False, + ): 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): @@ -160,6 +169,9 @@ class LLMStructuredParseMixin: @staticmethod def _reasoning_content_fallback_enabled(*, function_name: str) -> bool: fn = str(function_name or "").strip().lower() + # P0 guardrail: user-facing answer/reply/qa must never expose reasoning_content. + if fn and any(token in fn for token in ("answer", "reply", "qa")): + return False if fn and ("title_refine" in fn or "project_draft_analysis_glm47_fallback" in fn): return True raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() @@ -171,7 +183,4 @@ class LLMStructuredParseMixin: return False if "signal_batch_analysis" in fn: return False - if any(token in fn for token in ("answer", "reply", "qa")): - return True return False - diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index 1610fc20..2660aca6 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -46,6 +46,9 @@ class LLMTierEscalationMixin: ) -> bool: if not self._low_to_high_escalation_enabled(): return False + fn = str(function_name or "").strip().lower() + if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + return False failed_model = str(failed_engine.get("model") or "").strip() if self._is_glm_47_model(failed_model): return True @@ -143,7 +146,12 @@ class LLMTierEscalationMixin: response_mode: str, error: Exception, ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: - if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): + fn = str(function_name or "").strip().lower() + force_intent_retry = fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"} + if not force_intent_retry and not self._should_retry_glm47_with_high_tier( + function_name=function_name, + failed_engine=failed_engine, + ): return None, None try: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) @@ -167,13 +175,41 @@ class LLMTierEscalationMixin: high_provider = str(high_engine.get("provider") or "").strip().lower() high_model = str(high_engine.get("model") or "").strip() if failed_provider == high_provider and failed_model == high_model: - return None, None + if not force_intent_retry: + return None, None + fallback_engine: Optional[dict[str, Any]] = None + for fallback_tier in ("safety", "low"): + candidate = self._resolve_tier_engine( + tier=fallback_tier, + engines=engines, + active_engine=active_engine, + ) + if not isinstance(candidate, dict): + continue + candidate = self._ensure_engine_allowed_for_function( + engine=candidate, + function_name=function_name, + settings=self._normalize_engine_settings(bundle.get("settings")), + engine_label=f"intent_structured_failover_{fallback_tier}", + ) + candidate_provider = str(candidate.get("provider") or "").strip().lower() + candidate_model = str(candidate.get("model") or "").strip() + if candidate_provider == failed_provider and candidate_model == failed_model: + continue + fallback_engine = candidate + break + if fallback_engine is None: + return None, None + high_engine = fallback_engine + high_provider = str(high_engine.get("provider") or "").strip().lower() + high_model = str(high_engine.get("model") or "").strip() _runtime_log( "LLM路由", ( "低阶结构化失败自动升级:" f"function={function_name}, from={failed_provider}:{failed_model}, " - f"to={high_provider}:{high_model}, reason={str(error)[:120]}" + f"to={high_provider}:{high_model}, reason={str(error)[:120]}, " + f"force_intent_retry={force_intent_retry}" ), ) completion = await self._chat_completion_raw( @@ -185,7 +221,11 @@ class LLMTierEscalationMixin: model=high_model, temperature=temperature, max_tokens=max_tokens, - function_name=function_name, + function_name=( + "intent_structured_runtime_failover" + if force_intent_retry + else function_name + ), response_mode=response_mode, ) extraction = self._extract_structured_object_from_response( @@ -214,4 +254,3 @@ class LLMTierEscalationMixin: ), ) return None, None - diff --git a/backend/app/services/llm_service/shared.py b/backend/app/services/llm_service/shared.py index 39384b43..4ba435d7 100644 --- a/backend/app/services/llm_service/shared.py +++ b/backend/app/services/llm_service/shared.py @@ -2,3 +2,7 @@ from __future__ import annotations from .shared_part_01 import * from .shared_part_02 import * + +# Preserve underscore-prefixed helper symbols for modules that rely on +# `from .shared import *` (for example: `_env_bool`, `_normalize_provider`). +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 598502b7..837369ab 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -30,7 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...config.ai_prompts import build_default_role_prompt_bundle from ...core.database import AsyncSessionLocal from ...models import AICallLog, Integration -from ..model_context_bundle import ModelContextBundleService +from ..model.model_context_bundle import ModelContextBundleService from ...utils.timezone import now_cn_aware try: @@ -126,6 +126,7 @@ STRUCTURED_OUTPUT_FUNCTION_HINTS = ( "feishu_signal_extraction", "signal_source_onboarding_plan", "signal_source_ai_worker_execution", + "project_draft_", "rag_intent_classification", "classify_intent", "feishu_project_prefill", @@ -290,5 +291,3 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { }, }, } - - diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py index 6e562ae8..6979372f 100644 --- a/backend/app/services/llm_service/shared_part_02.py +++ b/backend/app/services/llm_service/shared_part_02.py @@ -65,10 +65,12 @@ def _looks_like_openclaw_timeout_message(text: str) -> bool: ) return any(signal in lowered for signal in timeout_signals) +@dataclass class ChatCompletionResult: text: str raw_response: Any = None +@dataclass class StructuredExtractionResult: payload: Optional[dict[str, Any]] source_field: str @@ -79,3 +81,5 @@ class StructuredExtractionResult: validation: str = "invalid" from_cache: bool = False + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/model/__init__.py b/backend/app/services/model/__init__.py new file mode 100644 index 00000000..c325098e --- /dev/null +++ b/backend/app/services/model/__init__.py @@ -0,0 +1 @@ +"""Model domain service namespace.""" diff --git a/backend/app/services/model_capability/__init__.py b/backend/app/services/model/model_capability/__init__.py similarity index 100% rename from backend/app/services/model_capability/__init__.py rename to backend/app/services/model/model_capability/__init__.py diff --git a/backend/app/services/model_capability/cost_optimization_service.py b/backend/app/services/model/model_capability/cost_optimization_service.py similarity index 98% rename from backend/app/services/model_capability/cost_optimization_service.py rename to backend/app/services/model/model_capability/cost_optimization_service.py index e2df0985..8fbbf817 100644 --- a/backend/app/services/model_capability/cost_optimization_service.py +++ b/backend/app/services/model/model_capability/cost_optimization_service.py @@ -5,7 +5,7 @@ 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 +from ....models import AICallLog, CostTracking, ModelRequirementBinding, ModelVersion, TruthNode class CostOptimizationService: diff --git a/backend/app/services/model_capability/mixin_cost_tracking.py b/backend/app/services/model/model_capability/mixin_cost_tracking.py similarity index 99% rename from backend/app/services/model_capability/mixin_cost_tracking.py rename to backend/app/services/model/model_capability/mixin_cost_tracking.py index d9994392..5e70b3be 100644 --- a/backend/app/services/model_capability/mixin_cost_tracking.py +++ b/backend/app/services/model/model_capability/mixin_cost_tracking.py @@ -198,7 +198,7 @@ class ModelCapabilityCostTrackingMixin: tenant_id: int, ) -> Dict[str, Any]: """AI 分析成本效益""" - from ...config.ai_prompts import PROMPT_ANALYZE_COST_ROI + from ....config.ai_prompts import PROMPT_ANALYZE_COST_ROI prompt = PROMPT_ANALYZE_COST_ROI.format( total_cost=f"${total_cost:.2f}", diff --git a/backend/app/services/model_capability/mixin_gap_queries.py b/backend/app/services/model/model_capability/mixin_gap_queries.py similarity index 100% rename from backend/app/services/model_capability/mixin_gap_queries.py rename to backend/app/services/model/model_capability/mixin_gap_queries.py diff --git a/backend/app/services/model_capability/mixin_validation.py b/backend/app/services/model/model_capability/mixin_validation.py similarity index 100% rename from backend/app/services/model_capability/mixin_validation.py rename to backend/app/services/model/model_capability/mixin_validation.py diff --git a/backend/app/services/model_capability/model_lifecycle_service.py b/backend/app/services/model/model_capability/model_lifecycle_service.py similarity index 99% rename from backend/app/services/model_capability/model_lifecycle_service.py rename to backend/app/services/model/model_capability/model_lifecycle_service.py index 1f0637f2..8ddfa229 100644 --- a/backend/app/services/model_capability/model_lifecycle_service.py +++ b/backend/app/services/model/model_capability/model_lifecycle_service.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import ( +from ....models import ( ModelRequirementBinding, ModelUpdateImpact, ModelVersion, diff --git a/backend/app/services/model_capability/service.py b/backend/app/services/model/model_capability/service.py similarity index 100% rename from backend/app/services/model_capability/service.py rename to backend/app/services/model/model_capability/service.py diff --git a/backend/app/services/model_capability/shared.py b/backend/app/services/model/model_capability/shared.py similarity index 83% rename from backend/app/services/model_capability/shared.py rename to backend/app/services/model/model_capability/shared.py index 15941ff1..60ed0523 100644 --- a/backend/app/services/model_capability/shared.py +++ b/backend/app/services/model/model_capability/shared.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Union from sqlalchemy import select, and_, func, desc from sqlalchemy.ext.asyncio import AsyncSession -from ...models import ( +from ....models import ( ModelVersion, ModelRequirementBinding, TruthNode, @@ -23,7 +23,7 @@ from ...models import ( CostTracking, User, ) -from ..ai_service_core import AIService +from ...ai_service_core import AIService -from ...utils.timezone import now_cn_naive +from ....utils.timezone import now_cn_naive diff --git a/backend/app/services/model_context_bundle/__init__.py b/backend/app/services/model/model_context_bundle/__init__.py similarity index 100% rename from backend/app/services/model_context_bundle/__init__.py rename to backend/app/services/model/model_context_bundle/__init__.py diff --git a/backend/app/services/model_context_bundle/mixin_build.py b/backend/app/services/model/model_context_bundle/mixin_build.py similarity index 98% rename from backend/app/services/model_context_bundle/mixin_build.py rename to backend/app/services/model/model_context_bundle/mixin_build.py index c45d26bd..e49a18de 100644 --- a/backend/app/services/model_context_bundle/mixin_build.py +++ b/backend/app/services/model/model_context_bundle/mixin_build.py @@ -13,7 +13,7 @@ from .shared import ( datetime, timezone, ) -from ..project_knowledge_graph_service import ProjectKnowledgeGraphService +from ...project_knowledge_graph_service import ProjectKnowledgeGraphService class ModelContextBundleBuildMixin: diff --git a/backend/app/services/model_context_bundle/mixin_core.py b/backend/app/services/model/model_context_bundle/mixin_core.py similarity index 100% rename from backend/app/services/model_context_bundle/mixin_core.py rename to backend/app/services/model/model_context_bundle/mixin_core.py diff --git a/backend/app/services/model_context_bundle/mixin_verify.py b/backend/app/services/model/model_context_bundle/mixin_verify.py similarity index 100% rename from backend/app/services/model_context_bundle/mixin_verify.py rename to backend/app/services/model/model_context_bundle/mixin_verify.py diff --git a/backend/app/services/model_context_bundle/service.py b/backend/app/services/model/model_context_bundle/service.py similarity index 100% rename from backend/app/services/model_context_bundle/service.py rename to backend/app/services/model/model_context_bundle/service.py diff --git a/backend/app/services/model_context_bundle/shared.py b/backend/app/services/model/model_context_bundle/shared.py similarity index 90% rename from backend/app/services/model_context_bundle/shared.py rename to backend/app/services/model/model_context_bundle/shared.py index 61f020d1..4b3935a5 100644 --- a/backend/app/services/model_context_bundle/shared.py +++ b/backend/app/services/model/model_context_bundle/shared.py @@ -10,10 +10,10 @@ from uuid import uuid4 from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import AICallLog +from ....models import AICallLog from ..model_routing import ModelRoutingService -from ..project_knowledge_graph_service import ProjectKnowledgeGraphService -from ..task_brief import ( +from ...project_knowledge_graph_service import ProjectKnowledgeGraphService +from ...task_brief import ( HIGH_CONTRACT_TEMPLATE_ID, HIGH_TEMPLATE_ID, LOW_TEMPLATE_ID, diff --git a/backend/app/services/model_routing/__init__.py b/backend/app/services/model/model_routing/__init__.py similarity index 100% rename from backend/app/services/model_routing/__init__.py rename to backend/app/services/model/model_routing/__init__.py diff --git a/backend/app/services/model_routing/service.py b/backend/app/services/model/model_routing/service.py similarity index 99% rename from backend/app/services/model_routing/service.py rename to backend/app/services/model/model_routing/service.py index 5d58a568..f92d55ee 100644 --- a/backend/app/services/model_routing/service.py +++ b/backend/app/services/model/model_routing/service.py @@ -8,8 +8,8 @@ from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import Integration -from ...utils.timezone import now_cn_naive +from ....models import Integration +from ....utils.timezone import now_cn_naive ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"} ALLOWED_TIERS = {"low", "high", "safety"} diff --git a/backend/app/services/prompt_profile/__init__.py b/backend/app/services/model/prompt_profile/__init__.py similarity index 100% rename from backend/app/services/prompt_profile/__init__.py rename to backend/app/services/model/prompt_profile/__init__.py diff --git a/backend/app/services/prompt_profile/service.py b/backend/app/services/model/prompt_profile/service.py similarity index 98% rename from backend/app/services/prompt_profile/service.py rename to backend/app/services/model/prompt_profile/service.py index 7d534e0a..89bfb726 100644 --- a/backend/app/services/prompt_profile/service.py +++ b/backend/app/services/model/prompt_profile/service.py @@ -10,10 +10,10 @@ 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 +from ....config.ai_prompts import build_default_role_prompt_bundle +from ....models import Integration -from ...utils.timezone import now_cn_aware +from ....utils.timezone import now_cn_aware INTEGRATION_TYPE = "ai_prompt_profiles" ROLE_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,31}$") MAX_ROLES = 12 diff --git a/backend/app/services/project_knowledge_graph_service/shared.py b/backend/app/services/project_knowledge_graph_service/shared.py index bfbf1e17..2b72b460 100644 --- a/backend/app/services/project_knowledge_graph_service/shared.py +++ b/backend/app/services/project_knowledge_graph_service/shared.py @@ -35,7 +35,7 @@ from ...models import ( User, ) from ..context_cache_service import context_cache_service -from ..symbol_index import SymbolIndexService +from ..signal.symbol_index import SymbolIndexService from ...utils.timezone import now_cn_naive @@ -56,3 +56,5 @@ DDL_BLOCKED_PREFIXES = [ DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only" DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程" + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/scheduler_center_core/ai_pmo_cadence.py b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py index 5cdcb1ed..2e9096a9 100644 --- a/backend/app/services/scheduler_center_core/ai_pmo_cadence.py +++ b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py @@ -2,7 +2,20 @@ from __future__ import annotations -from .shared import AIPMOCadenceService, PortfolioSnapshot, Project, and_, async_sessionmaker, asyncio, datetime, logger, os, select, timedelta, workload as workload_service +from .shared import ( + AIPMOCadenceService, + PortfolioSnapshot, + Project, + and_, + async_sessionmaker, + asyncio, + datetime, + logger, + os, + select, + timedelta, + workload_service, +) class AIPMOCadenceScheduler: diff --git a/backend/app/services/scheduler_center_core/knowledge_graph.py b/backend/app/services/scheduler_center_core/knowledge_graph.py index 5b4f0b48..6dcc7de7 100644 --- a/backend/app/services/scheduler_center_core/knowledge_graph.py +++ b/backend/app/services/scheduler_center_core/knowledge_graph.py @@ -2,7 +2,14 @@ from __future__ import annotations -from .shared import ProjectKnowledgeGraphService, async_sessionmaker, asyncio, logger, os, workload as workload_service +from .shared import ( + ProjectKnowledgeGraphService, + async_sessionmaker, + asyncio, + logger, + os, + workload_service, +) class KnowledgeGraphIncrementalScheduler: diff --git a/backend/app/services/scheduler_center_core/shared.py b/backend/app/services/scheduler_center_core/shared.py index 626db119..75cfb172 100644 --- a/backend/app/services/scheduler_center_core/shared.py +++ b/backend/app/services/scheduler_center_core/shared.py @@ -15,7 +15,7 @@ from ...models import KnowledgeBaseDoc, PortfolioSnapshot, Project from ..workload import workload_alert_service from .. import workload as workload_service from ..adaptive_rag_service import AdaptiveRAGService -from ..ai_pmo_cadence import AIPMOCadenceService +from ..ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ..project_knowledge_graph_service import ProjectKnowledgeGraphService logger = get_logger(__name__) diff --git a/backend/app/services/signal/__init__.py b/backend/app/services/signal/__init__.py new file mode 100644 index 00000000..862b694d --- /dev/null +++ b/backend/app/services/signal/__init__.py @@ -0,0 +1 @@ +"""Signal domain service namespace.""" diff --git a/backend/app/services/signal_capture_service/__init__.py b/backend/app/services/signal/signal_capture_service/__init__.py similarity index 100% rename from backend/app/services/signal_capture_service/__init__.py rename to backend/app/services/signal/signal_capture_service/__init__.py diff --git a/backend/app/services/signal_capture_service/external_fetcher.py b/backend/app/services/signal/signal_capture_service/external_fetcher.py similarity index 100% rename from backend/app/services/signal_capture_service/external_fetcher.py rename to backend/app/services/signal/signal_capture_service/external_fetcher.py diff --git a/backend/app/services/signal_capture_service/impact_analyzer.py b/backend/app/services/signal/signal_capture_service/impact_analyzer.py similarity index 100% rename from backend/app/services/signal_capture_service/impact_analyzer.py rename to backend/app/services/signal/signal_capture_service/impact_analyzer.py diff --git a/backend/app/services/signal_capture_service/mixin_analysis_normalize.py b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py similarity index 98% rename from backend/app/services/signal_capture_service/mixin_analysis_normalize.py rename to backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py index 490ca424..48fb862c 100644 --- a/backend/app/services/signal_capture_service/mixin_analysis_normalize.py +++ b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py @@ -22,7 +22,7 @@ class SignalCaptureAnalysisNormalizeMixin: pass # 兼容模型返回“解释文本 + JSON”场景:抽取首个 JSON 对象再解析 - obj_text = ProactiveSignalCaptureService._extract_first_json_object(content) + obj_text = SignalCaptureAnalysisNormalizeMixin._extract_first_json_object(content) if not obj_text: raise ValueError("empty llm response") parsed = json.loads(obj_text) @@ -150,7 +150,7 @@ class SignalCaptureAnalysisNormalizeMixin: @staticmethod def _sanitize_feishu_message(message: str) -> str: - text = ProactiveSignalCaptureService._normalize_single_line_text(message or "", max_len=2000) + text = SignalCaptureAnalysisNormalizeMixin._normalize_single_line_text(message or "", max_len=2000) if not text: return "" @@ -265,4 +265,3 @@ class SignalCaptureAnalysisNormalizeMixin: def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: _ = message return None - diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py new file mode 100644 index 00000000..742c190a --- /dev/null +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureEntryMixin: + 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, + force_sync: bool = False, + ) -> Dict[str, Any]: + """ + 从飞书群聊捕获信号 + + 触发条件: + - 提到产品、竞品、技术关键词 + - 讨论架构、技术选型 + - 分享技术文章 + """ + trace_id = f"feishu-{uuid4().hex[:12]}" + captured_dt = captured_at or now_cn_aware() + if captured_dt.tzinfo is None: + captured_dt = captured_dt.replace(tzinfo=CN_TZ) + + 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"} + + kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) + try: + should_sync_process = bool(force_sync) + if not should_sync_process: + session = await kernel._get_or_create_session( + tenant_id=int(tenant_id), + sender=proposer_name, + ) + should_sync_process = kernel._is_short_confirmation_message( + message_text=cleaned_message, + session=session, + ) + + if should_sync_process: + return await self._process_feishu_with_kernel_sync( + trace_id=trace_id, + tenant_id=tenant_id, + kernel=kernel, + group_chat_id=group_chat_id, + cleaned_message=cleaned_message, + proposer_name=proposer_name, + proposer_open_id=proposer_open_id, + captured_dt=captured_dt, + feishu_message_id=feishu_message_id, + chat_type=chat_type, + ) + + 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"} + + source_message_id = str(feishu_message_id or "").strip() + if not source_message_id: + source_message_id = AIPMOKernelService._build_source_message_id( + source_system="feishu", + source_message_id=None, + channel_ref=group_chat_id, + sender=proposer_name, + message_text=cleaned_message, + ) + + run_row, created = await AIOrchestrationRunService(self.db).enqueue_ingress_run( + tenant_id=int(tenant_id), + source_system="feishu", + source_message_id=source_message_id, + sender=proposer_name, + message_text=cleaned_message, + channel_ref=group_chat_id, + source_sender_open_id=proposer_open_id, + chat_type=chat_type, + captured_at=captured_dt, + project_hint_id=None, + max_retries=3, + ) + if created: + await AIOrchestrationRunService(self.db).mark_ingress_ack_sent( + tenant_id=int(tenant_id), + trace_id=str(run_row.trace_id or ""), + ) + await self.db.commit() + pipeline_result = self._build_async_processing_ack_result( + trace_id=str(run_row.trace_id or ""), + ) + else: + await self.db.commit() + pipeline_result = { + "status": "ignored", + "reason": "duplicate_filtered", + "classification": "duplicate", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": str(run_row.trace_id or trace_id), + "direct_reply_required": False, + "direct_reply_text": "", + } + 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), + ) + 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": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + + return await self._capture_from_feishu_legacy_flow( + trace_id=trace_id, + group_chat_id=group_chat_id, + message_content=message_content, + cleaned_message=cleaned_message, + proposer_name=proposer_name, + proposer_open_id=proposer_open_id, + mentioned_bot_names=mentioned_bot_names, + tenant_id=tenant_id, + captured_dt=captured_dt, + ) + + async def _process_feishu_with_kernel_sync( + self, + *, + trace_id: str, + tenant_id: int, + kernel: AIPMOKernelService, + group_chat_id: str, + cleaned_message: str, + proposer_name: str, + proposer_open_id: Optional[str], + captured_dt: datetime, + feishu_message_id: Optional[str], + chat_type: Optional[str], + ) -> Dict[str, Any]: + 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 self._build_kernel_error_result(trace_id=trace_id, reason=str(pipeline_result.get("reason") or "kernel_not_handled")) + + 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 + + @staticmethod + def _build_kernel_error_result(*, trace_id: str, reason: str) -> Dict[str, Any]: + return { + "status": "error", + "reason": str(reason or "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": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + + @staticmethod + def _build_async_processing_ack_result(*, trace_id: str) -> Dict[str, Any]: + return { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async", + } + ], + "direct_reply_required": True, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": None, + "decision_level": None, + "review_summary": None, + } diff --git a/backend/app/services/signal_capture_service/mixin_capture_legacy.py b/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_capture_legacy.py rename to backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py diff --git a/backend/app/services/signal_capture_service/mixin_external_sources.py b/backend/app/services/signal/signal_capture_service/mixin_external_sources.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_external_sources.py rename to backend/app/services/signal/signal_capture_service/mixin_external_sources.py diff --git a/backend/app/services/signal_capture_service/mixin_new_project.py b/backend/app/services/signal/signal_capture_service/mixin_new_project.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_new_project.py rename to backend/app/services/signal/signal_capture_service/mixin_new_project.py diff --git a/backend/app/services/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_pipeline_cache.py rename to backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py diff --git a/backend/app/services/signal_capture_service/mixin_source_analyzers.py b/backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py similarity index 99% rename from backend/app/services/signal_capture_service/mixin_source_analyzers.py rename to backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py index 5f3f37b0..ec6f4cf5 100644 --- a/backend/app/services/signal_capture_service/mixin_source_analyzers.py +++ b/backend/app/services/signal/signal_capture_service/mixin_source_analyzers.py @@ -191,7 +191,7 @@ URL: {url} {"project_id": project_id, "signal_id": signal_id}, ) if existing.first() is None: - from ...models import ProjectSignalLink + from ....models import ProjectSignalLink link = ProjectSignalLink( project_id=project_id, signal_id=signal_id, diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_main.py b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_task_orchestration_main.py rename to backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py rename to backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_team.py b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_team.py similarity index 100% rename from backend/app/services/signal_capture_service/mixin_task_orchestration_team.py rename to backend/app/services/signal/signal_capture_service/mixin_task_orchestration_team.py diff --git a/backend/app/services/signal_capture_service/service.py b/backend/app/services/signal/signal_capture_service/service.py similarity index 100% rename from backend/app/services/signal_capture_service/service.py rename to backend/app/services/signal/signal_capture_service/service.py diff --git a/backend/app/services/signal_capture_service/shared.py b/backend/app/services/signal/signal_capture_service/shared.py similarity index 85% rename from backend/app/services/signal_capture_service/shared.py rename to backend/app/services/signal/signal_capture_service/shared.py index 11a7359b..e0247dc0 100644 --- a/backend/app/services/signal_capture_service/shared.py +++ b/backend/app/services/signal/signal_capture_service/shared.py @@ -26,29 +26,30 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import and_, text -from ...core.logging import get_logger -from ...core.database import AsyncSessionLocal -from ...models import ( +from ....core.logging import get_logger +from ....core.database import AsyncSessionLocal +from ....models import ( Signal, Project, ProjectDomain, User, ProjectSignalLink, Task, TruthNode, SkillTag, UserProject, UserWorkload, UserSkillBinding, ) -from ...config.ai_prompts import ( +from ....config.ai_prompts import ( PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_TASK_ORCHESTRATION, ) -from ..llm_service import LLMService -from ..notification_service import NotificationService -from .. import workload as workload_service -from ..adaptive_rag_service import AdaptiveRAGService -from ..context_cache_service import context_cache_service +from ...llm_service import LLMService +from ...notification_service import NotificationService +from ... import workload as workload_service +from ...adaptive_rag_service import AdaptiveRAGService +from ...ai_orchestration_run import AIOrchestrationRunService +from ...context_cache_service import context_cache_service from ..signal_source_orchestration import SignalSourceOrchestrationService -from ..ai_pmo_kernel import AIPMOKernelService -from ...utils.project_members import ensure_project_default_members -from ...utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence +from ...ai_pmo.ai_pmo_kernel import AIPMOKernelService +from ....utils.project_members import ensure_project_default_members +from ....utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence -from ...utils.timezone import CN_TZ, now_cn_aware +from ....utils.timezone import CN_TZ, now_cn_aware EMOJI_UNICODE_RE = re.compile( r"[\U0001F300-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF" r"\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF" @@ -171,3 +172,4 @@ def _build_feishu_rag_conversation_key( return f"feishu:{chat}:{actor}" +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/signal_planner/__init__.py b/backend/app/services/signal/signal_planner/__init__.py similarity index 100% rename from backend/app/services/signal_planner/__init__.py rename to backend/app/services/signal/signal_planner/__init__.py diff --git a/backend/app/services/signal_planner/mixin_analyze.py b/backend/app/services/signal/signal_planner/mixin_analyze.py similarity index 100% rename from backend/app/services/signal_planner/mixin_analyze.py rename to backend/app/services/signal/signal_planner/mixin_analyze.py diff --git a/backend/app/services/signal_planner/mixin_loaders.py b/backend/app/services/signal/signal_planner/mixin_loaders.py similarity index 100% rename from backend/app/services/signal_planner/mixin_loaders.py rename to backend/app/services/signal/signal_planner/mixin_loaders.py diff --git a/backend/app/services/signal_planner/mixin_prompt.py b/backend/app/services/signal/signal_planner/mixin_prompt.py similarity index 100% rename from backend/app/services/signal_planner/mixin_prompt.py rename to backend/app/services/signal/signal_planner/mixin_prompt.py diff --git a/backend/app/services/signal_planner/mixin_utils.py b/backend/app/services/signal/signal_planner/mixin_utils.py similarity index 100% rename from backend/app/services/signal_planner/mixin_utils.py rename to backend/app/services/signal/signal_planner/mixin_utils.py diff --git a/backend/app/services/signal_planner/service.py b/backend/app/services/signal/signal_planner/service.py similarity index 100% rename from backend/app/services/signal_planner/service.py rename to backend/app/services/signal/signal_planner/service.py diff --git a/backend/app/services/signal_planner/shared.py b/backend/app/services/signal/signal_planner/shared.py similarity index 74% rename from backend/app/services/signal_planner/shared.py rename to backend/app/services/signal/signal_planner/shared.py index 5dc2b4c9..627f139b 100644 --- a/backend/app/services/signal_planner/shared.py +++ b/backend/app/services/signal/signal_planner/shared.py @@ -9,10 +9,10 @@ from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from ...config.ai_prompts import PROMPT_SIGNAL_PLANNER -from ...core.logging import get_logger -from ...core.database import AsyncSessionLocal -from ...models import ( +from ....config.ai_prompts import PROMPT_SIGNAL_PLANNER +from ....core.logging import get_logger +from ....core.database import AsyncSessionLocal +from ....models import ( Project, ProjectSignalLink, Signal, @@ -22,10 +22,10 @@ from ...models import ( UserSkillBinding, UserWorkload, ) -from ...utils.timezone import now_cn_naive -from ..feishu_outbox import FeishuOutboxService -from ..llm_service import llm_service -from ..model_routing import ModelRoutingService +from ....utils.timezone import now_cn_naive +from ...integrations.feishu_outbox import FeishuOutboxService +from ...llm_service import llm_service +from ...model.model_routing import ModelRoutingService from .signal_strategic_analysis_service import SignalStrategicAnalysisService logger = get_logger(__name__) diff --git a/backend/app/services/signal_planner/signal_strategic_analysis_service.py b/backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py similarity index 97% rename from backend/app/services/signal_planner/signal_strategic_analysis_service.py rename to backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py index 65c6a04a..16ef25d7 100644 --- a/backend/app/services/signal_planner/signal_strategic_analysis_service.py +++ b/backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py @@ -8,8 +8,8 @@ from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import Signal -from ...utils.timezone import now_cn_naive +from ....models import Signal +from ....utils.timezone import now_cn_naive class SignalStrategicAnalysisService: diff --git a/backend/app/services/signal_source_orchestration/__init__.py b/backend/app/services/signal/signal_source_orchestration/__init__.py similarity index 100% rename from backend/app/services/signal_source_orchestration/__init__.py rename to backend/app/services/signal/signal_source_orchestration/__init__.py diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py rename to backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_a.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py rename to backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_a.py diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py rename to backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py diff --git a/backend/app/services/signal_source_orchestration/mixin_execute_isolation.py b/backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_execute_isolation.py rename to backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py diff --git a/backend/app/services/signal_source_orchestration/mixin_lookup_parse.py b/backend/app/services/signal/signal_source_orchestration/mixin_lookup_parse.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_lookup_parse.py rename to backend/app/services/signal/signal_source_orchestration/mixin_lookup_parse.py diff --git a/backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py b/backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py rename to backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py diff --git a/backend/app/services/signal_source_orchestration/mixin_planning_onboard.py b/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_planning_onboard.py rename to backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py diff --git a/backend/app/services/signal_source_orchestration/mixin_registry_execute.py b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_registry_execute.py rename to backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py diff --git a/backend/app/services/signal_source_orchestration/mixin_result_resolution.py b/backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_result_resolution.py rename to backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py diff --git a/backend/app/services/signal_source_orchestration/mixin_result_run_updates.py b/backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_result_run_updates.py rename to backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py diff --git a/backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py b/backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py similarity index 100% rename from backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py rename to backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py diff --git a/backend/app/services/signal_source_orchestration/service.py b/backend/app/services/signal/signal_source_orchestration/service.py similarity index 100% rename from backend/app/services/signal_source_orchestration/service.py rename to backend/app/services/signal/signal_source_orchestration/service.py diff --git a/backend/app/services/signal_source_orchestration/shared.py b/backend/app/services/signal/signal_source_orchestration/shared.py similarity index 82% rename from backend/app/services/signal_source_orchestration/shared.py rename to backend/app/services/signal/signal_source_orchestration/shared.py index 19c22393..066c2538 100644 --- a/backend/app/services/signal_source_orchestration/shared.py +++ b/backend/app/services/signal/signal_source_orchestration/shared.py @@ -16,23 +16,23 @@ from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from ...config.ai_prompts import ( +from ....config.ai_prompts import ( PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_SIGNAL_SOURCE_ONBOARDING, ) -from ...models import ( +from ....models import ( AIWorkerExecution, Project, Signal, SignalSourceRegistry, User, ) -from ...core.database import AsyncSessionLocal -from ..ai_orchestration_run import AIOrchestrationRunService -from ..prompt_profile import prompt_profile_service -from ..llm_service import llm_service +from ....core.database import AsyncSessionLocal +from ...ai_orchestration_run import AIOrchestrationRunService +from ...model.prompt_profile import prompt_profile_service +from ...llm_service import llm_service -from ...utils.timezone import now_cn_naive +from ....utils.timezone import now_cn_naive SOURCE_ONBOARDING_ACTION_RE = re.compile( r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)", re.IGNORECASE, @@ -54,4 +54,3 @@ COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0 def _utc_now_naive() -> datetime: return now_cn_naive() - diff --git a/backend/app/services/signal_source_repair/__init__.py b/backend/app/services/signal/signal_source_repair/__init__.py similarity index 100% rename from backend/app/services/signal_source_repair/__init__.py rename to backend/app/services/signal/signal_source_repair/__init__.py diff --git a/backend/app/services/signal_source_repair/errors.py b/backend/app/services/signal/signal_source_repair/errors.py similarity index 100% rename from backend/app/services/signal_source_repair/errors.py rename to backend/app/services/signal/signal_source_repair/errors.py diff --git a/backend/app/services/signal_source_repair/mixin_capture.py b/backend/app/services/signal/signal_source_repair/mixin_capture.py similarity index 100% rename from backend/app/services/signal_source_repair/mixin_capture.py rename to backend/app/services/signal/signal_source_repair/mixin_capture.py diff --git a/backend/app/services/signal_source_repair/mixin_data_access.py b/backend/app/services/signal/signal_source_repair/mixin_data_access.py similarity index 100% rename from backend/app/services/signal_source_repair/mixin_data_access.py rename to backend/app/services/signal/signal_source_repair/mixin_data_access.py diff --git a/backend/app/services/signal_source_repair/mixin_persist.py b/backend/app/services/signal/signal_source_repair/mixin_persist.py similarity index 100% rename from backend/app/services/signal_source_repair/mixin_persist.py rename to backend/app/services/signal/signal_source_repair/mixin_persist.py diff --git a/backend/app/services/signal_source_repair/mixin_repair.py b/backend/app/services/signal/signal_source_repair/mixin_repair.py similarity index 100% rename from backend/app/services/signal_source_repair/mixin_repair.py rename to backend/app/services/signal/signal_source_repair/mixin_repair.py diff --git a/backend/app/services/signal_source_repair/mixin_utils.py b/backend/app/services/signal/signal_source_repair/mixin_utils.py similarity index 100% rename from backend/app/services/signal_source_repair/mixin_utils.py rename to backend/app/services/signal/signal_source_repair/mixin_utils.py diff --git a/backend/app/services/signal_source_repair/service.py b/backend/app/services/signal/signal_source_repair/service.py similarity index 100% rename from backend/app/services/signal_source_repair/service.py rename to backend/app/services/signal/signal_source_repair/service.py diff --git a/backend/app/services/signal_source_repair/shared.py b/backend/app/services/signal/signal_source_repair/shared.py similarity index 70% rename from backend/app/services/signal_source_repair/shared.py rename to backend/app/services/signal/signal_source_repair/shared.py index 2b349c5f..ad5eadc2 100644 --- a/backend/app/services/signal_source_repair/shared.py +++ b/backend/app/services/signal/signal_source_repair/shared.py @@ -10,11 +10,11 @@ from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import SignalSourceRegistry, User -from ...utils.audited_write import get_audit_context -from ...utils.timezone import now_cn_naive -from ..notification_service import NotificationService -from ..operation_log_service import log_operation +from ....models import SignalSourceRegistry, User +from ....utils.audited_write import get_audit_context +from ....utils.timezone import now_cn_naive +from ...notification_service import NotificationService +from ...operation_log_service import log_operation from ..signal_capture_service import ProactiveSignalCaptureService __all__ = [ diff --git a/backend/app/services/symbol_index/__init__.py b/backend/app/services/signal/symbol_index/__init__.py similarity index 100% rename from backend/app/services/symbol_index/__init__.py rename to backend/app/services/signal/symbol_index/__init__.py diff --git a/backend/app/services/symbol_index/mixin_parse_python.py b/backend/app/services/signal/symbol_index/mixin_parse_python.py similarity index 100% rename from backend/app/services/symbol_index/mixin_parse_python.py rename to backend/app/services/signal/symbol_index/mixin_parse_python.py diff --git a/backend/app/services/symbol_index/mixin_parse_typescript.py b/backend/app/services/signal/symbol_index/mixin_parse_typescript.py similarity index 100% rename from backend/app/services/symbol_index/mixin_parse_typescript.py rename to backend/app/services/signal/symbol_index/mixin_parse_typescript.py diff --git a/backend/app/services/symbol_index/mixin_query_overview.py b/backend/app/services/signal/symbol_index/mixin_query_overview.py similarity index 100% rename from backend/app/services/symbol_index/mixin_query_overview.py rename to backend/app/services/signal/symbol_index/mixin_query_overview.py diff --git a/backend/app/services/symbol_index/mixin_serialize.py b/backend/app/services/signal/symbol_index/mixin_serialize.py similarity index 100% rename from backend/app/services/symbol_index/mixin_serialize.py rename to backend/app/services/signal/symbol_index/mixin_serialize.py diff --git a/backend/app/services/symbol_index/mixin_sync_core.py b/backend/app/services/signal/symbol_index/mixin_sync_core.py similarity index 100% rename from backend/app/services/symbol_index/mixin_sync_core.py rename to backend/app/services/signal/symbol_index/mixin_sync_core.py diff --git a/backend/app/services/symbol_index/service.py b/backend/app/services/signal/symbol_index/service.py similarity index 100% rename from backend/app/services/symbol_index/service.py rename to backend/app/services/signal/symbol_index/service.py diff --git a/backend/app/services/symbol_index/shared.py b/backend/app/services/signal/symbol_index/shared.py similarity index 91% rename from backend/app/services/symbol_index/shared.py rename to backend/app/services/signal/symbol_index/shared.py index fbbc6b46..e6ba6b54 100644 --- a/backend/app/services/symbol_index/shared.py +++ b/backend/app/services/signal/symbol_index/shared.py @@ -19,10 +19,10 @@ from typing import Any, Optional from sqlalchemy import and_, delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...models import SymbolDefinition, SymbolReference +from ....models import SymbolDefinition, SymbolReference -from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive +from ....utils.timezone import CN_TZ, now_cn_aware, now_cn_naive 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, diff --git a/backend/app/services/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal_capture_service/mixin_capture_entry.py deleted file mode 100644 index 075c816d..00000000 --- a/backend/app/services/signal_capture_service/mixin_capture_entry.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -from .shared import * - -class SignalCaptureEntryMixin: - 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 now_cn_aware() - if captured_dt.tzinfo is None: - captured_dt = captured_dt.replace(tzinfo=CN_TZ) - - 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": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", - } - - return await self._capture_from_feishu_legacy_flow( - trace_id=trace_id, - group_chat_id=group_chat_id, - message_content=message_content, - cleaned_message=cleaned_message, - proposer_name=proposer_name, - proposer_open_id=proposer_open_id, - mentioned_bot_names=mentioned_bot_names, - tenant_id=tenant_id, - captured_dt=captured_dt, - ) - diff --git a/backend/app/services/skill/shared.py b/backend/app/services/skill/shared.py index e7e6ef1a..aa343d74 100644 --- a/backend/app/services/skill/shared.py +++ b/backend/app/services/skill/shared.py @@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ...models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task -from ..ai_pmo_kernel import AIPMOKernelService +from ..ai_pmo.ai_pmo_kernel import AIPMOKernelService # 系统预设 AI 开发专属技能标签 @@ -35,4 +35,3 @@ SYSTEM_SKILL_TAGS = [ {"name": "Git", "slug": "git", "category": "tool", "sort_order": 12}, ] - diff --git a/backend/app/services/sstc/charter_data.py b/backend/app/services/sstc/charter_data.py index 061d598a..12579e3c 100644 --- a/backend/app/services/sstc/charter_data.py +++ b/backend/app/services/sstc/charter_data.py @@ -118,11 +118,20 @@ def _serialize_charter_task(task: Task) -> dict[str, Any]: "due_date": task.due_date.isoformat() if task.due_date else None, } -def _serialize_alignment_record(record: AlignmentRecord) -> dict[str, Any]: +def _serialize_alignment_record( + record: AlignmentRecord, + user_name: Optional[str] = None, +) -> dict[str, Any]: + confirmed_at = None + if bool(record.is_confirmed): + ts = record.updated_at or record.created_at + confirmed_at = ts.isoformat() if ts else None + return { "user_id": str(record.user_id), + "user_name": user_name or f"用户 {record.user_id}", "score": record.alignment_score, "confirmed": bool(record.is_confirmed), "comments": record.comments, + "confirmed_at": confirmed_at, } - diff --git a/backend/app/services/sstc/shared.py b/backend/app/services/sstc/shared.py index a950ccb1..2ad74c29 100644 --- a/backend/app/services/sstc/shared.py +++ b/backend/app/services/sstc/shared.py @@ -48,7 +48,7 @@ from ...schemas.sstc import ( ) from ...services.llm_service import llm_service from ...services.ai_service_core import AIService -from ...services.ai_pmo_kernel import AIPMOKernelService +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.operation_log_service import log_operation from ...services.auth import has_any_role from ...services.skill import recommend_personnel @@ -61,7 +61,7 @@ from ...services.sstc_service_core import ( ) from ...services import workload as workload_service from ...services.budget_enforcement import BudgetEnforcementService -from ...services.feishu_integration_service import get_feishu_service +from ...services.integrations.feishu_integration_service import get_feishu_service from ...services.adaptive_rag_service import AdaptiveRAGService from ...services.context_cache_service import context_cache_service from ...services.task_persistence_core import build_task_create_draft, persist_task @@ -142,4 +142,6 @@ RELEASE_STATUS_TRANSITIONS: dict[str, set[str]] = { "verified": set(), } +# Keep underscore-prefixed constants/functions available to `from .shared import *`. +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/sstc_service_core/mixin_build_views.py b/backend/app/services/sstc_service_core/mixin_build_views.py index e6d9bd5b..7f94d872 100644 --- a/backend/app/services/sstc_service_core/mixin_build_views.py +++ b/backend/app/services/sstc_service_core/mixin_build_views.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared_helpers import * +from .shared_helpers import _to_json class SSTCBuildViewsMixin: async def build_overview(self, project_id: int) -> dict[str, Any]: @@ -295,4 +296,3 @@ class SSTCBuildViewsMixin: ], "tasks_by_charter": tasks_by_charter, } - diff --git a/backend/app/services/sstc_service_core/mixin_fetch_core.py b/backend/app/services/sstc_service_core/mixin_fetch_core.py index 310fd5a4..a0a02c1e 100644 --- a/backend/app/services/sstc_service_core/mixin_fetch_core.py +++ b/backend/app/services/sstc_service_core/mixin_fetch_core.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared_helpers import * +from .shared_helpers import _avg class SSTCFetchCoreMixin: def __init__(self, db: AsyncSession, tenant_id: int): @@ -246,4 +247,3 @@ class SSTCFetchCoreMixin: "overdue": overdue, "progress": round((done / total) * 100, 2) if total else 0, } - diff --git a/backend/app/services/sstc_service_core/service.py b/backend/app/services/sstc_service_core/service.py index d3f57f64..25a46d0f 100644 --- a/backend/app/services/sstc_service_core/service.py +++ b/backend/app/services/sstc_service_core/service.py @@ -3,6 +3,15 @@ from __future__ import annotations from .mixin_build_views import SSTCBuildViewsMixin from .mixin_fetch_core import SSTCFetchCoreMixin from .mixin_members import SSTCMembersMixin +from .shared import ( + ALIGNED_STATUSES, + ARCHIVED_STATUSES, + BLOCKED_STATUSES, + DONE_STATUSES, + RELEASE_LIVE_STATUSES, + RELEASE_RELEASING_STATUSES, + RELEASE_VERIFIED_STATUSES, +) class SSTCService( diff --git a/backend/app/services/sstc_service_core/shared.py b/backend/app/services/sstc_service_core/shared.py index dd180a68..084914f7 100644 --- a/backend/app/services/sstc_service_core/shared.py +++ b/backend/app/services/sstc_service_core/shared.py @@ -56,3 +56,6 @@ def _avg(values: Iterable[float]) -> float: return float(sum(values) / len(values)) +# Keep underscore-prefixed helpers available to `from .shared import *`. +__all__ = [name for name in globals() if not name.startswith("__")] + diff --git a/backend/app/services/sstc_service_core/shared_helpers.py b/backend/app/services/sstc_service_core/shared_helpers.py index 4803b670..bce5de56 100644 --- a/backend/app/services/sstc_service_core/shared_helpers.py +++ b/backend/app/services/sstc_service_core/shared_helpers.py @@ -22,3 +22,6 @@ def _avg(values: Iterable[float]) -> float: return 0.0 return float(sum(values) / len(values)) + +# Keep underscore-prefixed helpers available to `from .shared_helpers import *`. +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/task_brief/__init__.py b/backend/app/services/task_brief/__init__.py index 01ccb308..279cbc92 100644 --- a/backend/app/services/task_brief/__init__.py +++ b/backend/app/services/task_brief/__init__.py @@ -1,5 +1,11 @@ from __future__ import annotations from .service import TaskBriefService +from .shared import HIGH_CONTRACT_TEMPLATE_ID, HIGH_TEMPLATE_ID, LOW_TEMPLATE_ID -__all__ = ["TaskBriefService"] +__all__ = [ + "TaskBriefService", + "LOW_TEMPLATE_ID", + "HIGH_TEMPLATE_ID", + "HIGH_CONTRACT_TEMPLATE_ID", +] diff --git a/backend/app/services/task_persistence_core/persist.py b/backend/app/services/task_persistence_core/persist.py index 518f858b..5e707f0a 100644 --- a/backend/app/services/task_persistence_core/persist.py +++ b/backend/app/services/task_persistence_core/persist.py @@ -3,7 +3,7 @@ from __future__ import annotations from .models import TaskCreateDraft -from .shared import AsyncSession, Optional, Task, log_operation, workload as workload_service +from .shared import AsyncSession, Optional, Task, log_operation, workload_service async def persist_task( diff --git a/backend/app/services/workload/workload_alert_service.py b/backend/app/services/workload/workload_alert_service.py index e8e586c0..cd113e38 100644 --- a/backend/app/services/workload/workload_alert_service.py +++ b/backend/app/services/workload/workload_alert_service.py @@ -8,10 +8,10 @@ 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 ..integration_core.lark_service import lark_service +from ...models import OperationLog, Task, User, UserProject, UserWorkload +from ..integrations.integration_core.lark_service import lark_service -from ..utils.timezone import now_cn_naive +from ...utils.timezone import now_cn_naive DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} diff --git a/backend/scripts/p1_real_validate.py b/backend/scripts/p1_real_validate.py new file mode 100644 index 00000000..2c27de67 --- /dev/null +++ b/backend/scripts/p1_real_validate.py @@ -0,0 +1,313 @@ +import asyncio +import json +import time +import urllib.request +import urllib.parse +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import select, func + +from app.core.database import AsyncSessionLocal +from app.models import AIOrchestrationRun, FeishuReplyOutbox, AIProcessingLog, AICallLog + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" + +CASES = [ + {"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]}, + {"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]}, + {"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]}, + {"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]}, + {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, +] + +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, *, message: str, message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=240) as resp: + text = resp.read().decode("utf-8") + try: + return json.loads(text) + except Exception: + return {"raw": text} + + +def detect_prompt_scene(prompt: str) -> Optional[str]: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return None + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(reply: str) -> bool: + for line in str(reply or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def fetch_max_ai_call_id(db) -> int: + m = (await db.execute(select(func.max(AICallLog.id)))).scalar() + return int(m or 0) + + +async def wait_run(db, source_message_id: str, timeout: int = 540) -> Optional[AIOrchestrationRun]: + deadline = time.time() + timeout + last = None + while time.time() < deadline: + row = ( + await db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.source_message_id == source_message_id) + .order_by(AIOrchestrationRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is not None: + last = row + s = str(row.status or "").lower() + if s in {"succeeded", "failed", "cancelled"}: + return row + await asyncio.sleep(2) + return last + + +async def fetch_outbox(db, source_message_id: str): + return ( + await db.execute( + select(FeishuReplyOutbox) + .where(FeishuReplyOutbox.source_message_id == source_message_id) + .order_by(FeishuReplyOutbox.id.asc()) + ) + ).scalars().all() + + +async def wait_outbox(db, source_message_id: str, run_status: str, timeout: int = 240): + deadline = time.time() + timeout + expected = 2 if run_status == "succeeded" else 1 + latest = [] + while time.time() < deadline: + latest = await fetch_outbox(db, source_message_id) + sent = [r for r in latest if str(r.status or "") == "sent"] + if len(sent) >= expected: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(db, source_message_id: str): + return ( + await db.execute( + select(AIProcessingLog) + .where(AIProcessingLog.feishu_message_id == source_message_id) + .order_by(AIProcessingLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + +async def fetch_scene_from_call_logs(db, min_id: int, keys: list[str]): + rows = ( + await db.execute( + select(AICallLog) + .where( + AICallLog.id > min_id, + AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]), + ) + .order_by(AICallLog.id.asc()) + .limit(160) + ) + ).scalars().all() + + picked = "unknown" + inspected = [] + lk = [k.lower() for k in keys if k] + for r in rows: + prompt = str(r.prompt or "") + pl = prompt.lower() + if lk and not any(k in pl for k in lk): + continue + sc = detect_prompt_scene(prompt) + inspected.append( + { + "id": int(r.id), + "scene": sc or "unknown", + "function_name": str(r.function_name or ""), + "created_at": str(r.created_at or ""), + } + ) + if sc: + picked = sc + + if picked == "unknown" and inspected: + picked = str(inspected[-1].get("scene") or "unknown") + return picked, inspected + + +def evaluate(case_idx: int, scene: str, reply: str, run_status: str): + leak = has_thinking_leak(reply) + if case_idx == 1: + ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak + return ok, "member_workload+百分比+无项目列表" + if case_idx == 2: + sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply + src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply + ok = scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段+来源限制" + if case_idx == 3: + mixed = "[回答]" in reply and "[已收到需求]" in reply + prog = "进行中" in reply + ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建+进行中数量" + if case_idx == 4: + ok = scene == "default" and "完成率" in reply and "%" in reply and not leak + return ok, "default+完成率" + if case_idx == 5: + ok = scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def run_case(token: str, case: dict[str, Any]): + idx = int(case["idx"]) + message = str(case["message"]) + source_message_id = f"real-scene-p1-v2-{idx}-{int(time.time())}" + + async with AsyncSessionLocal() as db: + before_call_id = await fetch_max_ai_call_id(db) + + capture = post_capture(token, message=message, message_id=source_message_id) + + async with AsyncSessionLocal() as db: + run_row = await wait_run(db, source_message_id) + run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing" + outbox_rows = await wait_outbox(db, source_message_id, run_status) + processing = await fetch_processing(db, source_message_id) + prompt_scene, candidates = await fetch_scene_from_call_logs(db, before_call_id, list(case.get("keys") or [])) + + sent = [r for r in outbox_rows if str(r.status or "") == "sent"] + ack = str(sent[0].message_text or "").strip() if sent else "" + final_reply = str(sent[-1].message_text or "").strip() if sent else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + + passed, rule = evaluate(idx, actual_scene, final_reply, run_status) + + return { + "idx": idx, + "source_message_id": source_message_id, + "message": message, + "expected_scene": str(case["expected_scene"]), + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "ack": ack, + "final_reply": final_reply, + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "thinking_leak": has_thinking_leak(final_reply), + "processing": { + "route": str(getattr(processing, "route", "") or ""), + "intent": str(getattr(processing, "intent", "") or ""), + "status": str(getattr(processing, "processing_status", "") or ""), + "duration_ms": int(getattr(processing, "duration_ms", 0) or 0), + "error_message": str(getattr(processing, "error_message", "") or ""), + "reply_source": str(getattr(processing, "reply_source", "") or ""), + }, + "outbox": [ + { + "id": int(r.id), + "status": str(r.status or ""), + "attempts": int(r.attempts or 0), + "created_at": str(r.created_at or ""), + "text": str(r.message_text or ""), + } + for r in outbox_rows + ], + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": rule, + } + + +async def main(): + token = login_token() + if not token: + raise RuntimeError("token empty") + + results = [] + for case in CASES: + print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True) + one = await run_case(token, case) + print(f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']}", flush=True) + results.append(one) + await asyncio.sleep(2) + + payload = { + "generated_at": datetime.now().isoformat(), + "chat_id": CHAT_ID, + "results": results, + } + print("===RESULT_JSON_START===") + print(json.dumps(payload, ensure_ascii=False)) + print("===RESULT_JSON_END===") + + +asyncio.run(main()) diff --git a/backend/tmp_p1_real_validate_v4.py b/backend/tmp_p1_real_validate_v4.py new file mode 100644 index 00000000..1b5360d8 --- /dev/null +++ b/backend/tmp_p1_real_validate_v4.py @@ -0,0 +1,330 @@ +import asyncio +import json +import time +import urllib.parse +import urllib.request +from datetime import datetime +from typing import Any + +from sqlalchemy import text + +from app.core.database import AsyncSessionLocal + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" + +CASES = [ + {"idx": 1, "message": "杨宇现在负载多少", "expected_scene": "member_workload", "keys": ["杨宇", "负载"]}, + {"idx": 2, "message": "最近AI行业有什么动态", "expected_scene": "external_trend", "keys": ["AI行业", "行业", "动态", "趋势"]}, + {"idx": 3, "message": "帮我创建个项目叫路由验收测试,顺便问下现在有几个进行中的项目", "expected_scene": "mixed", "keys": ["顺便", "问题部分", "需求部分", "进行中的项目"]}, + {"idx": 4, "message": "AI劝退助手项目进展怎么样", "expected_scene": "default", "keys": ["AI劝退助手", "进展怎么样", "项目进展"]}, + {"idx": 5, "message": "帮我看看宠物行业最新动态", "expected_scene": "external_trend", "keys": ["宠物行业", "最新动态", "行业", "动态"]}, +] + +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": source_message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=240) as resp: + text_body = resp.read().decode("utf-8") + try: + return json.loads(text_body) + except Exception: + return {"raw": text_body} + + +def detect_prompt_scene(prompt: str) -> str: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return "unknown" + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(reply: str) -> bool: + for line in str(reply or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def get_max_ai_call_id() -> int: + async with AsyncSessionLocal() as db: + row = ( + await db.execute(text("SELECT COALESCE(MAX(id), 0) AS v FROM ai_call_logs")) + ).mappings().first() + return int((row or {}).get("v") or 0) + + +async def wait_run(source_message_id: str, timeout_sec: int = 600) -> dict[str, Any] | None: + deadline = time.time() + timeout_sec + last = None + while time.time() < deadline: + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + text( + """ + SELECT id, status, trace_id, source_message_id, created_at, updated_at + FROM ai_orchestration_runs + WHERE source_message_id = :sid + ORDER BY id DESC + LIMIT 1 + """ + ), + {"sid": source_message_id}, + ) + ).mappings().first() + if row: + last = dict(row) + status = str(last.get("status") or "").lower() + if status in {"succeeded", "failed", "cancelled", "waiting_manual"}: + return last + await asyncio.sleep(2) + return last + + +async def wait_outbox(source_message_id: str, expected_sent: int, timeout_sec: int = 300) -> list[dict[str, Any]]: + deadline = time.time() + timeout_sec + latest: list[dict[str, Any]] = [] + while time.time() < deadline: + async with AsyncSessionLocal() as db: + rows = ( + await db.execute( + text( + """ + SELECT id, status, attempts, message_text, created_at + FROM feishu_reply_outbox + WHERE source_message_id = :sid + ORDER BY id ASC + """ + ), + {"sid": source_message_id}, + ) + ).mappings().all() + latest = [dict(r) for r in rows] + sent_count = sum(1 for r in latest if str(r.get("status") or "") == "sent") + if sent_count >= expected_sent: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(source_message_id: str) -> dict[str, Any]: + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + text( + """ + SELECT id, route, intent, processing_status, duration_ms, error_message, reply_source, created_at + FROM ai_processing_logs + WHERE feishu_message_id = :sid + ORDER BY id DESC + LIMIT 1 + """ + ), + {"sid": source_message_id}, + ) + ).mappings().first() + return dict(row or {}) + + +async def fetch_prompt_scene(min_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]: + async with AsyncSessionLocal() as db: + rows = ( + await db.execute( + text( + """ + SELECT id, function_name, prompt, created_at + FROM ai_call_logs + WHERE id > :min_id + AND function_name IN ('adaptive_rag_answer_normal', 'adaptive_rag_answer_deep') + ORDER BY id ASC + LIMIT 400 + """ + ), + {"min_id": min_id}, + ) + ).mappings().all() + + lowered = [str(k).lower() for k in (keys or []) if str(k).strip()] + picked = "unknown" + candidates: list[dict[str, Any]] = [] + for r in rows: + prompt = str(r.get("prompt") or "") + pl = prompt.lower() + if lowered and not any(k in pl for k in lowered): + continue + scene = detect_prompt_scene(prompt) + candidates.append( + { + "id": int(r.get("id") or 0), + "scene": scene, + "function_name": str(r.get("function_name") or ""), + "created_at": str(r.get("created_at") or ""), + } + ) + if scene != "unknown": + picked = scene + + if picked == "unknown" and candidates: + picked = str(candidates[-1].get("scene") or "unknown") + return picked, candidates + + +def evaluate(case_idx: int, scene: str, reply: str, run_status: str) -> tuple[bool, str]: + leak = has_thinking_leak(reply) + if case_idx == 1: + ok = scene == "member_workload" and "%" in reply and "项目列表" not in reply and not leak + return ok, "member_workload+百分比+无项目列表" + if case_idx == 2: + sec = "[行业动态]" in reply and ("[对项目影响]" in reply or "[对项目的影响]" in reply) and "[建议]" in reply + src = "未接入实时数据" in reply or "训练数据" in reply or "数据来源" in reply + ok = scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段+来源限制" + if case_idx == 3: + mixed = "[回答]" in reply and "[已收到需求]" in reply + prog = "进行中" in reply + ok = scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建+进行中数量" + if case_idx == 4: + ok = scene == "default" and "完成率" in reply and "%" in reply and not leak + return ok, "default+完成率" + if case_idx == 5: + ok = scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def run_case(token: str, case: dict[str, Any]) -> dict[str, Any]: + idx = int(case["idx"]) + source_message_id = f"real-scene-p1-v4-{idx}-{int(time.time())}" + message = str(case["message"]) + + before_call_id = await get_max_ai_call_id() + capture = post_capture(token, message, source_message_id) + + run_row = await wait_run(source_message_id) + run_status = str((run_row or {}).get("status") or "missing") + expected_sent = 2 if run_status == "succeeded" else 1 + outbox_rows = await wait_outbox(source_message_id, expected_sent) + + sent = [r for r in outbox_rows if str(r.get("status") or "") == "sent"] + ack = str(sent[0].get("message_text") or "").strip() if sent else "" + final_reply = str(sent[-1].get("message_text") or "").strip() if sent else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + processing = await fetch_processing(source_message_id) + prompt_scene, candidates = await fetch_prompt_scene(before_call_id, list(case.get("keys") or [])) + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + passed, pass_rule = evaluate(idx, actual_scene, final_reply, run_status) + + return { + "idx": idx, + "source_message_id": source_message_id, + "message": message, + "expected_scene": str(case["expected_scene"]), + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "ack": ack, + "final_reply": final_reply, + "thinking_leak": has_thinking_leak(final_reply), + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "processing": { + "route": str(processing.get("route") or ""), + "intent": str(processing.get("intent") or ""), + "status": str(processing.get("processing_status") or ""), + "duration_ms": int(processing.get("duration_ms") or 0), + "error_message": str(processing.get("error_message") or ""), + "reply_source": str(processing.get("reply_source") or ""), + }, + "outbox": outbox_rows, + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": pass_rule, + } + + +async def main() -> None: + token = login_token() + if not token: + raise RuntimeError("token empty") + + results = [] + for case in CASES: + print(f"[RUN] case {case['idx']} -> {case['message']}", flush=True) + one = await run_case(token, case) + print( + f"[DONE] case {one['idx']} scene={one['actual_scene']} pass={one['pass']} run={one['run_status']} leak={one['thinking_leak']}", + flush=True, + ) + results.append(one) + await asyncio.sleep(2) + + payload = { + "generated_at": datetime.now().isoformat(), + "chat_id": CHAT_ID, + "results": results, + } + print("===RESULT_JSON_START===") + print(json.dumps(payload, ensure_ascii=False)) + print("===RESULT_JSON_END===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tmp_run_feishu_case.py b/backend/tmp_run_feishu_case.py new file mode 100644 index 00000000..81359fda --- /dev/null +++ b/backend/tmp_run_feishu_case.py @@ -0,0 +1,291 @@ +import argparse +import asyncio +import json +import time +import urllib.parse +import urllib.request +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import func, select + +from app.core.database import AsyncSessionLocal +from app.models import AICallLog, AIOrchestrationRun, AIProcessingLog, FeishuReplyOutbox + +BASE = "http://127.0.0.1:8000" +CHAT_ID = "oc_c487d87563e48c2f9566ccd5a758212c" +SENDER = "P1验收机器人" +BANNED_PREFIXES = ["让我先", "让我尝试", "我来看看", "我先查", "让我看看", "我需要先"] + + +def login_token() -> str: + data = urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode() + req = urllib.request.Request( + f"{BASE}/auth/login", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return str(payload.get("access_token") or "") + + +def post_capture(token: str, message: str, source_message_id: str) -> dict[str, Any]: + payload = { + "group_chat_id": CHAT_ID, + "message_content": message, + "sender": SENDER, + "feishu_message_id": source_message_id, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + f"{BASE}/ai-steward/ai-steward/signal/capture-feishu", + data=body, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + text = resp.read().decode("utf-8") + try: + return json.loads(text) + except Exception: + return {"raw": text} + + +def detect_prompt_scene(prompt: str) -> Optional[str]: + p = str(prompt or "") + if "你是 PMFlow 的团队协作助手。" in p: + return "member_workload" + if "你是 PMFlow 的市场情报助手。" in p: + return "external_trend" + if "用户消息同时包含问题和需求。" in p: + return "mixed" + if "你是 PMFlow 的 AI 项目管理官。" in p: + return "ai_pmo" + if "用户通过飞书直接提问。" in p: + return "feishu_direct" + if "你是 PMFlow 的 RAG 问答助手" in p: + return "default" + return None + + +def infer_scene_from_reply(reply: str) -> str: + t = str(reply or "") + if "[回答]" in t and "[已收到需求]" in t: + return "mixed" + if "[行业动态]" in t and "[建议]" in t: + return "external_trend" + if ("负载" in t or "工作量" in t) and "%" in t and "项目列表" not in t: + return "member_workload" + if "完成率" in t: + return "default" + return "unknown" + + +def has_thinking_leak(text: str) -> bool: + for line in str(text or "").splitlines(): + s = line.strip() + if not s: + continue + if any(s.startswith(prefix) for prefix in BANNED_PREFIXES): + return True + return False + + +async def fetch_max_ai_call_id(db) -> int: + row = (await db.execute(select(func.max(AICallLog.id)))).scalar() + return int(row or 0) + + +async def wait_run(db, source_message_id: str, timeout_sec: int = 720) -> Optional[AIOrchestrationRun]: + deadline = time.time() + timeout_sec + last = None + while time.time() < deadline: + row = ( + await db.execute( + select(AIOrchestrationRun) + .where(AIOrchestrationRun.source_message_id == source_message_id) + .order_by(AIOrchestrationRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is not None: + last = row + st = str(row.status or "").lower() + if st in {"succeeded", "failed", "cancelled"}: + return row + await asyncio.sleep(2) + return last + + +async def wait_outbox(db, source_message_id: str, run_status: str, timeout_sec: int = 300) -> list[FeishuReplyOutbox]: + deadline = time.time() + timeout_sec + expected = 2 if run_status == "succeeded" else 1 + latest: list[FeishuReplyOutbox] = [] + while time.time() < deadline: + latest = ( + await db.execute( + select(FeishuReplyOutbox) + .where(FeishuReplyOutbox.source_message_id == source_message_id) + .order_by(FeishuReplyOutbox.id.asc()) + ) + ).scalars().all() + sent = [r for r in latest if str(r.status or "") == "sent"] + if len(sent) >= expected: + return latest + await asyncio.sleep(2) + return latest + + +async def fetch_processing(db, source_message_id: str) -> Optional[AIProcessingLog]: + return ( + await db.execute( + select(AIProcessingLog) + .where(AIProcessingLog.feishu_message_id == source_message_id) + .order_by(AIProcessingLog.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + +async def fetch_prompt_scene(db, min_call_id: int, keys: list[str]) -> tuple[str, list[dict[str, Any]]]: + rows = ( + await db.execute( + select(AICallLog) + .where( + AICallLog.id > min_call_id, + AICallLog.function_name.in_(["adaptive_rag_answer_normal", "adaptive_rag_answer_deep"]), + ) + .order_by(AICallLog.id.asc()) + .limit(200) + ) + ).scalars().all() + + lowered = [k.lower() for k in keys if k] + picked = "unknown" + candidates: list[dict[str, Any]] = [] + + for row in rows: + prompt = str(row.prompt or "") + pl = prompt.lower() + if lowered and not any(k in pl for k in lowered): + continue + scene = detect_prompt_scene(prompt) + candidates.append( + { + "id": int(row.id), + "scene": scene or "unknown", + "function": str(row.function_name or ""), + "created_at": str(row.created_at or ""), + } + ) + if scene: + picked = scene + + if picked == "unknown" and candidates: + picked = str(candidates[-1].get("scene") or "unknown") + + return picked, candidates + + +def evaluate(idx: int, actual_scene: str, final_reply: str, run_status: str) -> tuple[bool, str]: + leak = has_thinking_leak(final_reply) + if idx == 1: + ok = actual_scene == "member_workload" and "%" in final_reply and "项目列表" not in final_reply and not leak + return ok, "member_workload+百分比+无项目列表" + if idx == 2: + sec = "[行业动态]" in final_reply and ("[对项目影响]" in final_reply or "[对项目的影响]" in final_reply) and "[建议]" in final_reply + src = "未接入实时数据" in final_reply or "训练数据" in final_reply or "数据来源" in final_reply + ok = actual_scene == "external_trend" and sec and src and not leak + return ok, "external_trend+三段输出+来源限制" + if idx == 3: + mixed = "[回答]" in final_reply and "[已收到需求]" in final_reply + prog = "进行中" in final_reply + ok = actual_scene == "mixed" and run_status == "succeeded" and mixed and prog and not leak + return ok, "mixed+创建成功+进行中数量" + if idx == 4: + ok = actual_scene == "default" and "完成率" in final_reply and "%" in final_reply and not leak + return ok, "default+项目状态完成率" + if idx == 5: + ok = actual_scene == "external_trend" and not leak + return ok, "external_trend+无思考外漏" + return False, "unknown" + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--idx", type=int, required=True) + parser.add_argument("--message", type=str, required=True) + parser.add_argument("--keys", type=str, default="") + parser.add_argument("--prefix", type=str, default="real-scene-p1-manual") + args = parser.parse_args() + + keys = [k.strip() for k in str(args.keys).split("|") if k.strip()] + source_message_id = f"{args.prefix}-{args.idx}-{int(time.time())}" + + token = login_token() + if not token: + raise RuntimeError("token empty") + + async with AsyncSessionLocal() as db: + before_call_id = await fetch_max_ai_call_id(db) + + capture = post_capture(token, args.message, source_message_id) + + async with AsyncSessionLocal() as db: + run_row = await wait_run(db, source_message_id) + run_status = str(getattr(run_row, "status", "missing") or "missing") if run_row is not None else "missing" + outbox_rows = await wait_outbox(db, source_message_id, run_status) + processing = await fetch_processing(db, source_message_id) + prompt_scene, candidates = await fetch_prompt_scene(db, before_call_id, keys) + + sent_rows = [r for r in outbox_rows if str(r.status or "") == "sent"] + ack = str(sent_rows[0].message_text or "").strip() if sent_rows else "" + final_reply = str(sent_rows[-1].message_text or "").strip() if sent_rows else "" + if not final_reply: + final_reply = str(capture.get("direct_reply_text") or "").strip() + + inferred_scene = infer_scene_from_reply(final_reply) + actual_scene = prompt_scene if prompt_scene != "unknown" else inferred_scene + passed, pass_rule = evaluate(args.idx, actual_scene, final_reply, run_status) + + payload = { + "generated_at": datetime.now().isoformat(), + "idx": args.idx, + "source_message_id": source_message_id, + "message": args.message, + "capture_status": str(capture.get("status") or ""), + "run_status": run_status, + "ack": ack, + "final_reply": final_reply, + "prompt_scene": prompt_scene, + "inferred_scene": inferred_scene, + "actual_scene": actual_scene, + "thinking_leak": has_thinking_leak(final_reply), + "has_capacity_warning": "⚠ 产能提示" in final_reply, + "processing": { + "route": str(getattr(processing, "route", "") or ""), + "intent": str(getattr(processing, "intent", "") or ""), + "status": str(getattr(processing, "processing_status", "") or ""), + "duration_ms": int(getattr(processing, "duration_ms", 0) or 0), + "error_message": str(getattr(processing, "error_message", "") or ""), + "reply_source": str(getattr(processing, "reply_source", "") or ""), + }, + "outbox": [ + { + "id": int(r.id), + "status": str(r.status or ""), + "attempts": int(r.attempts or 0), + "created_at": str(r.created_at or ""), + "text": str(r.message_text or ""), + } + for r in outbox_rows + ], + "call_log_candidates": candidates, + "pass": passed, + "pass_rule": pass_rule, + } + print(json.dumps(payload, ensure_ascii=False)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8f700188..0eab75b2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -80,6 +80,22 @@ services: LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED} LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} + AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true} + AI_PMO_INTENT_ARBITRATION_ENABLED: ${AI_PMO_INTENT_ARBITRATION_ENABLED:-true} + AI_PMO_INTENT_CONFLICT_POLICY: ${AI_PMO_INTENT_CONFLICT_POLICY:-pending_manual} + AI_PMO_INTENT_LLM_TIMEOUT_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_SECONDS:-30} + AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS:-55} + AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS:-45} + AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS: ${AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS:-70} + AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD: ${AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD:-0.72} + AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS: ${AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS:-12} + AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS: ${AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS:-8} + AI_PMO_INTENT_LLM_MAX_TOKENS_LOW: ${AI_PMO_INTENT_LLM_MAX_TOKENS_LOW:-260} + AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH: ${AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH:-360} + LLM_HTTP_STATUS_RETRY_BUDGET: ${LLM_HTTP_STATUS_RETRY_BUDGET:-2} + LLM_INTENT_HTTP_RETRY_BUDGET: ${LLM_INTENT_HTTP_RETRY_BUDGET:-0} + LLM_INTENT_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2} + LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true} AUTO_CREATE_TABLES: "false" GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} @@ -91,6 +107,14 @@ services: OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} PYTHONUNBUFFERED: "1" + HTTP_PROXY: "" + HTTPS_PROXY: "" + ALL_PROXY: "" + http_proxy: "" + https_proxy: "" + all_proxy: "" + NO_PROXY: "localhost,127.0.0.1,db,redis,backend,frontend,api.edgefn.net,open.bigmodel.cn" + no_proxy: "localhost,127.0.0.1,db,redis,backend,frontend,api.edgefn.net,open.bigmodel.cn" command: [ "uvicorn", diff --git a/docker-compose.yml b/docker-compose.yml index fbe47ac5..6b80aba2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,22 @@ services: LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:?missing_LLM_ROUTING_SAFETY_MODEL} LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:?missing_LLM_DIRECT_EMERGENCY_ENABLED} LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:?missing_LLM_REQUEST_TIMEOUT} + AI_PMO_INTENT_LLM_ENABLED: ${AI_PMO_INTENT_LLM_ENABLED:-true} + AI_PMO_INTENT_ARBITRATION_ENABLED: ${AI_PMO_INTENT_ARBITRATION_ENABLED:-true} + AI_PMO_INTENT_CONFLICT_POLICY: ${AI_PMO_INTENT_CONFLICT_POLICY:-pending_manual} + AI_PMO_INTENT_LLM_TIMEOUT_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_SECONDS:-30} + AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS:-55} + AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS: ${AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS:-45} + AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS: ${AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS:-70} + AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD: ${AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD:-0.72} + AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS: ${AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS:-12} + AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS: ${AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS:-8} + AI_PMO_INTENT_LLM_MAX_TOKENS_LOW: ${AI_PMO_INTENT_LLM_MAX_TOKENS_LOW:-260} + AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH: ${AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH:-360} + LLM_HTTP_STATUS_RETRY_BUDGET: ${LLM_HTTP_STATUS_RETRY_BUDGET:-2} + LLM_INTENT_HTTP_RETRY_BUDGET: ${LLM_INTENT_HTTP_RETRY_BUDGET:-0} + LLM_INTENT_EMPTY_RETRY_BUDGET: ${LLM_INTENT_EMPTY_RETRY_BUDGET:-2} + LLM_INTENT_STRUCTURED_REPAIR_ENABLED: ${LLM_INTENT_STRUCTURED_REPAIR_ENABLED:-true} AUTO_CREATE_TABLES: "false" GLM_API_KEY: ${GLM_API_KEY:?missing_GLM_API_KEY} GLM_BASE_URL: ${GLM_BASE_URL:?missing_GLM_BASE_URL} @@ -90,6 +106,14 @@ services: OLLAMA_HOST: ${OLLAMA_HOST:?missing_OLLAMA_HOST} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} + HTTP_PROXY: "" + HTTPS_PROXY: "" + ALL_PROXY: "" + http_proxy: "" + https_proxy: "" + all_proxy: "" + NO_PROXY: "localhost,127.0.0.1,db,redis,backend,frontend,api.edgefn.net,open.bigmodel.cn" + no_proxy: "localhost,127.0.0.1,db,redis,backend,frontend,api.edgefn.net,open.bigmodel.cn" ports: - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000" volumes: diff --git a/frontend/src/api/sstc.ts b/frontend/src/api/sstc.ts index 3c9f9a75..79b8f0f5 100644 --- a/frontend/src/api/sstc.ts +++ b/frontend/src/api/sstc.ts @@ -482,9 +482,11 @@ export const sstcApi = { tasks: SSTCTask[]; alignment_records: Array<{ user_id: string; + user_name?: string | null; score: number; confirmed: boolean; comments?: string | null; + confirmed_at?: string | null; }>; } > { @@ -496,9 +498,11 @@ export const sstcApi = { tasks: SSTCTask[]; alignment_records: Array<{ user_id: string; + user_name?: string | null; score: number; confirmed: boolean; comments?: string | null; + confirmed_at?: string | null; }>; } > diff --git a/frontend/src/components/sstc/SSTCCharterStagePanel.tsx b/frontend/src/components/sstc/SSTCCharterStagePanel.tsx index 0b3d06ed..73f2f0ee 100644 --- a/frontend/src/components/sstc/SSTCCharterStagePanel.tsx +++ b/frontend/src/components/sstc/SSTCCharterStagePanel.tsx @@ -5,6 +5,7 @@ import type { SSTCSignal, SSTCTask, } from "@/api/sstc"; +import type { User } from "@/api/users"; import { CHARTER_MODULE_SCHEMAS, type CharterModuleKey } from "@/components/sstc/charterModules"; import { FIXED_ALIGNMENT_QA_QUESTIONS, @@ -22,6 +23,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { formatBeijingDateTime } from "@/lib/datetime"; import { toZhPriority, toZhSignalType, toZhStatus } from "@/lib/display"; import { compactTitle } from "@/lib/title"; @@ -46,9 +48,11 @@ interface CharterDetailShape extends SSTCCharter { tasks: SSTCTask[]; alignment_records: Array<{ user_id: string; + user_name?: string | null; score: number; confirmed: boolean; comments?: string | null; + confirmed_at?: string | null; }>; } @@ -92,6 +96,8 @@ interface SSTCCharterStagePanelProps { } >; }; + allModulesConfirmed: boolean; + shouldShowCharterGenerating: boolean; serializedCharterContent: Record; sourceSignalOpen: boolean; showAutoFields: boolean; @@ -105,9 +111,13 @@ interface SSTCCharterStagePanelProps { setShowAllAlignmentQa: (show: boolean | ((prev: boolean) => boolean)) => void; setEditingAlignmentQaIndex: (index: number | null) => void; setActiveModuleKey: (key: CharterModuleKey) => void; + projectUsers: User[]; onSelectCharter: (charterId: string) => void; updateModuleField: (moduleKey: CharterModuleKey, fieldKey: string, value: string) => void; updateAlignmentQaAnswer: (answerIndex: number, value: string) => void; + confirmModule: (moduleKey: CharterModuleKey) => void; + onStartAlignment: () => void; + onConfirmAlignment: () => void; getFieldEditState: (moduleKey: CharterModuleKey, fieldKey: string) => ModuleFieldEditState; getModuleStepStatus: (moduleKey: CharterModuleKey) => string; updateCharterMutation: UpdateCharterMutationLike; @@ -128,6 +138,8 @@ export function SSTCCharterStagePanel({ activeModule, activeModuleIndex, charterProgress, + allModulesConfirmed, + shouldShowCharterGenerating, serializedCharterContent, sourceSignalOpen, showAutoFields, @@ -141,9 +153,13 @@ export function SSTCCharterStagePanel({ setShowAllAlignmentQa, setEditingAlignmentQaIndex, setActiveModuleKey, + projectUsers, onSelectCharter, updateModuleField, updateAlignmentQaAnswer, + confirmModule, + onStartAlignment, + onConfirmAlignment, getFieldEditState, getModuleStepStatus, updateCharterMutation, @@ -169,9 +185,15 @@ export function SSTCCharterStagePanel({ ? CHARTER_MODULE_SCHEMAS[activeModuleIndex + 1] : null; const hasMultipleCharters = charters.length > 1; + const confirmedModules = CHARTER_MODULE_SCHEMAS.filter( + (schema) => getModuleStepStatus(schema.key) === "已完成" + ).length; const filledModulePercent = Math.round( - (charterProgress.completedModules / Math.max(CHARTER_MODULE_SCHEMAS.length, 1)) * 100 + (confirmedModules / Math.max(CHARTER_MODULE_SCHEMAS.length, 1)) * 100 ); + const charterDisplayStatus = allModulesConfirmed ? "已对齐" : toZhStatus(selectedCharter?.status); + const activeModuleStatus = getModuleStepStatus(activeModule.key); + const activeModuleProgress = charterProgress.moduleProgress[activeModule.key]; const aiReferenceText = sourceSignals.length > 0 ? `参考了 ${sourceSignals @@ -179,6 +201,16 @@ export function SSTCCharterStagePanel({ .map((signal, signalIndex) => `信号 #${signal.id.match(/\d+/)?.[0] || signalIndex + 1}`) .join(" 和 ")} 与项目上下文资料` : "参考了项目上下文资料"; + const alignmentUserNameMap = new Map( + projectUsers.map((member) => [String(member.id), member.name || member.username]) + ); + const alignmentRecords = (charterDetail?.alignment_records || []).filter((record) => { + if (projectUsers.length === 0) return false; + return ( + alignmentUserNameMap.has(String(record.user_id)) || + String(record.user_name || "").trim().length > 0 + ); + }); return (
@@ -227,7 +259,7 @@ export function SSTCCharterStagePanel({ {compactTitle(selectedCharter?.title || "未选择宪章", 24)}

- v{selectedCharter?.version || 0} · {toZhStatus(selectedCharter?.status)} + v{selectedCharter?.version || 0} · {charterDisplayStatus}

{!hasMultipleCharters && (

单宪章模式:左侧列表已自动折叠

@@ -263,11 +295,11 @@ export function SSTCCharterStagePanel({
)} @@ -290,15 +322,21 @@ export function SSTCCharterStagePanel({ size="sm" variant="ghost" className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700" - disabled={!selectedCharter} - onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)} + disabled={!selectedCharter || confirmAlignmentMutation.isPending} + onClick={onConfirmAlignment} > - 确认对齐 + {confirmAlignmentMutation.isPending ? "提交中..." : "确认对齐"} )} + {allModulesConfirmed && ( +
+ 6 个模块已逐项确认,可提交“确认对齐”完成整体对齐。 +
+ )} + {aiPrefillCharterMutation.isPending && (
AI 正在根据信号源自动生成 6 模块草稿,请稍候… @@ -307,8 +345,21 @@ export function SSTCCharterStagePanel({
-
-
+ {shouldShowCharterGenerating ? ( +
+
+
生成中...
+
+
+
+
+
+
+
+
+ ) : ( +
+
diff --git a/frontend/src/components/sstc/SSTCReleaseStagePanel.tsx b/frontend/src/components/sstc/SSTCReleaseStagePanel.tsx index c88615ab..cd07eb88 100644 --- a/frontend/src/components/sstc/SSTCReleaseStagePanel.tsx +++ b/frontend/src/components/sstc/SSTCReleaseStagePanel.tsx @@ -89,6 +89,7 @@ export function SSTCReleaseStagePanel({ stageLocked={stageLocked} charters={charters} tasks={tasks} + releases={releases} onRefresh={onRefresh} /> diff --git a/frontend/src/components/sstc/SSTCWorkspace.tsx b/frontend/src/components/sstc/SSTCWorkspace.tsx index 462241b5..b49a917d 100644 --- a/frontend/src/components/sstc/SSTCWorkspace.tsx +++ b/frontend/src/components/sstc/SSTCWorkspace.tsx @@ -52,6 +52,8 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { activeModule: orchestration.activeModule, activeModuleIndex: orchestration.activeModuleIndex, charterProgress: orchestration.charterProgress, + allModulesConfirmed: orchestration.allModulesConfirmed, + shouldShowCharterGenerating: orchestration.shouldShowCharterGenerating, serializedCharterContent: orchestration.serializedCharterContent, sourceSignalOpen: orchestration.sourceSignalOpen, showAutoFields: orchestration.showAutoFields, @@ -67,9 +69,13 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { setActiveModuleKey: orchestration.setActiveModuleKey, isMobileViewport: props.isMobileViewport, readOnly: props.charterReadOnly, + projectUsers: orchestration.projectUsers, onSelectCharter: props.onSelectCharter, updateModuleField: orchestration.updateModuleField, updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer, + confirmModule: orchestration.confirmModule, + onStartAlignment: orchestration.handleStartAlignment, + onConfirmAlignment: orchestration.handleConfirmAlignment, getFieldEditState: orchestration.getFieldEditState, getModuleStepStatus: orchestration.getModuleStepStatus, updateCharterMutation: orchestration.updateCharterMutation, @@ -108,6 +114,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { {props.activeStage === "execution" && ( { + const state = {} as Record; + for (const schema of CHARTER_MODULE_SCHEMAS) { + state[schema.key] = false; + } + return state; +} + +function parseModuleConfirmationState(raw: unknown): Record { + const initial = createEmptyModuleConfirmationState(); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return initial; + } + const source = raw as Record; + for (const schema of CHARTER_MODULE_SCHEMAS) { + initial[schema.key] = Boolean(source[schema.key]); + } + return initial; +} + export interface SSTCWorkspaceProps { projectId: string; activeStage: StageKey; @@ -84,6 +104,9 @@ export function useSSTCWorkspaceOrchestration({ const [charterDraftTitle, setCharterDraftTitle] = useState(""); const [charterModuleDraft, setCharterModuleDraft] = useState(createEmptyCharterModuleDraft()); const [charterBaselineDraft, setCharterBaselineDraft] = useState(createEmptyCharterModuleDraft()); + const [moduleConfirmations, setModuleConfirmations] = useState>( + createEmptyModuleConfirmationState() + ); const [activeModuleKey, setActiveModuleKey] = useState("module_1"); const [sourceSignalOpen, setSourceSignalOpen] = useState(false); const [showAutoFields, setShowAutoFields] = useState(false); @@ -170,21 +193,49 @@ export function useSSTCWorkspaceOrchestration({ const charterProgress = useMemo(() => calculateCharterProgress(charterModuleDraft), [charterModuleDraft]); - const serializedCharterContent = useMemo( + const serializedCharterContent = useMemo(() => { + const payload = buildCharterContentPayload( + charterModuleDraft, + (selectedCharter?.content as Record) || {} + ); + payload.module_confirmations = moduleConfirmations; + return payload; + }, [charterModuleDraft, selectedCharter?.content, moduleConfirmations]); + + const hasAnyModuleContent = useMemo( () => - buildCharterContentPayload(charterModuleDraft, (selectedCharter?.content as Record) || {}), - [charterModuleDraft, selectedCharter?.content] + CHARTER_MODULE_SCHEMAS.some((schema) => + Object.entries(charterModuleDraft.modules[schema.key] || {}).some(([fieldKey, value]) => { + if (schema.key === "module_2" && fieldKey === "alignment_qa") { + return parseAlignmentQaAnswers(String(value || "")).some((answer) => answer.trim().length > 0); + } + return String(value || "").trim().length > 0; + }) + ), + [charterModuleDraft] + ); + + const requiredModuleKeys = useMemo( + () => CHARTER_MODULE_SCHEMAS.filter((schema) => schema.fields.some((field) => !!field.required)).map((schema) => schema.key), + [] + ); + + const allModulesConfirmed = useMemo( + () => requiredModuleKeys.every((moduleKey) => !!moduleConfirmations[moduleKey]), + [moduleConfirmations, requiredModuleKeys] ); useEffect(() => { setCharterDraftTitle(selectedCharter?.title || ""); - const parsed = parseCharterModuleDraft((selectedCharter?.content as Record) || {}); + const currentContent = (selectedCharter?.content as Record) || {}; + const parsed = parseCharterModuleDraft(currentContent); if (!parsed.modules.module_2.alignment_qa.trim()) { const fallback = buildFallbackAlignmentAnswers(parsed); parsed.modules.module_2.alignment_qa = buildAlignmentQaText(fallback); } setCharterModuleDraft(parsed); setCharterBaselineDraft(parsed); + setModuleConfirmations(parseModuleConfirmationState(currentContent.module_confirmations)); setActiveModuleKey("module_1"); setSourceSignalOpen(false); setShowAutoFields(false); @@ -248,13 +299,37 @@ export function useSSTCWorkspaceOrchestration({ const startAlignmentMutation = useMutation({ mutationFn: (charterId: string) => sstcApi.startAlignment(projectId, charterId), - onSuccess: onRefresh, + onSuccess: () => { + toast({ title: "已发起对齐流程", variant: "success" }); + onRefresh(); + }, + onError: (error) => { + toast({ + title: getApiErrorMessage(error, "发起对齐失败"), + variant: "error", + }); + }, }); const confirmAlignmentMutation = useMutation({ mutationFn: (charterId: string) => sstcApi.confirmAlignment(projectId, charterId, 90, true, "已确认对齐"), - onSuccess: onRefresh, + onSuccess: (data) => { + const confirmedCount = Number(data?.confirmed_count || 0); + const memberCount = Number(data?.member_count || 0); + const statusText = String(data?.charter_status || ""); + toast({ + title: `对齐已提交(${confirmedCount}/${memberCount || 0})${statusText ? ` · ${statusText}` : ""}`, + variant: "success", + }); + onRefresh(); + }, + onError: (error) => { + toast({ + title: getApiErrorMessage(error, "确认对齐失败"), + variant: "error", + }); + }, }); const updateCharterMutation = useMutation({ @@ -302,6 +377,26 @@ export function useSSTCWorkspaceOrchestration({ aiPrefillCharterMutation, ]); + const shouldShowCharterGenerating = useMemo(() => { + if (!selectedCharter?.id) return false; + if (hasAnyModuleContent) return false; + const hasSignal = + (selectedCharter.signal_ids?.length || 0) > 0 || + (charterDetail?.signals?.length || 0) > 0 || + !!charterDetail?.source_signal; + if (!hasSignal) return false; + const prefillAttempted = !!aiPrefillAttempted[selectedCharter.id]; + return aiPrefillCharterMutation.isPending || !prefillAttempted; + }, [ + selectedCharter?.id, + selectedCharter?.signal_ids, + charterDetail?.signals?.length, + charterDetail?.source_signal, + hasAnyModuleContent, + aiPrefillAttempted, + aiPrefillCharterMutation.isPending, + ]); + const aiBreakdownMutation = useMutation({ mutationFn: () => sstcApi.aiBreakdownTasks(projectId, selectedCharter!.id), onSuccess: onRefresh, @@ -500,6 +595,7 @@ export function useSSTCWorkspaceOrchestration({ const activeModule = CHARTER_MODULE_SCHEMAS.find((item) => item.key === activeModuleKey) || CHARTER_MODULE_SCHEMAS[0]; const updateModuleField = (moduleKey: CharterModuleKey, fieldKey: string, value: string) => { + const prevValue = charterModuleDraft.modules[moduleKey][fieldKey] || ""; setCharterModuleDraft((prev) => ({ ...prev, modules: { @@ -510,6 +606,9 @@ export function useSSTCWorkspaceOrchestration({ }, }, })); + if (prevValue !== value && moduleConfirmations[moduleKey]) { + setModuleConfirmations((prev) => ({ ...prev, [moduleKey]: false })); + } }; const moduleOrder = CHARTER_MODULE_SCHEMAS.map((schema) => schema.key); @@ -526,6 +625,43 @@ export function useSSTCWorkspaceOrchestration({ updateModuleField("module_2", "alignment_qa", buildAlignmentQaText(nextAnswers)); }; + const confirmModule = (moduleKey: CharterModuleKey) => { + if (!selectedCharter) return; + const progress = charterProgress.moduleProgress[moduleKey]; + if (!progress?.done) { + toast({ title: "请先补全当前模块的必填项,再执行确认", variant: "error" }); + return; + } + if (moduleConfirmations[moduleKey]) return; + + const previous = moduleConfirmations; + const next = { ...previous, [moduleKey]: true }; + setModuleConfirmations(next); + updateCharterMutation.mutate( + { + charterId: selectedCharter.id, + title: charterDraftTitle || selectedCharter.title, + content: { + ...serializedCharterContent, + module_confirmations: next, + }, + }, + { + onSuccess: () => { + const schema = CHARTER_MODULE_SCHEMAS.find((item) => item.key === moduleKey); + toast({ title: `${schema?.shortTitle || "模块"}已确认`, variant: "success" }); + }, + onError: (error) => { + setModuleConfirmations(previous); + toast({ + title: getApiErrorMessage(error, "模块确认失败"), + variant: "error", + }); + }, + } + ); + }; + const getFieldEditState = (moduleKey: CharterModuleKey, fieldKey: string) => { const baseline = (charterBaselineDraft.modules[moduleKey][fieldKey] || "").trim(); const current = (charterModuleDraft.modules[moduleKey][fieldKey] || "").trim(); @@ -535,18 +671,28 @@ export function useSSTCWorkspaceOrchestration({ }; const getModuleStepStatus = (moduleKey: CharterModuleKey): string => { + if (moduleConfirmations[moduleKey]) return "已完成"; if (moduleKey === activeModule.key) return "进行中"; const progress = charterProgress.moduleProgress[moduleKey]; - if (progress.done) return "已完成"; const moduleContent = charterModuleDraft.modules[moduleKey]; const hasContent = Object.values(moduleContent).some((value) => String(value || "").trim().length > 0); if (!hasContent) return moduleKey === "module_6" ? "选填" : "未开始"; - const changed = Object.keys(moduleContent).some( - (fieldKey) => - String(charterModuleDraft.modules[moduleKey][fieldKey] || "").trim() !== - String(charterBaselineDraft.modules[moduleKey][fieldKey] || "").trim() - ); - return changed ? "已编辑" : "AI已预填"; + if (progress.done) return "进行中"; + return "进行中"; + }; + + const handleStartAlignment = () => { + if (!selectedCharter) return; + startAlignmentMutation.mutate(selectedCharter.id); + }; + + const handleConfirmAlignment = () => { + if (!selectedCharter) return; + if (!allModulesConfirmed) { + toast({ title: "请先逐个确认 6 个模块,再提交整体对齐", variant: "error" }); + return; + } + confirmAlignmentMutation.mutate(selectedCharter.id); }; return { @@ -563,6 +709,8 @@ export function useSSTCWorkspaceOrchestration({ setCharterModuleDraft, charterBaselineDraft, setCharterBaselineDraft, + moduleConfirmations, + setModuleConfirmations, activeModuleKey, setActiveModuleKey, sourceSignalOpen, @@ -587,6 +735,8 @@ export function useSSTCWorkspaceOrchestration({ selectedCharter, charterProgress, serializedCharterContent, + allModulesConfirmed, + shouldShowCharterGenerating, rankedSignals, highValueSignalCount, displaySignals, @@ -601,6 +751,8 @@ export function useSSTCWorkspaceOrchestration({ confirmAlignmentMutation, updateCharterMutation, aiPrefillCharterMutation, + handleStartAlignment, + handleConfirmAlignment, aiBreakdownMutation, patchTaskStatusMutation, bulkTaskStatusMutation, @@ -633,6 +785,7 @@ export function useSSTCWorkspaceOrchestration({ activeModuleIndex, alignmentQaAnswers, updateAlignmentQaAnswer, + confirmModule, getFieldEditState, getModuleStepStatus, }; diff --git a/frontend/src/pages/project-board/ProjectBoardPage.tsx b/frontend/src/pages/project-board/ProjectBoardPage.tsx index 2a85224c..f66df28e 100644 --- a/frontend/src/pages/project-board/ProjectBoardPage.tsx +++ b/frontend/src/pages/project-board/ProjectBoardPage.tsx @@ -214,6 +214,7 @@ export default function ProjectBoardPage() { queryClient.invalidateQueries({ queryKey: ["sstc-signals", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-signals-picker", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-charters", id] }), + queryClient.invalidateQueries({ queryKey: ["sstc-charter-detail", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-task-board", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-releases", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-reviews", id] }), diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 08efe66d..2cdf87f6 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file