Refactor AI PMO services and harden ingress error handling

This commit is contained in:
Yuuuu 2026-03-18 22:24:47 +08:00
parent 90b7e4301f
commit 67b553688d
359 changed files with 5574 additions and 904 deletions

View File

@ -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,

View File

@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 1 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-1-1773760579</div><pre>杨宇当前负载为 199.17%。根据系统快照,他身兼多职(FE, BOSS, AI, BE, ADMIN),负责 4 个项目,负载已严重超载,建议尽快进行任务调配。</pre></div></body></html>

BIN
.tmp/feishu_case1_v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Feishu 验收失败样本</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f7fb; padding:24px; }
.card { background:#fff; border:1px solid #dfe4ea; border-radius:12px; padding:16px; max-width:900px; margin:0 auto; }
h1 { font-size:20px; margin:0 0 12px; }
.meta { color:#556; font-size:13px; margin-bottom:12px; }
.bubble { border-radius:10px; padding:12px 14px; margin:10px 0; white-space:pre-wrap; line-height:1.5; }
.user { background:#e9f2ff; border:1px solid #c8dcff; }
.bot { background:#f8f9fb; border:1px solid #e4e8ef; }
.fail { color:#b42318; font-weight:600; }
</style>
</head>
<body>
<div class="card">
<h1>真实飞书验收 - 失败证据(Case 2)</h1>
<div class="meta">source_message_id: real-scene-p1-v2-2-1773749870</div>
<div class="bubble user">最近AI行业有什么动态</div>
<div class="bubble bot">已收到,正在处理。</div>
<div class="bubble bot fail">模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容...</div>
</div>
</body>
</html>

BIN
.tmp/feishu_case2_fail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

25
.tmp/feishu_case2_v4.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px;background:#f5f7fb;color:#222}.card{background:#fff;border:1px solid #dbe2ea;border-radius:12px;padding:20px;max-width:900px}pre{white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.5}.meta{color:#667085;font-size:13px;margin-bottom:10px}</style></head><body><div class="card"><h2>Case 2 回复</h2><div class="meta">source_message_id: real-scene-p1-v4-2-1773760726</div><pre>[行业动态]
当前未接入实时数据,以下基于训练数据(截止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 应用门槛降低</pre></div></body></html>

BIN
.tmp/feishu_case2_v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

313
.tmp/p1_real_validate.py Normal file
View File

@ -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())

330
.tmp/p1_real_validate_v4.py Normal file
View File

@ -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())

BIN
.tmp/real_feishu_case_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.tmp/real_feishu_case_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
.tmp/real_feishu_case_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.tmp/real_feishu_case_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.tmp/real_feishu_case_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

291
.tmp/run_feishu_case.py Normal file
View File

@ -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())

View File

@ -85,6 +85,7 @@ frontend/ # React 前端
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
## 9. 新增代码的参考模式

146
CLAUDE.md
View File

@ -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` 门禁遮住未暴露,需单独任务修复

View File

@ -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,

View File

@ -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")

View File

@ -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__)

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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"])

View File

@ -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"])

View File

@ -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"])

View File

@ -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)

View File

@ -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)
)

View File

@ -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 (

View File

@ -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"])

View File

@ -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"])

View File

@ -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"])

View File

@ -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()

View File

@ -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 (

View File

@ -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),
}
)

View File

@ -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)

View File

@ -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),
}
)

View File

@ -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

View File

@ -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
],
}
)

View File

@ -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"])

View File

@ -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

View File

@ -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"])

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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",
]

View File

@ -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",
]

View File

@ -188,6 +188,14 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
【输出规范】
- 严禁输出任何思考过程或推理步骤
- 严禁以以下句式开头:
"让我先/让我尝试/我来看看/
我先查/让我看看/我需要先"
- 直接输出最终回复内容
- 不解释你的推理过程
只输出最终回复正文。
""".strip()

View File

@ -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 = """

View File

@ -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 格式:
{{

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

View File

@ -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)

View File

@ -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__)

View File

@ -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__)

View File

@ -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):

View File

@ -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"),

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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]

View File

@ -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}"

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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,

View File

@ -0,0 +1 @@
"""AI PMO domain service namespace."""

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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("__")]

View File

@ -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,
)

View File

@ -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"}

View File

@ -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",

View File

@ -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:

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More