Refactor AI PMO services and harden ingress error handling
|
|
@ -29,6 +29,16 @@
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
"function_exact": "classify_intent_complex"
|
"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",
|
"name": "engine_low_execute_glm_flash",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 38 KiB |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
|
@ -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())
|
||||||
|
|
@ -85,6 +85,7 @@ frontend/ # React 前端
|
||||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
|
- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果
|
||||||
|
|
||||||
## 9. 新增代码的参考模式
|
## 9. 新增代码的参考模式
|
||||||
|
|
||||||
|
|
|
||||||
146
CLAUDE.md
|
|
@ -37,7 +37,7 @@ backend/
|
||||||
│ ├── __init__.py # app 包入口(根目录轻量化后保留)
|
│ ├── __init__.py # app 包入口(根目录轻量化后保留)
|
||||||
│ ├── api/ # 路由层(含 endpoints)
|
│ ├── api/ # 路由层(含 endpoints)
|
||||||
│ ├── core/ # 配置、日志、核心能力
|
│ ├── core/ # 配置、日志、核心能力
|
||||||
│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/probe/warmup)
|
│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup)
|
||||||
│ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal)
|
│ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal)
|
||||||
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||||
│ ├── middleware/ # 请求中间件(审计上下文注入等)
|
│ ├── middleware/ # 请求中间件(审计上下文注入等)
|
||||||
|
|
@ -54,7 +54,8 @@ backend/
|
||||||
│ ├── 20260311_add_operation_log_audit_fields.py # operation_logs 审计字段扩展(trace/path/caller)
|
│ ├── 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_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_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/ # 后端运维/数据修复脚本
|
├── scripts/ # 后端运维/数据修复脚本
|
||||||
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
||||||
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
||||||
|
|
@ -64,7 +65,8 @@ backend/
|
||||||
└── (tests 已移除)
|
└── (tests 已移除)
|
||||||
```
|
```
|
||||||
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
|
- 新增:`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/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`)
|
||||||
- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化)
|
- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化)
|
||||||
- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
|
- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
|
||||||
|
|
@ -73,10 +75,10 @@ backend/
|
||||||
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
||||||
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
|
- 新增:`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/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/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
|
||||||
- 新增:`backend/app/services/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
|
- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口)
|
||||||
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
- 新增:`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/task_brief_service.py`(增量任务包中心服务)
|
||||||
- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务)
|
- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务)
|
||||||
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
|
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
|
||||||
|
|
@ -91,44 +93,50 @@ backend/
|
||||||
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
|
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
|
||||||
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
|
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
|
||||||
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
|
- 新增:`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_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/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/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/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/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/ai_pmo_typed_executor_discovery/`(TypedExecutor discover 候选上下文逻辑)
|
||||||
- 新增:`backend/app/services/ai_pmo_typed_executor_mixins_execute.py`(TypedExecutor execute/create/source-onboard 落库逻辑)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/`(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_kernel_runtime.py`(AI PMO kernel 组合入口)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel_common.py`(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/ai_pmo_kernel/compositions/ingress.py`(Kernel ingress 组合入口)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py`(Kernel 入站主流程编排)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/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/ai_pmo_kernel/mixins/ingress/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/ai_pmo_kernel/compositions/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/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护)
|
- 新增:`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_kernel_mixins_world_model_aggregates.py`(Kernel 组合摘要构建器)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/`(Kernel 组合摘要构建器)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent.py`(Kernel intent 组合入口)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py`(Kernel intent 组合入口)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_intent_rules.py`(Kernel 规则意图与槽位守卫)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/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/ai_pmo_kernel/mixins/intent/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/ai_pmo_kernel/mixins/intent/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/ai_pmo_kernel/compositions/followup.py`(Kernel followup 组合入口)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_followup_records.py`(followup 决策/计划/回执写入)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/mixins/followup/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/ai_pmo_kernel/compositions/closure.py`(Kernel closure 组合入口)
|
||||||
- 新增:`backend/app/services/ai_pmo_kernel/ai_pmo_kernel_mixins_closure_helpers.py`(Kernel 闭环通用归一化工具)
|
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/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/ai_pmo_kernel/mixins/closure/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/ai_pmo_kernel/mixins/closure/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/ai_pmo/ai_pmo_kernel/mixins/`(新增 `closure/followup/ingress/intent/world_model` 分组目录与 `__init__.py` 包声明,降低同层文件密度)
|
||||||
- 新增:`backend/app/services/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行)
|
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/`(新增组合入口分组目录,根目录仅保留 runtime/agent/domain 等真正入口)
|
||||||
- 新增:`backend/app/services/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力)
|
- 更新:`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/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分)
|
- 更新:`backend/app/services/model/`(新增模型能力分组目录,统一收敛 `model_capability/model_context_bundle/model_routing/prompt_profile`)
|
||||||
- 新增:`backend/app/services/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现)
|
- 更新:`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/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`)
|
||||||
- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录)
|
- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录)
|
||||||
- 新增:`backend/app/api/feishu_api/router.py`(飞书 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/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review)
|
||||||
- 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行)
|
- 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行)
|
||||||
- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`)
|
- 更新:`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/signal_capture.py`(降级为兼容入口)
|
||||||
- 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分)
|
- 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分)
|
||||||
- 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口)
|
- 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口)
|
||||||
- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分)
|
- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分)
|
||||||
- 更新:`backend/app/services/context_cache.py`(降级为兼容入口)
|
- 更新:`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/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle`)
|
||||||
- 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块)
|
- 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块)
|
||||||
- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块)
|
- 新增:`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/__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/` 顶层兼容壳批量清理(如 `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/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/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_kernel/`(AI PMO kernel 子包化,清理 services 根目录散落文件)
|
- 更新:`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_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/ai_orchestration_run/ingress_worker.py`(AI PMO 入站消息异步消费、重试与最终回执发送)
|
||||||
- 更新:`backend/app/services/cost_optimization_service.py`、`backend/app/services/model_lifecycle_service.py` → `backend/app/services/model_capability/`(模型能力相关服务收敛)
|
- 更新:`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/signal_strategic_analysis_service.py` → `backend/app/services/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 分析写回服务归并)
|
- 更新:`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/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/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/integration_core/plane_service.py`(Plane 兼容服务归并到 integration_core)
|
- 更新:`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/integration_core/lark_service.py`(Lark 兼容服务归并到 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/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/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/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/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/model_routing_service.py` → `backend/app/services/model/model_routing/service.py`(模型路由服务子包化)
|
||||||
- 更新:`backend/app/services/prompt_profile_service.py` → `backend/app/services/prompt_profile/service.py`(Prompt 画像服务子包化)
|
- 更新:`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/feishu_outbox/service.py`(飞书 outbox 服务子包化)
|
- 更新:`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/wecom/service.py`(企业微信集成服务子包化)
|
- 更新:`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/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/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` 已清零(全部收敛到子包),仅保留目录化服务结构
|
- 更新:`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 上下文
|
- 双模型上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文
|
||||||
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
|
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
|
||||||
- 所有 LLM 调用禁止模型级兜底:禁止高阶升级、Safety 引擎、应急直连、stub 假成功;模型异常、超时或结构化输出不合法时必须显式报错
|
- 所有 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` 显式开启;未开启时运行期与连通测试都不可用
|
- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用
|
||||||
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
|
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
|
||||||
- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要
|
- 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 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流
|
||||||
- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块
|
- 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}/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`
|
- 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution`
|
||||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||||
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||||
|
|
@ -416,26 +433,33 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
- 当前 LLM 主链默认直连引擎,不再依赖 OpenClaw 网关;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||||
- LLM 非结构化响应在 `content` 为空且存在 `reasoning_content` 时,可按需回退读取 `reasoning_content`(开关:`LLM_REASONING_CONTENT_FALLBACK`,默认关闭)
|
- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退
|
||||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||||
- 写操作后主动 invalidate,动态层不缓存
|
- 写操作后主动 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` 已移除且不再读取
|
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
|
||||||
- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM
|
- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM
|
||||||
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
|
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
|
||||||
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
||||||
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
||||||
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
|
- 模型命名统一使用裸模型名(如 `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`
|
- 双模型 DDL 治理图谱的模型来源优先读取显式模型配置;缺失时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL`
|
||||||
- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分
|
- 写操作链路统一产出 `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` 强制进入人工确认,不直接自动执行
|
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
|
||||||
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
|
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
|
||||||
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认
|
- 飞书 `humanized_reply` 与 RAG 问答 Prompt 已显式禁止思考过程外显;回复链路会过滤以“让我先/让我尝试/我来看看/我先查/让我看看/我需要先”开头的前置段落
|
||||||
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
|
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
|
||||||
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义
|
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义
|
||||||
- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐
|
- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐
|
||||||
- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录
|
- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录
|
||||||
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
|
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
|
||||||
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
|
- `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` 门禁遮住未暴露,需单独任务修复
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,16 @@
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
"function_exact": "classify_intent_complex"
|
"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",
|
"name": "engine_low_execute_glm_flash",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -16,8 +16,8 @@ from ...core.database import AsyncSessionLocal, get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
||||||
from ...services.ai_pmo_cadence import AIPMOCadenceService
|
from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService
|
||||||
from ...services.ai_pmo_kernel import AIPMOKernelService
|
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -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.cognitive_conflict_core import get_cognitive_conflict_service
|
||||||
from ...services.daily_briefing_core import get_daily_briefing_service
|
from ...services.daily_briefing_core import get_daily_briefing_service
|
||||||
from ...services.event_workflow_core import WorkflowEventType, get_event_workflow_engine
|
from ...services.event_workflow_core import WorkflowEventType, get_event_workflow_engine
|
||||||
from ...services.feishu_outbox import FeishuOutboxService
|
from ...services.integrations.feishu_outbox import FeishuOutboxService
|
||||||
from ...services.signal_capture_service import ProactiveSignalCaptureService
|
from ...services.signal.signal_capture_service import ProactiveSignalCaptureService
|
||||||
from ...services.signal_source_orchestration import SignalSourceOrchestrationService
|
from ...services.signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||||
from ...services.signal_source_repair import (
|
from ...services.signal.signal_source_repair import (
|
||||||
SignalSourceRepairMissingSignalError,
|
SignalSourceRepairMissingSignalError,
|
||||||
SignalSourceRepairService,
|
SignalSourceRepairService,
|
||||||
SignalSourceRepairTimeoutError,
|
SignalSourceRepairTimeoutError,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import Integration, User
|
from ...models import Integration, User
|
||||||
from ...services.auth import has_any_role
|
from ...services.auth import has_any_role
|
||||||
from ...services.feishu_integration_service import get_feishu_service
|
from ...services.integrations.feishu_integration_service import get_feishu_service
|
||||||
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
|
||||||
from .schemas import FeishuConfigRequest
|
from .schemas import FeishuConfigRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...models import User
|
from ...models import User
|
||||||
from ...services.auth import has_any_role
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from ...core.database import get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import User
|
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
|
from .schemas import FeishuSendCardRequest, FeishuSendMessageRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ from ...core.database import get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import User
|
from ...models import User
|
||||||
from ...services.feishu_integration_service import get_feishu_service
|
from ...services.integrations.feishu_integration_service import get_feishu_service
|
||||||
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
|
||||||
from .helpers import build_long_connection_failure
|
from .helpers import build_long_connection_failure
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from ..dependencies.auth import get_current_user
|
||||||
from ..dependencies.tenant import get_tenant_id
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
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
|
from ..services.project_knowledge_graph_service import ProjectKnowledgeGraphService
|
||||||
|
|
||||||
router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"])
|
router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"])
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ from ...schemas.model_capability import (
|
||||||
ValidateMatchRequest,
|
ValidateMatchRequest,
|
||||||
)
|
)
|
||||||
from ...services.ai_service_core import get_ai_service
|
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.experiment_sync import ExperimentSyncService
|
||||||
from ...services.model_capability import ModelCapabilityService
|
from ...services.model.model_capability import ModelCapabilityService
|
||||||
from ...services.model_capability.model_lifecycle_service import ModelLifecycleService
|
from ...services.model.model_capability.model_lifecycle_service import ModelLifecycleService
|
||||||
|
|
||||||
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
|
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from ..core.database import get_db
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
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
|
from ..services.operation_log_service import log_operation
|
||||||
|
|
||||||
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from fastapi import APIRouter
|
||||||
|
|
||||||
from . import routes_list, routes_mutation, routes_signals
|
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]:
|
for _module in [routes_list, routes_mutation, routes_signals]:
|
||||||
router.include_router(_module.router)
|
router.include_router(_module.router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ async def _cleanup_project_dependencies(
|
||||||
.values(task_id=None)
|
.values(task_id=None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 非空外键:必须先删依赖记录。
|
# 强关联依赖:删除项目时同步清理关联运行/执行记录。
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(AIWorkerExecution).where(
|
delete(AIWorkerExecution).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -263,4 +263,3 @@ async def _cleanup_project_dependencies(
|
||||||
)
|
)
|
||||||
.values(current_project_id=None)
|
.values(current_project_id=None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ from ...models import (
|
||||||
UserProject,
|
UserProject,
|
||||||
WorkItem,
|
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.context_cache_service import context_cache_service
|
||||||
from ...services.operation_log_service import log_operation
|
from ...services.operation_log_service import log_operation
|
||||||
from ...services.project_member import (
|
from ...services.project_member import (
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from ..dependencies.auth import get_current_user
|
||||||
from ..dependencies.tenant import get_tenant_id
|
from ..dependencies.tenant import get_tenant_id
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..services.auth import has_any_role
|
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"])
|
router = APIRouter(prefix="/ai-steward/prompt-profiles", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@ from ...schemas.rag import (
|
||||||
SymbolSyncRequest,
|
SymbolSyncRequest,
|
||||||
)
|
)
|
||||||
from ...services.adaptive_rag_service import AdaptiveRAGService
|
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"])
|
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ from ..core.database import get_db
|
||||||
from ..dependencies import require_manager_user
|
from ..dependencies import require_manager_user
|
||||||
from ..models import KnowledgeBaseDoc, Project, User
|
from ..models import KnowledgeBaseDoc, Project, User
|
||||||
from ..services.adaptive_rag_service import AdaptiveRAGService
|
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.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
|
from ..services import workload as workload_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ from ...core.database import get_db
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import Signal
|
from ...models import Signal
|
||||||
from ...schemas import SignalCreate, SignalPriority, classify_signal_priority
|
from ...schemas import SignalCreate, SignalPriority, classify_signal_priority
|
||||||
from ...services.signal_capture_service.impact_analyzer import analyze_impact
|
from ...services.signal.signal_capture_service.impact_analyzer import analyze_impact
|
||||||
from ...services.signal_capture_service.external_fetcher import fetch_hn_stories
|
from ...services.signal.signal_capture_service.external_fetcher import fetch_hn_stories
|
||||||
from ...services.integration_core.lark_service import LarkService
|
from ...services.integrations.integration_core.lark_service import LarkService
|
||||||
from ...services.llm_service import llm_service
|
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()
|
router = APIRouter()
|
||||||
lark_service = LarkService()
|
lark_service = LarkService()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from ...core.database import get_db
|
||||||
from ...dependencies import get_current_user, get_tenant_id, require_roles
|
from ...dependencies import get_current_user, get_tenant_id, require_roles
|
||||||
from ...models import SkillTag, User, UserSkillBinding
|
from ...models import SkillTag, User, UserSkillBinding
|
||||||
from ...services import skill as skill_service
|
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 ...services.auth import has_any_role
|
||||||
from ...utils.timezone import now_cn_naive
|
from ...utils.timezone import now_cn_naive
|
||||||
from .contracts import (
|
from .contracts import (
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,9 @@ async def confirm_alignment(
|
||||||
row.comments = body.comments
|
row.comments = body.comments
|
||||||
|
|
||||||
member_ids = await svc.get_project_member_ids(pid)
|
member_ids = await svc.get_project_member_ids(pid)
|
||||||
|
if not member_ids:
|
||||||
|
# 无项目成员时至少以当前操作者作为最小确认集合,避免状态永久卡在“对齐中”。
|
||||||
|
member_ids = [int(current_user.id)]
|
||||||
confirmed_map: set[int] = set()
|
confirmed_map: set[int] = set()
|
||||||
rows_result = await db.execute(
|
rows_result = await db.execute(
|
||||||
select(AlignmentRecord).where(
|
select(AlignmentRecord).where(
|
||||||
|
|
@ -253,4 +256,3 @@ async def confirm_alignment(
|
||||||
"member_count": len(member_ids),
|
"member_count": len(member_ids),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,20 @@ async def get_charter_detail(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
alignments = list(align_result.scalars().all())
|
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)
|
source_signal = _select_source_signal(charter, signal_rows)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
|
|
@ -69,7 +83,13 @@ async def get_charter_detail(
|
||||||
"signals": [_serialize_charter_signal(signal) for signal in signal_rows],
|
"signals": [_serialize_charter_signal(signal) for signal in signal_rows],
|
||||||
"source_signal": _serialize_source_signal(source_signal) if source_signal else None,
|
"source_signal": _serialize_source_signal(source_signal) if source_signal else None,
|
||||||
"tasks": [_serialize_charter_task(task) for task in tasks],
|
"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,
|
"status": charter.status,
|
||||||
"version": charter.version,
|
"version": charter.version,
|
||||||
}
|
}
|
||||||
|
|
@ -173,4 +193,3 @@ async def update_charter(
|
||||||
update_payload = _build_charter_summary_payload(charter)
|
update_payload = _build_charter_summary_payload(charter)
|
||||||
update_payload["new_version_created"] = False
|
update_payload["new_version_created"] = False
|
||||||
return success_response(update_payload)
|
return success_response(update_payload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,20 +54,49 @@ async def create_release_plan(
|
||||||
charter_id=int(body.charter_id),
|
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="发布计划必须绑定至少一个任务")
|
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(
|
task_result = await db.execute(
|
||||||
select(Task).where(
|
select(Task).where(
|
||||||
and_(
|
and_(
|
||||||
Task.id.in_(body.task_ids),
|
Task.id.in_(normalized_task_ids),
|
||||||
Task.project_id == pid,
|
Task.project_id == pid,
|
||||||
Task.tenant_id == tenant_id,
|
Task.tenant_id == tenant_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tasks = list(task_result.scalars().all())
|
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="部分任务不存在")
|
raise HTTPException(status_code=400, detail="部分任务不存在")
|
||||||
not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES]
|
not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES]
|
||||||
if not_done:
|
if not_done:
|
||||||
|
|
@ -108,7 +137,7 @@ async def create_release_plan(
|
||||||
entity_type="release",
|
entity_type="release",
|
||||||
entity_id="new",
|
entity_id="new",
|
||||||
action="create",
|
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.commit()
|
||||||
await db.refresh(release_node)
|
await db.refresh(release_node)
|
||||||
|
|
@ -120,4 +149,3 @@ async def create_release_plan(
|
||||||
"content": _as_dict(release_node.content),
|
"content": _as_dict(release_node.content),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@ from ...schemas.sstc import (
|
||||||
)
|
)
|
||||||
from ...services.skill import skill_score_service
|
from ...services.skill import skill_score_service
|
||||||
from ...services import workload as workload_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.ai_service_core import AIService
|
||||||
from ...services.auth import has_any_role
|
from ...services.auth import has_any_role
|
||||||
from ...services.budget_enforcement import BudgetEnforcementService
|
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.llm_service import llm_service
|
||||||
from ...services.operation_log_service import log_operation
|
from ...services.operation_log_service import log_operation
|
||||||
from ...services.skill import recommend_personnel
|
from ...services.skill import recommend_personnel
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
|
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"])
|
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")
|
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
|
||||||
async def ai_breakdown_tasks(
|
async def ai_breakdown_tasks(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|
@ -45,13 +54,38 @@ async def ai_breakdown_tasks(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from 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 = []
|
created = []
|
||||||
|
skipped_duplicates = 0
|
||||||
prompt_tokens = max(len(prompt) // 4, 1)
|
prompt_tokens = max(len(prompt) // 4, 1)
|
||||||
completion_tokens = 0
|
completion_tokens = 0
|
||||||
for item in rows or []:
|
for item in rows or []:
|
||||||
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
|
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
|
||||||
if not title:
|
if not title:
|
||||||
continue
|
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()
|
priority = str(item.get("priority") or "P1").lower()
|
||||||
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
|
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
|
||||||
task = await persist_task(
|
task = await persist_task(
|
||||||
|
|
@ -82,7 +116,7 @@ async def ai_breakdown_tasks(
|
||||||
user_id=int(current_user.id),
|
user_id=int(current_user.id),
|
||||||
function_name="sstc_ai_breakdown",
|
function_name="sstc_ai_breakdown",
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
response=f"created_count={len(created)}",
|
response=f"created_count={len(created)},skipped_duplicates={skipped_duplicates}",
|
||||||
tokens_used=prompt_tokens + completion_tokens,
|
tokens_used=prompt_tokens + completion_tokens,
|
||||||
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
|
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
|
||||||
status="success",
|
status="success",
|
||||||
|
|
@ -96,7 +130,7 @@ async def ai_breakdown_tasks(
|
||||||
entity_type="task",
|
entity_type="task",
|
||||||
entity_id=str(cid),
|
entity_id=str(cid),
|
||||||
action="ai_breakdown",
|
action="ai_breakdown",
|
||||||
after_data={"created_count": len(created)},
|
after_data={"created_count": len(created), "skipped_duplicates": skipped_duplicates},
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
for task in created:
|
for task in created:
|
||||||
|
|
@ -112,10 +146,10 @@ async def ai_breakdown_tasks(
|
||||||
return success_response(
|
return success_response(
|
||||||
{
|
{
|
||||||
"created_count": len(created),
|
"created_count": len(created),
|
||||||
|
"skipped_duplicates": skipped_duplicates,
|
||||||
"items": [
|
"items": [
|
||||||
{"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status}
|
{"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status}
|
||||||
for t in created
|
for t in created
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from ..dependencies import get_current_user, require_manager_user
|
||||||
from ..models import Task, User, UserProject
|
from ..models import Task, User, UserProject
|
||||||
from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse
|
from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse
|
||||||
from ..services.llm_service import llm_service
|
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
|
from ..services.skill import recommend_personnel
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ from ...schemas import (
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
from ...services import skill as skill_service
|
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.auth import AuthService
|
||||||
from ...services.operation_log_service import log_operation
|
from ...services.operation_log_service import log_operation
|
||||||
from ...utils.timezone import now_cn_naive
|
from ...utils.timezone import now_cn_naive
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from sqlalchemy.future import select
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..models import Signal
|
from ..models import Signal
|
||||||
from ..services.llm_service import llm_service
|
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
|
from ..utils.timezone import now_cn_aware
|
||||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import Integration, User
|
from ...models import Integration, User
|
||||||
from ...services.auth import has_any_role
|
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
|
from .schemas import WeComConfigRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from ...core.database import get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.tenant import get_tenant_id
|
from ...dependencies.tenant import get_tenant_id
|
||||||
from ...models import User
|
from ...models import User
|
||||||
from ...services.wecom import get_wecom_service
|
from ...services.integrations.wecom import get_wecom_service
|
||||||
from .schemas import WeComSendMessageRequest
|
from .schemas import WeComSendMessageRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...services.wecom import get_wecom_service
|
from ...services.integrations.wecom import get_wecom_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,5 +116,11 @@ __all__ = [
|
||||||
"PROMPT_WORKLOAD_OPTIMIZATION",
|
"PROMPT_WORKLOAD_OPTIMIZATION",
|
||||||
"PROMPT_ANALYZE_FOOD_CALORIES",
|
"PROMPT_ANALYZE_FOOD_CALORIES",
|
||||||
"PROMPT_SIGNAL_PLANNER",
|
"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",
|
"build_default_role_prompt_bundle",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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_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_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_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__ = [
|
__all__ = [
|
||||||
"PROMPT_IMPACT_ANALYSIS",
|
"PROMPT_IMPACT_ANALYSIS",
|
||||||
|
|
@ -42,4 +48,10 @@ __all__ = [
|
||||||
"PROMPT_WORKLOAD_OPTIMIZATION",
|
"PROMPT_WORKLOAD_OPTIMIZATION",
|
||||||
"PROMPT_ANALYZE_FOOD_CALORIES",
|
"PROMPT_ANALYZE_FOOD_CALORIES",
|
||||||
"PROMPT_SIGNAL_PLANNER",
|
"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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,14 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
|
||||||
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
||||||
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
||||||
|
|
||||||
|
【输出规范】
|
||||||
|
- 严禁输出任何思考过程或推理步骤
|
||||||
|
- 严禁以以下句式开头:
|
||||||
|
"让我先/让我尝试/我来看看/
|
||||||
|
我先查/让我看看/我需要先"
|
||||||
|
- 直接输出最终回复内容
|
||||||
|
- 不解释你的推理过程
|
||||||
|
|
||||||
只输出最终回复正文。
|
只输出最终回复正文。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,17 +156,15 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
||||||
|
|
||||||
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
你是 PMFlow 的 AI PMO 意图识别模块。你只做分类,不做执行。
|
你是 PMFlow 的 AI PMO 意图识别模块。只做分类,不做执行。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- message_text: {message_text}
|
- message_text: {message_text}
|
||||||
- session_last_intent: {session_last_intent}
|
- session_last_intent: {session_last_intent}
|
||||||
- session_project_id: {session_project_id}
|
- session_project_id: {session_project_id}
|
||||||
- world_model_project_id: {world_model_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",
|
"family": "query|project_create|project_signal|signal_source",
|
||||||
"action": "answer|create|discover|onboard",
|
"action": "answer|create|discover|onboard",
|
||||||
|
|
@ -178,22 +176,16 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
"reasoning": "一句话简述判定原因"
|
"reasoning": "一句话简述判定原因"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
规则:
|
快速规则(按优先级):
|
||||||
1. family/action 必须和 candidate_routes_json 可映射,不能自造。
|
1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。
|
||||||
2. 兼容字段 intent 必须从 candidate_intents_json 内选择,不能自造。
|
2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。
|
||||||
3. 若 family=project_signal 且 action=discover,requires_confirmation 必须为 true。
|
3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。
|
||||||
4. requested_count 仅在 project_signal 路由有效,范围 1-5;无法判断时返回 1。
|
4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
|
||||||
5. 若可直接回答,优先 family=query action=answer。
|
5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。
|
||||||
6. “直接新增信号”返回 family=project_signal action=create;“先分析候选让我确认”返回 family=project_signal action=discover。
|
6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。
|
||||||
7. “新增/接入信号源”返回 family=signal_source action=onboard。
|
7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
|
||||||
8. “新增项目”返回 family=project_create action=create。
|
8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
|
||||||
2. 若消息是短确认语气,优先结合 session_last_intent 判定。
|
9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。
|
||||||
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。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ PROMPT_RAG_ANSWER = """
|
||||||
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
|
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
|
||||||
10. 若字段缺失,显式列出缺失字段名,不要假设。
|
10. 若字段缺失,显式列出缺失字段名,不要假设。
|
||||||
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
|
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
|
||||||
|
12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。
|
||||||
|
若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]"
|
||||||
|
|
||||||
当前项目ID:{project_id}
|
当前项目ID:{project_id}
|
||||||
问题:{question}
|
问题:{question}
|
||||||
|
|
@ -85,7 +87,7 @@ PROMPT_DECOMPOSE_TASKS = """
|
||||||
宪章内容:
|
宪章内容:
|
||||||
{charter_content}
|
{charter_content}
|
||||||
|
|
||||||
请识别每个任务所需的 AI 开发技能标签(从系统技能中匹配:Python, TypeScript, FastAPI, React, SQLAlchemy, LangChain, LLM调优, RAG构建, 向量数据库, Ollama, Docker, Git 等)。
|
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
|
||||||
|
|
||||||
输出 JSON 格式:
|
输出 JSON 格式:
|
||||||
{{
|
{{
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -9,7 +9,7 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
from ..database import engine
|
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
|
from .db_guard import normalize_dialect
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from ..logging import get_logger
|
||||||
from ..database import AsyncSessionLocal
|
from ..database import AsyncSessionLocal
|
||||||
from .db_guard import is_missing_table_error
|
from .db_guard import is_missing_table_error
|
||||||
from ...models import Project, WorkItem
|
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
|
from ...utils.timezone import now_cn_aware
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ from .core.logging import configure_logging, get_logger
|
||||||
from .core.response import success_response
|
from .core.response import success_response
|
||||||
from .core.database import AsyncSessionLocal, engine
|
from .core.database import AsyncSessionLocal, engine
|
||||||
from .core.startup.db_guard import check_database_dialect_guard
|
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.feishu_outbox import feishu_outbox_drain_loop
|
||||||
from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log
|
from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log
|
||||||
from .core.startup.world_model_warmup import warmup_world_models
|
from .core.startup.world_model_warmup import warmup_world_models
|
||||||
from .middleware import AuditContextMiddleware
|
from .middleware import AuditContextMiddleware
|
||||||
from .services.ai_orchestration_run import AIOrchestrationRunService
|
from .services.ai_orchestration_run import AIOrchestrationRunService
|
||||||
from .services.context_cache_service import register_context_cache_invalidation_hooks
|
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 (
|
from .services.scheduler_center_core import (
|
||||||
AIPMOCadenceScheduler,
|
AIPMOCadenceScheduler,
|
||||||
KnowledgeGraphIncrementalScheduler,
|
KnowledgeGraphIncrementalScheduler,
|
||||||
|
|
@ -85,6 +86,7 @@ async def lifespan(_: FastAPI):
|
||||||
await feishu_lc.start()
|
await feishu_lc.start()
|
||||||
|
|
||||||
outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop")
|
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")
|
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}")
|
startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
|
||||||
|
|
||||||
|
|
@ -97,6 +99,12 @@ async def lifespan(_: FastAPI):
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
ingress_task.cancel()
|
||||||
|
try:
|
||||||
|
await ingress_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
if not wm_warmup_task.done():
|
if not wm_warmup_task.done():
|
||||||
wm_warmup_task.cancel()
|
wm_warmup_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class AIOrchestrationRun(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=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)
|
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)
|
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)
|
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)
|
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)
|
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
planner_diagnostics: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
planner_diagnostics: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
result_payload: 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(
|
created_by_user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
BigInteger,
|
BigInteger,
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,28 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGAnswerEntryMixin:
|
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(
|
async def answer_question(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -19,6 +41,8 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
thinking_mode: str = THINKING_MODE_NORMAL,
|
thinking_mode: str = THINKING_MODE_NORMAL,
|
||||||
action_mode: str = ACTION_MODE_AUTO,
|
action_mode: str = ACTION_MODE_AUTO,
|
||||||
sync_requirement: bool = True,
|
sync_requirement: bool = True,
|
||||||
|
message_intent: Optional[str] = None,
|
||||||
|
mixed_signal_part: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
clean_q = str(question or "").strip()
|
clean_q = str(question or "").strip()
|
||||||
|
|
@ -30,6 +54,10 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||||||
asked_via_lower = str(asked_via or "").strip().lower()
|
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_retrieval_steps = DEEP_RETRIEVAL_STEPS
|
||||||
deep_vector_top_k = DEEP_VECTOR_TOP_K
|
deep_vector_top_k = DEEP_VECTOR_TOP_K
|
||||||
if asked_via_lower == "feishu":
|
if asked_via_lower == "feishu":
|
||||||
|
|
@ -166,7 +194,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
user_id=asked_by,
|
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(
|
deterministic_result = await self._run_deterministic_query(
|
||||||
question=effective_q,
|
question=effective_q,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -191,7 +219,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
direct_answer = None
|
direct_answer = None
|
||||||
if not deep_thinking:
|
if not deep_thinking and not enforce_scene_prompt_only:
|
||||||
direct_answer = self._build_snapshot_direct_answer(
|
direct_answer = self._build_snapshot_direct_answer(
|
||||||
effective_q,
|
effective_q,
|
||||||
system_snapshot=system_snapshot,
|
system_snapshot=system_snapshot,
|
||||||
|
|
@ -217,7 +245,7 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
return direct_result
|
return direct_result
|
||||||
|
|
||||||
is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q)
|
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)
|
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:
|
if glm_semantic_result is not None:
|
||||||
return await self._persist_fast_answer(
|
return await self._persist_fast_answer(
|
||||||
|
|
@ -260,5 +288,6 @@ class AdaptiveRAGAnswerEntryMixin:
|
||||||
system_snapshot=system_snapshot,
|
system_snapshot=system_snapshot,
|
||||||
user_snapshot=user_snapshot,
|
user_snapshot=user_snapshot,
|
||||||
started=started,
|
started=started,
|
||||||
|
message_intent=message_intent,
|
||||||
|
mixed_signal_part=mixed_signal_part,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
system_snapshot: dict[str, Any],
|
system_snapshot: dict[str, Any],
|
||||||
user_snapshot: Optional[dict[str, Any]],
|
user_snapshot: Optional[dict[str, Any]],
|
||||||
started: float,
|
started: float,
|
||||||
|
message_intent: Optional[str] = None,
|
||||||
|
mixed_signal_part: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
domains = self._route_domains(effective_q)
|
domains = self._route_domains(effective_q)
|
||||||
if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains:
|
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,
|
thinking_mode=active_thinking_mode,
|
||||||
context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None,
|
context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None,
|
||||||
answer_max_tokens=DEEP_ANSWER_MAX_TOKENS 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)
|
used_sources = self._dedupe_sources(retrieved)
|
||||||
if not used_sources:
|
if not used_sources:
|
||||||
|
|
@ -134,6 +139,9 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
thinking_mode=THINKING_MODE_DEEP,
|
thinking_mode=THINKING_MODE_DEEP,
|
||||||
context_block_limit=DEEP_CONTEXT_BLOCKS,
|
context_block_limit=DEEP_CONTEXT_BLOCKS,
|
||||||
answer_max_tokens=DEEP_ANSWER_MAX_TOKENS,
|
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)
|
deep_sources = self._dedupe_sources(deep_retrieved)
|
||||||
if not deep_sources:
|
if not deep_sources:
|
||||||
|
|
@ -284,4 +292,3 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
missing_fields=missing_fields,
|
missing_fields=missing_fields,
|
||||||
thinking_mode=active_thinking_mode,
|
thinking_mode=active_thinking_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,161 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGDeepGuardAnswerMixin:
|
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(
|
def _build_snapshot_direct_answer(
|
||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
|
|
@ -122,6 +277,9 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
thinking_mode: str = THINKING_MODE_NORMAL,
|
thinking_mode: str = THINKING_MODE_NORMAL,
|
||||||
context_block_limit: Optional[int] = None,
|
context_block_limit: Optional[int] = None,
|
||||||
answer_max_tokens: 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:
|
) -> str:
|
||||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
||||||
|
|
@ -149,18 +307,24 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
for row in conversation[-10:]:
|
for row in conversation[-10:]:
|
||||||
history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}")
|
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]
|
system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000]
|
||||||
user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000]
|
user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000]
|
||||||
|
scene_key, prompt = self._select_scene_prompt(
|
||||||
prompt = PROMPT_RAG_ANSWER.format(
|
|
||||||
project_id=project_id if project_id is not None else "unknown",
|
|
||||||
question=question,
|
question=question,
|
||||||
conversation=chr(10).join(history_lines) if history_lines else "无",
|
project_id=project_id,
|
||||||
context_blocks=chr(10).join(context_blocks) if context_blocks else "无",
|
asked_via=asked_via,
|
||||||
system_snapshot=system_snapshot_text or "{}",
|
message_intent=message_intent,
|
||||||
user_snapshot=user_snapshot_text or "{}",
|
mixed_signal_part=mixed_signal_part,
|
||||||
relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}",
|
conversation_text=conversation_text,
|
||||||
).strip()
|
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:
|
if deep_thinking:
|
||||||
prompt = (
|
prompt = (
|
||||||
f"{prompt}\n\n"
|
f"{prompt}\n\n"
|
||||||
|
|
@ -191,6 +355,12 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
text = str(answer or "").strip()
|
text = str(answer or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return "我未能从当前上下文生成有效答案,请补充问题细节后重试。"
|
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(
|
enforced = self._enforce_feature_evidence_in_answer(
|
||||||
question=question,
|
question=question,
|
||||||
answer_text=text,
|
answer_text=text,
|
||||||
|
|
@ -198,4 +368,3 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
system_snapshot=system_snapshot,
|
system_snapshot=system_snapshot,
|
||||||
)
|
)
|
||||||
return str(enforced or text)[:3200]
|
return str(enforced or text)[:3200]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,14 +168,14 @@ class AdaptiveRAGDeepGuardRulesMixin:
|
||||||
"请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。"
|
"请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _is_snapshot_direct_question(question: str) -> bool:
|
def _is_snapshot_direct_question(cls, question: str) -> bool:
|
||||||
text = str(question or "").strip().lower()
|
text = str(question or "").strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
return False
|
return False
|
||||||
if AdaptiveRAGService._is_correction_query(text):
|
if cls._is_correction_query(text):
|
||||||
return False
|
return False
|
||||||
if AdaptiveRAGService._is_engine_process_query(text):
|
if cls._is_engine_process_query(text):
|
||||||
return False
|
return False
|
||||||
has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS)
|
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)
|
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:
|
if is_enumeration and "项目" in anchor_question:
|
||||||
return f"{anchor_question}。请列出对应项目的名称和ID。"
|
return f"{anchor_question}。请列出对应项目的名称和ID。"
|
||||||
return f"{anchor_question}。补充问题:{current}"
|
return f"{anchor_question}。补充问题:{current}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ class AdaptiveRAGIngestMixin:
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
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:
|
try:
|
||||||
await service.ingest_feishu_message(
|
await service.ingest_feishu_message(
|
||||||
content=content,
|
content=content,
|
||||||
|
|
@ -93,7 +95,9 @@ class AdaptiveRAGIngestMixin:
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
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:
|
try:
|
||||||
await service.process_knowledge_doc_content(
|
await service.process_knowledge_doc_content(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
|
|
@ -138,7 +142,9 @@ class AdaptiveRAGIngestMixin:
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
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:
|
try:
|
||||||
await service.refresh_url_doc(doc_id=doc_id, project_id=project_id)
|
await service.refresh_url_doc(doc_id=doc_id, project_id=project_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -216,7 +222,9 @@ class AdaptiveRAGIngestMixin:
|
||||||
content: str,
|
content: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
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:
|
try:
|
||||||
await service.upsert_pmflow_record(
|
await service.upsert_pmflow_record(
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
|
|
@ -227,4 +235,3 @@ class AdaptiveRAGIngestMixin:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,16 @@ import httpx
|
||||||
from sqlalchemy import and_, bindparam, delete, func, or_, select, text
|
from sqlalchemy import and_, bindparam, delete, func, or_, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 ...core.database import AsyncSessionLocal
|
||||||
from ...models import (
|
from ...models import (
|
||||||
KnowledgeBaseChunk,
|
KnowledgeBaseChunk,
|
||||||
|
|
@ -50,8 +59,8 @@ from ...models import (
|
||||||
)
|
)
|
||||||
from ..context_cache_service import context_cache_service
|
from ..context_cache_service import context_cache_service
|
||||||
from ..llm_service import LLMService
|
from ..llm_service import LLMService
|
||||||
from ..signal_source_orchestration import SignalSourceOrchestrationService
|
from ..signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||||
from ..symbol_index import SymbolIndexService
|
from ..signal.symbol_index import SymbolIndexService
|
||||||
|
|
||||||
|
|
||||||
from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive
|
from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,9 @@ ENTITY_ALIAS_MAP: dict[str, str] = {
|
||||||
TS_SYMBOL_RE = re.compile(
|
TS_SYMBOL_RE = re.compile(
|
||||||
r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class RetrievedItem:
|
class RetrievedItem:
|
||||||
source: str
|
source: str
|
||||||
content: str
|
content: str
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,5 @@ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||||
return max(minimum, min(maximum, value))
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["_env_int"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -2,20 +2,40 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ...core.database import AsyncSessionLocal
|
||||||
from ...models import AIOrchestrationRun
|
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:
|
def _utc_now_naive() -> datetime:
|
||||||
return now_cn_naive()
|
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:
|
class AIOrchestrationRunService:
|
||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
@ -26,7 +46,7 @@ class AIOrchestrationRunService:
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
run_type: str,
|
run_type: str,
|
||||||
project_id: int,
|
project_id: Optional[int],
|
||||||
signal_id: Optional[int],
|
signal_id: Optional[int],
|
||||||
response_mode: str,
|
response_mode: str,
|
||||||
status: str,
|
status: str,
|
||||||
|
|
@ -66,6 +86,287 @@ class AIOrchestrationRunService:
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return row
|
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]:
|
async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]:
|
||||||
return (
|
return (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
|
|
@ -130,6 +431,7 @@ class AIOrchestrationRunService:
|
||||||
select(AIOrchestrationRun).where(
|
select(AIOrchestrationRun).where(
|
||||||
and_(
|
and_(
|
||||||
AIOrchestrationRun.status.in_(("queued", "running")),
|
AIOrchestrationRun.status.in_(("queued", "running")),
|
||||||
|
AIOrchestrationRun.run_type != INGRESS_RUN_TYPE,
|
||||||
AIOrchestrationRun.started_at.is_not(None),
|
AIOrchestrationRun.started_at.is_not(None),
|
||||||
AIOrchestrationRun.started_at < threshold,
|
AIOrchestrationRun.started_at < threshold,
|
||||||
)
|
)
|
||||||
|
|
@ -160,7 +462,7 @@ class AIOrchestrationRunService:
|
||||||
return {
|
return {
|
||||||
"trace_id": str(row.trace_id or ""),
|
"trace_id": str(row.trace_id or ""),
|
||||||
"run_type": str(row.run_type 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,
|
"signal_id": int(row.signal_id) if row.signal_id is not None else None,
|
||||||
"response_mode": str(row.response_mode or ""),
|
"response_mode": str(row.response_mode or ""),
|
||||||
"run_status": str(row.status or ""),
|
"run_status": str(row.status or ""),
|
||||||
|
|
@ -174,6 +476,18 @@ class AIOrchestrationRunService:
|
||||||
"summary": str(row.summary or payload.get("summary") or "").strip(),
|
"summary": str(row.summary or payload.get("summary") or "").strip(),
|
||||||
"planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None,
|
"planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None,
|
||||||
"result_snapshot": payload,
|
"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,
|
"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,
|
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||||||
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""AI PMO domain service namespace."""
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Optional
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...models import (
|
from ....models import (
|
||||||
Budget,
|
Budget,
|
||||||
Notification,
|
Notification,
|
||||||
PortfolioSnapshot,
|
PortfolioSnapshot,
|
||||||
|
|
@ -22,7 +22,7 @@ from ...models import (
|
||||||
UserWorkload,
|
UserWorkload,
|
||||||
WorkItem,
|
WorkItem,
|
||||||
)
|
)
|
||||||
from ...utils.timezone import now_cn_naive
|
from ....utils.timezone import now_cn_naive
|
||||||
|
|
||||||
|
|
||||||
def utc_now_naive() -> datetime:
|
def utc_now_naive() -> datetime:
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from .shared import (
|
from .shared import (
|
||||||
CanonicalIntent,
|
CanonicalIntent,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
|
|
@ -25,6 +27,12 @@ class ExecutionPolicyBrain:
|
||||||
intent: CanonicalIntent,
|
intent: CanonicalIntent,
|
||||||
simulation: dict[str, object],
|
simulation: dict[str, object],
|
||||||
) -> 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()
|
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||||
for gate_code, tokens in self.policy_gate_rules:
|
for gate_code, tokens in self.policy_gate_rules:
|
||||||
if any(token.lower().replace(" ", "") in normalized for token in tokens):
|
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 {
|
return {
|
||||||
"auto_execute": False,
|
"auto_execute": False,
|
||||||
"policy_gate_hit": True,
|
"policy_gate_hit": True,
|
||||||
|
|
@ -92,9 +100,17 @@ class ExecutionPolicyBrain:
|
||||||
"approval_requirement": {
|
"approval_requirement": {
|
||||||
"required": True,
|
"required": True,
|
||||||
"reason": "simulation_capacity_limit",
|
"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:
|
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -125,4 +141,15 @@ class ExecutionPolicyBrain:
|
||||||
"policy_gate_hit": False,
|
"policy_gate_hit": False,
|
||||||
"policy_gate_code": None,
|
"policy_gate_code": None,
|
||||||
"approval_requirement": {"required": False},
|
"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)
|
||||||
|
|
@ -202,7 +202,7 @@ class IntentResolver:
|
||||||
return True
|
return True
|
||||||
return bool(
|
return bool(
|
||||||
re.search(
|
re.search(
|
||||||
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目",
|
r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目",
|
||||||
normalized,
|
normalized,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
@ -212,4 +212,3 @@ class IntentResolver:
|
||||||
def _build_signal_title(text: str, *, default: str) -> str:
|
def _build_signal_title(text: str, *, default: str) -> str:
|
||||||
raw = str(text or "").strip()
|
raw = str(text or "").strip()
|
||||||
return raw or default
|
return raw or default
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ def _safe_int(value: Any) -> Optional[int]:
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class CanonicalIntent:
|
class CanonicalIntent:
|
||||||
name: str
|
name: str
|
||||||
confidence: float
|
confidence: float
|
||||||
|
|
@ -18,6 +19,7 @@ class CanonicalIntent:
|
||||||
signal_summary: str
|
signal_summary: str
|
||||||
metadata: dict[str, Any]
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class WorkflowRoute:
|
class WorkflowRoute:
|
||||||
family: str
|
family: str
|
||||||
action: str
|
action: str
|
||||||
|
|
@ -30,3 +32,5 @@ class WorkflowRoute:
|
||||||
def key(self) -> str:
|
def key(self) -> str:
|
||||||
return f"{self.family}.{self.action}"
|
return f"{self.family}.{self.action}"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||||
|
|
@ -45,49 +45,11 @@ class RouteResolver:
|
||||||
return INTENT_FOLLOWUP_CONTINUE
|
return INTENT_FOLLOWUP_CONTINUE
|
||||||
return INTENT_QUERY
|
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
|
@classmethod
|
||||||
def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int:
|
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
|
metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None
|
||||||
if metadata_count is not None and metadata_count > 0:
|
if metadata_count is not None and metadata_count > 0:
|
||||||
return min(max(metadata_count, 1), 5)
|
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
|
return 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -95,22 +57,20 @@ class RouteResolver:
|
||||||
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
|
explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else ""
|
||||||
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER:
|
||||||
return True
|
return True
|
||||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
explicit_action = str(metadata.get("workflow_action") or metadata.get("action") or "").strip().lower() if isinstance(metadata, dict) else ""
|
||||||
if not normalized:
|
return explicit_action == ROUTE_ACTION_DISCOVER
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_followup_action(*, message_text: str, canonical_intent: str) -> str:
|
def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str:
|
||||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower()
|
||||||
if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS):
|
if explicit_action in {
|
||||||
return ROUTE_ACTION_REFRESH
|
ROUTE_ACTION_CONTINUE,
|
||||||
if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS):
|
ROUTE_ACTION_CONFIRM,
|
||||||
return ROUTE_ACTION_CANCEL
|
ROUTE_ACTION_COMMIT,
|
||||||
if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS):
|
ROUTE_ACTION_CANCEL,
|
||||||
return ROUTE_ACTION_COMMIT
|
ROUTE_ACTION_REFRESH,
|
||||||
|
}:
|
||||||
|
return explicit_action
|
||||||
if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||||||
return ROUTE_ACTION_COMMIT
|
return ROUTE_ACTION_COMMIT
|
||||||
return ROUTE_ACTION_CONTINUE
|
return ROUTE_ACTION_CONTINUE
|
||||||
|
|
@ -123,15 +83,17 @@ class RouteResolver:
|
||||||
message_text: str,
|
message_text: str,
|
||||||
) -> WorkflowRoute:
|
) -> WorkflowRoute:
|
||||||
metadata = dict(canonical_intent.metadata or {})
|
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:
|
if canonical_intent.name == INTENT_SIGNAL_CREATE:
|
||||||
route_key = (
|
if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata):
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER
|
route_key = ROUTE_PROJECT_SIGNAL_DISCOVER
|
||||||
if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata)
|
elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}:
|
||||||
else ROUTE_PROJECT_SIGNAL_CREATE
|
route_key = ROUTE_PROJECT_SIGNAL_CREATE
|
||||||
)
|
|
||||||
elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
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:
|
if action == ROUTE_ACTION_CONFIRM:
|
||||||
route_key = ROUTE_FOLLOWUP_CONFIRM
|
route_key = ROUTE_FOLLOWUP_CONFIRM
|
||||||
else:
|
else:
|
||||||
|
|
@ -215,4 +177,3 @@ class RouteResolver:
|
||||||
requires_confirmation=requires_confirmation,
|
requires_confirmation=requires_confirmation,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -6,8 +6,8 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from ...utils.project_command_utils import extract_project_create_title
|
from ....utils.project_command_utils import extract_project_create_title
|
||||||
from ..signal_source_orchestration import SignalSourceOrchestrationService
|
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||||
|
|
||||||
INTENT_QUERY = "query.read_only"
|
INTENT_QUERY = "query.read_only"
|
||||||
INTENT_PROJECT_CREATE = "project.create"
|
INTENT_PROJECT_CREATE = "project.create"
|
||||||
|
|
@ -209,4 +209,3 @@ WRITE_INTENTS = {
|
||||||
}
|
}
|
||||||
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}
|
INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -7,7 +7,14 @@ from .ai_agent_kernel import (
|
||||||
select_intent_route,
|
select_intent_route,
|
||||||
)
|
)
|
||||||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
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__ = [
|
__all__ = [
|
||||||
"AIPMOKernelService",
|
"AIPMOKernelService",
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any, Optional
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..signal_source_orchestration import SignalSourceOrchestrationService
|
from ...signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||||
|
|
||||||
|
|
||||||
class SignalSourceDomainWorkflow:
|
class SignalSourceDomainWorkflow:
|
||||||
|
|
@ -8,11 +8,11 @@ for _name, _value in vars(_common).items():
|
||||||
if not _name.startswith("__"):
|
if not _name.startswith("__"):
|
||||||
globals()[_name] = _value
|
globals()[_name] = _value
|
||||||
|
|
||||||
from .ai_pmo_kernel_mixins_closure import KernelClosureMixin
|
from .compositions.closure import KernelClosureMixin
|
||||||
from .ai_pmo_kernel_mixins_followup import KernelFollowupMixin
|
from .compositions.followup import KernelFollowupMixin
|
||||||
from .ai_pmo_kernel_mixins_ingress import KernelIngressMixin
|
from .compositions.ingress import KernelIngressMixin
|
||||||
from .ai_pmo_kernel_mixins_intent import KernelIntentMixin
|
from .compositions.intent import KernelIntentMixin
|
||||||
from .ai_pmo_kernel_mixins_world_model import KernelWorldModelMixin
|
from .compositions.world_model import KernelWorldModelMixin
|
||||||
|
|
||||||
|
|
||||||
class AIPMOKernelService(
|
class AIPMOKernelService(
|
||||||
|
|
@ -54,4 +54,3 @@ class AIPMOKernelService(
|
||||||
return async_sessionmaker(bind=bind, expire_on_commit=False, class_=AsyncSession)
|
return async_sessionmaker(bind=bind, expire_on_commit=False, class_=AsyncSession)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||