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