Update PMFlow docs and services

This commit is contained in:
Yuuuu 2026-03-13 01:19:35 +08:00
parent 5b7cf23aeb
commit 8dde37448e
16 changed files with 1426 additions and 127 deletions

View File

@ -79,6 +79,9 @@ backend/
- 新增:`backend/app/api/task_briefs.py`(任务包中心接口) - 新增:`backend/app/api/task_briefs.py`(任务包中心接口)
- 新增:`backend/tests/test_model_routing_api.py`(模型路由接口回归测试) - 新增:`backend/tests/test_model_routing_api.py`(模型路由接口回归测试)
- 新增:`backend/tests/test_task_briefs_api.py`(任务包中心接口回归测试) - 新增:`backend/tests/test_task_briefs_api.py`(任务包中心接口回归测试)
- 新增:`backend/tests/test_project_knowledge_graph_guardrails.py`(双模型 DDL 治理知识图谱同步回归测试)
- 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,覆盖 low/high 意图模型与 OpenClaw 规划模型)
- 更新:`backend/app/services/project_knowledge_graph.py`(新增 `ai_model -> governance_policy -> interface` 治理图谱同步能力,增量任务新增 `model_guardrails` 阶段)
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) - 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
- 新增:`backend/.openclaw/pmflow-routing-rules.json`(容器内兼容同步副本,与根目录规则保持一致) - 新增:`backend/.openclaw/pmflow-routing-rules.json`(容器内兼容同步副本,与根目录规则保持一致)
@ -197,6 +200,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 - 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` - 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列 - `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
- low/high 双模型(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required`
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
@ -204,6 +208,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) - 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`)
- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`)
- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`)
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交) - 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交)
- 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects` - 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member_service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects`
@ -240,6 +245,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时自动写入降级 `ai_planner(fallback_mode=true)` 并收敛为 `planner_status=completed` - Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时自动写入降级 `ai_planner(fallback_mode=true)` 并收敛为 `planner_status=completed`
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
- 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier - 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier
- 双模型 DDL 治理图谱的模型来源优先读取 `OPENCLAW_ROUTING_RULES_PATH` 指向的路由规则;读取失败时回退 `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` 强制进入人工确认,不直接自动执行
- `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认 - `project.create` 启用槽位完整性闸门:无法从指令提取有效项目名时标记 `slot_incomplete` 并进入人工确认

View File

@ -30,6 +30,7 @@ from ..services.daily_briefing import get_daily_briefing_service
from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType
from ..services.adaptive_rag_service import AdaptiveRAGService from ..services.adaptive_rag_service import AdaptiveRAGService
from ..services.ai_orchestration_run_service import AIOrchestrationRunService from ..services.ai_orchestration_run_service import AIOrchestrationRunService
from ..services.feishu_outbox_service import FeishuOutboxService
from ..services.openclaw_commander_service import OpenClawCommanderService from ..services.openclaw_commander_service import OpenClawCommanderService
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
from ..services.signal_source_repair_service import ( from ..services.signal_source_repair_service import (
@ -448,7 +449,31 @@ async def capture_feishu_signal(
tenant_id=tenant_id, tenant_id=tenant_id,
feishu_message_id=request.feishu_message_id, feishu_message_id=request.feishu_message_id,
) )
return result response_payload = dict(result) if isinstance(result, dict) else {"result": result}
should_enqueue_reply = bool(response_payload.get("direct_reply_required"))
reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。"
source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None)
normalized_chat_id = str(request.group_chat_id or "").strip()
if should_enqueue_reply and normalized_chat_id:
try:
outbox_id = await FeishuOutboxService.enqueue(
db,
tenant_id=int(tenant_id),
chat_id=normalized_chat_id,
text=reply_text,
source_message_id=source_message_id,
dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}",
trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None,
)
await db.commit()
response_payload["reply_outbox_enqueued"] = bool(outbox_id)
response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None
except Exception as outbox_exc:
await db.rollback()
response_payload["reply_outbox_enqueued"] = False
response_payload["reply_outbox_error"] = str(outbox_exc)[:200]
return response_payload
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}") raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}")

View File

@ -422,6 +422,8 @@ PROMPT_OPENCLAW_COMMAND_DISPATCH = """
11. 字段未知时返回空字符串、空数组或 null。 11. 字段未知时返回空字符串、空数组或 null。
12. 信息不足时也必须返回合法 JSON,operations=[]。 12. 信息不足时也必须返回合法 JSON,operations=[]。
13. 不要 Markdown,不要解释,只返回 JSON。 13. 不要 Markdown,不要解释,只返回 JSON。
14. 严禁输出任何 DDL 直连数据库执行方案(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。
15. 若输入涉及数据库 DDL 变更,必须返回 operations=[],并在 summary 明确写出 ddl_api_required(表示必须走受控接口/迁移流程)。
""" """
@ -527,6 +529,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。 6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。
7. 只输出 JSON,不要额外文本。 7. 只输出 JSON,不要额外文本。
8. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 query.read_only,并在 reasoning 包含 ddl_api_required。
""" """

View File

@ -376,6 +376,8 @@ class IntentResolver:
OPERATION_VERBS = ( OPERATION_VERBS = (
"新增", "新增",
"增加", "增加",
"添加",
"加",
"加一个", "加一个",
"加个", "加个",
"新建", "新建",
@ -394,6 +396,26 @@ class IntentResolver:
"执行", "执行",
) )
@staticmethod
def _looks_like_negated_write_statement(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用")
if not any(token in normalized for token in negation_tokens):
return False
write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项")
object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源")
if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens):
return True
return bool(
re.search(
r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)",
normalized,
re.IGNORECASE,
)
)
@staticmethod @staticmethod
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
@ -402,6 +424,15 @@ class IntentResolver:
return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"})
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized):
return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"})
if IntentResolver._looks_like_negated_write_statement(text=pretty):
return CanonicalIntent(
INTENT_QUERY,
0.9,
project_id,
"",
pretty[:300],
{"reason": "negated_write_statement"},
)
if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
@ -427,6 +458,15 @@ class IntentResolver:
pretty[:300], pretty[:300],
{}, {},
) )
if IntentResolver._looks_like_signal_create_request(text=pretty):
return CanonicalIntent(
INTENT_SIGNAL_CREATE,
0.78,
project_id,
IntentResolver._build_signal_title(pretty, default="新信号"),
pretty[:300],
{},
)
if IntentResolver._looks_like_query(text=pretty): if IntentResolver._looks_like_query(text=pretty):
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
@ -436,15 +476,6 @@ class IntentResolver:
pretty[:300], pretty[:300],
{}, {},
) )
if any(token in pretty for token in SIGNAL_CREATE_HINTS) or any(token in pretty for token in ("新增", "增加", "录入", "跟踪", "关注")):
return CanonicalIntent(
INTENT_SIGNAL_CREATE,
0.78,
project_id,
IntentResolver._build_signal_title(pretty, default="新信号"),
pretty[:300],
{},
)
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
0.6, 0.6,
@ -454,6 +485,28 @@ class IntentResolver:
{"fallback": True, "reason": "default_query_fallback"}, {"fallback": True, "reason": "default_query_fallback"},
) )
@staticmethod
def _looks_like_signal_create_request(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
if IntentResolver._looks_like_negated_write_statement(text=normalized):
return False
if any(token in normalized for token in SIGNAL_CREATE_HINTS):
return True
signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化")
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
has_signal_object = any(token in normalized for token in signal_object_hints)
if has_operation and has_signal_object:
return True
return bool(
re.search(
r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)",
normalized,
re.IGNORECASE,
)
)
@staticmethod @staticmethod
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower()
@ -472,6 +525,8 @@ class IntentResolver:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized: if not normalized:
return False return False
if IntentResolver._looks_like_signal_create_request(text=normalized):
return False
if any(token in normalized for token in QUESTION_HINTS): if any(token in normalized for token in QUESTION_HINTS):
return True return True
has_question_structure = ( has_question_structure = (
@ -1004,16 +1059,23 @@ class TypedExecutor:
) -> dict[str, Any]: ) -> dict[str, Any]:
if intent.name == INTENT_QUERY: if intent.name == INTENT_QUERY:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
rag_result = await rag_service.answer_question( try:
question=message_text, rag_result = await rag_service.answer_question(
asked_by=operator_user_id, question=message_text,
asked_via=source_system, asked_by=operator_user_id,
project_id=intent.project_id, asked_via=source_system,
source_chat_id=source_message_id, project_id=intent.project_id,
conversation_key=source_message_id, source_chat_id=source_message_id,
action_mode="read", conversation_key=source_message_id,
sync_requirement=False, action_mode="read",
) sync_requirement=False,
)
except Exception as exc:
return {
"status": "failed_safe",
"error_message": self._build_query_error_message(exc),
"project_id": intent.project_id,
}
return { return {
"status": "succeeded", "status": "succeeded",
"answer": str(rag_result.get("answer") or "").strip(), "answer": str(rag_result.get("answer") or "").strip(),
@ -1139,6 +1201,20 @@ class TypedExecutor:
"error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。", "error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。",
} }
@staticmethod
def _build_query_error_message(error: Exception) -> str:
reason = re.sub(r"\s+", " ", str(error or "")).strip().strip("'\"")
reason = re.sub(r"^(runtimeerror|valueerror|exception)\s*:\s*", "", reason, flags=re.IGNORECASE)
if len(reason) > 120:
reason = f"{reason[:120]}..."
marker = "模型调用失败:"
while reason.startswith(marker):
reason = reason[len(marker):].strip()
if not reason:
reason = "unknown_error"
message = f"{marker}{reason}"
return message if "请稍后重试" in message else f"{message}。请稍后重试。"
async def _create_signal( async def _create_signal(
self, self,
*, *,
@ -1395,6 +1471,18 @@ class AIPMOKernelService:
message_text=clean_text, message_text=clean_text,
closure_payload=closure_payload, closure_payload=closure_payload,
) )
if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text):
metadata = dict(intent.metadata or {})
metadata["guard_reason"] = "negated_write_statement"
metadata["guarded_from_intent"] = intent.name
intent = CanonicalIntent(
INTENT_QUERY,
max(float(intent.confidence or 0.0), 0.9),
intent.project_id,
"",
clean_text[:300],
metadata,
)
perf_intent_name = intent.name perf_intent_name = intent.name
_log_perf("_resolve_intent", stage_started_at) _log_perf("_resolve_intent", stage_started_at)
if intent_context_source: if intent_context_source:
@ -2630,6 +2718,9 @@ class AIPMOKernelService:
) -> tuple[CanonicalIntent, str]: ) -> tuple[CanonicalIntent, str]:
project_id = _safe_int(world_model.get("resolved_project_id")) project_id = _safe_int(world_model.get("resolved_project_id"))
rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id) rule_intent = self.intent_resolver.resolve(text=message_text, project_id=project_id)
# 查询/噪音场景优先走规则意图,避免额外触发 LLM 分类链路导致不必要失败重试。
if rule_intent.name in {INTENT_QUERY, INTENT_IGNORE} and float(rule_intent.confidence or 0.0) >= 0.85:
return self._apply_slot_guard(intent=rule_intent, message_text=message_text), "rule_preferred"
if self._is_short_confirmation_message(message_text=message_text, session=session): if self._is_short_confirmation_message(message_text=message_text, session=session):
if session is None: if session is None:
return rule_intent, "rule_fallback" return rule_intent, "rule_fallback"

View File

@ -371,6 +371,7 @@ class FeishuIntegrationService:
) )
reply_route = "none" reply_route = "none"
capability_reply = self._build_capability_reply(message_content)
if scene == "execute": if scene == "execute":
if self._is_duplicate_capture_result(result): if self._is_duplicate_capture_result(result):
self._emit_state_transition( self._emit_state_transition(
@ -384,7 +385,7 @@ class FeishuIntegrationService:
state="ROUTED", state="ROUTED",
route="tool_first_execute", route="tool_first_execute",
) )
deterministic_reply = str(result.get("direct_reply_text") or "").strip() deterministic_reply = capability_reply or str(result.get("direct_reply_text") or "").strip()
if not deterministic_reply: if not deterministic_reply:
deterministic_reply = self._build_execution_receipt_reply( deterministic_reply = self._build_execution_receipt_reply(
message_content=message_content, message_content=message_content,
@ -423,7 +424,18 @@ class FeishuIntegrationService:
state="ROUTED", state="ROUTED",
route="qa_consult", route="qa_consult",
) )
if chat_id and bool(result.get("direct_reply_required")): if chat_id and capability_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=capability_reply,
trace_id=state_trace_id,
source_message_id=message_id,
perf_t0=perf_t0,
)
reply_route = "qa_capability"
elif chat_id and bool(result.get("direct_reply_required")):
await self._safe_reply_text( await self._safe_reply_text(
chat_id=chat_id, chat_id=chat_id,
mention_open_id=sender_open_id, mention_open_id=sender_open_id,
@ -1024,6 +1036,16 @@ class FeishuIntegrationService:
) )
return any(token in normalized for token in capability_tokens) return any(token in normalized for token in capability_tokens)
@classmethod
def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]:
if not cls._is_capability_question(message_content):
return None
return (
"我是 PMFlow 的项目协同助手。"
"我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。"
"你直接说目标、对象和期望动作即可。"
)
@staticmethod @staticmethod
def _strip_identity_lead(reply_text: str) -> str: def _strip_identity_lead(reply_text: str) -> str:
text = str(reply_text or "").strip() text = str(reply_text or "").strip()

View File

@ -179,6 +179,12 @@ FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",)
FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0 FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0
FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0 FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0
STRUCTURED_CACHE_TTL_SECONDS = 300 STRUCTURED_CACHE_TTL_SECONDS = 300
STRICT_MODEL_ERROR_FUNCTION_PREFIXES = (
"adaptive_rag_answer_",
)
STRICT_MODEL_ERROR_FUNCTION_EXACT = {
"rag_db_project_semantic_retrieval",
}
FAIL_FAST_ERROR_MARKERS = ( FAIL_FAST_ERROR_MARKERS = (
"primary_engine_empty_response", "primary_engine_empty_response",
"high_tier_empty_response", "high_tier_empty_response",
@ -189,6 +195,19 @@ FAIL_FAST_ERROR_MARKERS = (
) )
CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2 CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2
CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0 CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0
DDL_API_GUARDRAIL_FUNCTION_EXACT = {
"classify_intent_ai_pmo_kernel",
"classify_intent_complex",
"openclaw_command_dispatch",
}
DDL_API_GUARDRAIL_SYSTEM_PROMPT = """
硬性约束(最高优先级):
1. 禁止直接执行或建议执行数据库 DDL SQL(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。
2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。
3. 若输入要求直接改库执行 DDL:
- `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。
- `openclaw_command_dispatch` 必须返回 `operations=[]`,并在 summary 包含 `ddl_api_required`。
""".strip()
STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"signal_source_onboarding_plan": { "signal_source_onboarding_plan": {
"required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"), "required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"),
@ -699,15 +718,21 @@ class LLMService:
routing_decision = "primary_engine" routing_decision = "primary_engine"
engine_meta: dict[str, Any] = {} engine_meta: dict[str, Any] = {}
prompt_profile = {"role_name": "", "system_prompt": ""} prompt_profile = {"role_name": "", "system_prompt": ""}
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) merged_prompt = self._merge_prompt_with_profile(
prompt=prompt,
prompt_profile=prompt_profile,
function_name=function_name,
)
safety_attempted = False safety_attempted = False
def _log_stub_failure(error_obj: Exception) -> None: def _log_stub_failure(error_obj: Exception, *, used_stub: bool = True) -> None:
elapsed_ms = (time.perf_counter() - started_at) * 1000 elapsed_ms = (time.perf_counter() - started_at) * 1000
provider = str(engine_meta.get("provider") or "") provider = str(engine_meta.get("provider") or "")
model = str(engine_meta.get("model") or "") model = str(engine_meta.get("model") or "")
outcome = "兜底 stub" if used_stub else "显式报错"
logger.warning( logger.warning(
"[LLM_FAIL] 生成失败 → 兜底 stub function=%s provider=%s model=%s error=%r duration_ms=%.0f", "[LLM_FAIL] 生成失败 → %s function=%s provider=%s model=%s error=%r duration_ms=%.0f",
outcome,
function_name, function_name,
provider, provider,
model, model,
@ -718,7 +743,11 @@ class LLMService:
try: try:
if use_prompt_profile: if use_prompt_profile:
prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id)
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) merged_prompt = self._merge_prompt_with_profile(
prompt=prompt,
prompt_profile=prompt_profile,
function_name=function_name,
)
engine = await self._resolve_engine_for_call( engine = await self._resolve_engine_for_call(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -815,13 +844,18 @@ class LLMService:
return response_text return response_text
except Exception as exc: except Exception as exc:
upgraded_exc: Exception = exc upgraded_exc: Exception = exc
if self._is_fail_fast_exception(exc): strict_model_error = self._should_raise_model_error(function_name)
if self._is_fail_fast_exception(exc) and not strict_model_error:
status = "failed_fast" status = "failed_fast"
response_text = self._stub_response(prompt) response_text = self._stub_response(prompt)
_runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}") _runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}")
_log_stub_failure(exc) _log_stub_failure(exc)
return response_text return response_text
if self._is_low_tier_function(function_name): if self._is_fail_fast_exception(exc) and strict_model_error:
status = "failed_fast"
_runtime_log("LLM", f"生成快速失败,严格模式继续升级重试:{exc}")
should_try_high_tier_retry = self._is_low_tier_function(function_name) or strict_model_error
if should_try_high_tier_retry:
if self._prefer_fast_local_fallback(function_name): if self._prefer_fast_local_fallback(function_name):
raise upgraded_exc raise upgraded_exc
high_engine = await self._resolve_high_tier_engine_for_escalation( high_engine = await self._resolve_high_tier_engine_for_escalation(
@ -863,9 +897,29 @@ class LLMService:
response_text = escalated_text response_text = escalated_text
return response_text return response_text
except Exception as high_exc: except Exception as high_exc:
_runtime_log(
"LLM升级",
(
"高阶兜底失败:"
f"function={function_name}, provider={high_provider}, "
f"model={high_model}, error={high_exc}"
),
)
upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}") upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}")
engine_meta = high_engine engine_meta = high_engine
if strict_model_error:
status = "failed"
reason_text = self._build_model_error_message(
function_name=function_name,
provider=str(engine_meta.get("provider") or ""),
model=str(engine_meta.get("model") or ""),
error=upgraded_exc,
)
response_text = reason_text
_log_stub_failure(upgraded_exc, used_stub=False)
raise RuntimeError(reason_text) from upgraded_exc
if not safety_attempted: if not safety_attempted:
safety_attempted = True safety_attempted = True
safety_text, safety_engine = await self._try_safety_engine( safety_text, safety_engine = await self._try_safety_engine(
@ -931,7 +985,11 @@ class LLMService:
return response_text return response_text
finally: finally:
try: try:
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) merged_prompt = self._merge_prompt_with_profile(
prompt=prompt,
prompt_profile=prompt_profile,
function_name=function_name,
)
await self._log_ai_call( await self._log_ai_call(
tenant_id=tenant_id, tenant_id=tenant_id,
function_name=function_name, function_name=function_name,
@ -973,7 +1031,11 @@ class LLMService:
try: try:
if use_prompt_profile: if use_prompt_profile:
prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id)
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) merged_prompt = self._merge_prompt_with_profile(
prompt=prompt,
prompt_profile=prompt_profile,
function_name=function_name,
)
engine = await self._resolve_engine_for_call( engine = await self._resolve_engine_for_call(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -1133,7 +1195,10 @@ class LLMService:
raise ValueError(f"provider_circuit_open:{provider_name}:{model}") raise ValueError(f"provider_circuit_open:{provider_name}:{model}")
key = api_key.strip() key = api_key.strip()
messages: list[dict[str, str]] = [] messages: list[dict[str, str]] = []
cleaned_system_prompt = str(system_prompt or "").strip() cleaned_system_prompt = self._inject_function_guardrails(
system_prompt=system_prompt,
function_name=function_name,
)
if cleaned_system_prompt: if cleaned_system_prompt:
messages.append({"role": "system", "content": cleaned_system_prompt}) messages.append({"role": "system", "content": cleaned_system_prompt})
messages.append({"role": "user", "content": prompt}) messages.append({"role": "user", "content": prompt})
@ -1183,67 +1248,96 @@ class LLMService:
if not key: if not key:
raise ValueError("未配置 LLM API Key") raise ValueError("未配置 LLM API Key")
endpoint = f"{base_url.rstrip('/')}/chat/completions" endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if is_openclaw_gateway_request:
payload["agents"] = {
"defaults": {
"timeoutSeconds": int(openclaw_agents_timeout_seconds),
}
}
headers = { headers = {
"Authorization": f"Bearer {key}", "Authorization": f"Bearer {key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: current_max_tokens = max(int(max_tokens or 0), 1)
resp = await client.post(endpoint, headers=headers, json=payload) empty_content_retry_budget = 1
while True:
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": current_max_tokens,
}
if is_openclaw_gateway_request: if is_openclaw_gateway_request:
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 payload["agents"] = {
_runtime_log( "defaults": {
"OpenClaw调用", "timeoutSeconds": int(openclaw_agents_timeout_seconds),
( }
f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} " }
f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}" async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
), resp = await client.post(endpoint, headers=headers, json=payload)
)
if resp.status_code >= 400:
body_preview = str(resp.text or "").replace("\n", " ").strip()[:260]
if is_openclaw_gateway_request: if is_openclaw_gateway_request:
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log( _runtime_log(
"OpenClaw调用", "OpenClaw调用",
( (
f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} status={resp.status_code} body={body_preview}" f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}"
), ),
) )
resp.raise_for_status() if resp.status_code >= 400:
data = resp.json() body_preview = str(resp.text or "").replace("\n", " ").strip()[:260]
choices = data.get("choices") or [] if is_openclaw_gateway_request:
if not choices: _runtime_log(
raise ValueError("LLM 响应无 choices") "OpenClaw调用",
message = choices[0].get("message") if isinstance(choices[0], dict) else {} (
text = self._extract_text_from_chat_response( f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} "
message=message, f"function={function_label} status={resp.status_code} body={body_preview}"
choice=choices[0] if isinstance(choices[0], dict) else {}, ),
response=data, )
structured_output=structured_output, resp.raise_for_status()
) data = resp.json()
if not text and not structured_output: choices = data.get("choices") or []
if not choices:
raise ValueError("LLM 响应无 choices")
first_choice = choices[0] if isinstance(choices[0], dict) else {}
message = first_choice.get("message") if isinstance(first_choice, dict) else {}
text = self._extract_text_from_chat_response(
message=message,
choice=first_choice,
response=data,
structured_output=structured_output,
)
if text or structured_output:
break
msg_keys = list(message.keys()) if isinstance(message, dict) else [] msg_keys = list(message.keys()) if isinstance(message, dict) else []
choice_keys = list(choices[0].keys()) if isinstance(choices[0], dict) else [] choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else []
choice_preview = "" choice_preview = ""
try: try:
choice_preview = json.dumps(choices[0], ensure_ascii=False)[:300] choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300]
except Exception: except Exception:
choice_preview = str(choices[0])[:300] choice_preview = str(first_choice)[:300]
raise ValueError( empty_error = ValueError(
"LLM 返回空内容: " "LLM 返回空内容: "
f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}"
) )
can_retry_empty = (
empty_content_retry_budget > 0
and self._should_retry_empty_chat_response(
structured_output=structured_output,
message=message,
choice=first_choice,
current_max_tokens=current_max_tokens,
)
)
if not can_retry_empty:
raise empty_error
empty_content_retry_budget -= 1
next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens)
_runtime_log(
"LLM",
(
"空内容提额重试:"
f"function={function_label}, provider={provider_name}, model={model}, "
f"max_tokens={current_max_tokens}->{next_max_tokens}"
),
)
current_max_tokens = next_max_tokens
if _looks_like_openclaw_timeout_message(text): if _looks_like_openclaw_timeout_message(text):
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log( _runtime_log(
@ -1894,8 +1988,38 @@ class LLMService:
return self._looks_like_unanswerable_response(response_text) return self._looks_like_unanswerable_response(response_text)
@staticmethod @staticmethod
def _merge_prompt_with_profile(*, prompt: str, prompt_profile: dict[str, str]) -> str: def _requires_ddl_api_guardrail(function_name: str) -> bool:
system_prompt = str(prompt_profile.get("system_prompt") or "").strip() fn = str(function_name or "").strip().lower()
if not fn:
return False
return fn in DDL_API_GUARDRAIL_FUNCTION_EXACT
@classmethod
def _inject_function_guardrails(cls, *, system_prompt: str, function_name: str) -> str:
base_prompt = str(system_prompt or "").strip()
if not cls._requires_ddl_api_guardrail(function_name):
return base_prompt
extra = str(DDL_API_GUARDRAIL_SYSTEM_PROMPT or "").strip()
if not extra:
return base_prompt
if extra in base_prompt:
return base_prompt
if not base_prompt:
return extra
return f"{base_prompt}\n\n{extra}"
@classmethod
def _merge_prompt_with_profile(
cls,
*,
prompt: str,
prompt_profile: dict[str, str],
function_name: str = "",
) -> str:
system_prompt = cls._inject_function_guardrails(
system_prompt=str(prompt_profile.get("system_prompt") or "").strip(),
function_name=function_name,
)
if not system_prompt: if not system_prompt:
return prompt return prompt
role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown" role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown"
@ -1911,6 +2035,36 @@ class LLMService:
return False return False
return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS) return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS)
@staticmethod
def _should_raise_model_error(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
if fn in STRICT_MODEL_ERROR_FUNCTION_EXACT:
return True
return any(prefix and fn.startswith(prefix) for prefix in STRICT_MODEL_ERROR_FUNCTION_PREFIXES)
@staticmethod
def _build_model_error_message(
*,
function_name: str,
provider: str,
model: str,
error: Exception,
) -> str:
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
if len(clean_error) > 160:
clean_error = f"{clean_error[:160]}..."
provider_text = str(provider or "unknown").strip() or "unknown"
model_text = str(model or "unknown").strip() or "unknown"
fn_text = str(function_name or "llm_generate").strip() or "llm_generate"
if not clean_error:
clean_error = "upstream_empty_or_invalid_response"
return (
f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, "
f"reason={clean_error}"
)
@staticmethod @staticmethod
def _is_fast_local_planner_function(function_name: str) -> bool: def _is_fast_local_planner_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower() fn = str(function_name or "").strip().lower()
@ -1968,6 +2122,34 @@ class LLMService:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX)
@staticmethod
def _should_retry_empty_chat_response(
*,
structured_output: bool,
message: Any,
choice: Any,
current_max_tokens: int,
) -> bool:
if structured_output:
return False
if int(current_max_tokens or 0) >= 2800:
return False
finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else ""
if finish_reason not in {"length", "max_tokens"}:
return False
reasoning_text = ""
if isinstance(message, dict):
reasoning_text = str(message.get("reasoning_content") or message.get("reasoning") or "").strip()
if not reasoning_text and isinstance(choice, dict):
reasoning_text = str(choice.get("reasoning_content") or choice.get("reasoning") or "").strip()
return bool(reasoning_text)
@staticmethod
def _next_retry_max_tokens_for_empty_chat(current_max_tokens: int) -> int:
current = max(int(current_max_tokens or 0), 1)
grown = max(current + 512, int(current * 1.8))
return min(grown, 3000)
def _use_provider_circuit_breaker(self, function_name: str) -> bool: def _use_provider_circuit_breaker(self, function_name: str) -> bool:
return not self._prefer_fast_local_fallback(function_name) return not self._prefer_fast_local_fallback(function_name)
@ -2540,7 +2722,8 @@ class LLMService:
if base_url_override: if base_url_override:
routed["base_url"] = base_url_override routed["base_url"] = base_url_override
elif self._openclaw_routing_enabled() and provider == "openai": elif self._openclaw_routing_enabled() and provider == "openai":
routed["base_url"] = OPENCLAW_PRIMARY_GATEWAY_URL configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/")
routed["base_url"] = configured_openai_base or OPENCLAW_PRIMARY_GATEWAY_URL
if str(routed.get("id") or "") != str(active_engine.get("id") or ""): if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
_runtime_log( _runtime_log(

View File

@ -10,6 +10,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -41,6 +43,20 @@ def _utc_now_naive() -> datetime:
return now_cn_naive() return now_cn_naive()
DDL_BLOCKED_PREFIXES = [
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"RENAME",
"COMMENT",
"GRANT",
"REVOKE",
]
DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only"
DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程"
class ProjectKnowledgeGraphService: class ProjectKnowledgeGraphService:
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db self.db = db
@ -1093,6 +1109,284 @@ class ProjectKnowledgeGraphService:
counters["evidences"] += 1 counters["evidences"] += 1
return counters return counters
@staticmethod
def _routing_rule_candidate_paths() -> list[Path]:
candidates: list[Path] = []
configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip()
if configured:
candidates.append(Path(configured).expanduser())
backend_root = Path(__file__).resolve().parents[2]
project_root = Path(__file__).resolve().parents[3]
candidates.extend(
[
project_root / ".openclaw" / "pmflow-routing-rules.json",
backend_root / ".openclaw" / "pmflow-routing-rules.json",
]
)
deduped: list[Path] = []
seen: set[str] = set()
for path in candidates:
key = str(path)
if key in seen:
continue
seen.add(key)
deduped.append(path)
return deduped
@classmethod
def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]:
specs: dict[str, dict[str, str]] = {
"low": {
"tier": "low",
"provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(),
"model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(),
"api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(),
"source": "env_default",
"source_path": "",
"rule_name": "",
},
"high": {
"tier": "high",
"provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(),
"model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(),
"api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(),
"source": "env_default",
"source_path": "",
"rule_name": "",
},
}
for path in cls._routing_rule_candidate_paths():
try:
content = path.read_text(encoding="utf-8")
payload = json.loads(content)
except Exception:
continue
rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
if not rows:
continue
enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))]
enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
resolved_any = False
for tier in ("low", "high"):
for row in enabled_rows:
if str(row.get("tier") or "").strip().lower() != tier:
continue
model = str(row.get("model") or "").strip()
if not model:
continue
specs[tier] = {
"tier": tier,
"provider": str(row.get("provider") or specs[tier]["provider"]).strip(),
"model": model,
"api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(),
"source": "routing_rule",
"source_path": str(path),
"rule_name": str(row.get("name") or "").strip(),
}
resolved_any = True
break
if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")):
break
return specs
async def _sync_model_guardrail_entities(self) -> dict[str, Any]:
counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0}
model_specs = self._resolve_guardrail_target_models()
policy_entity = await self.upsert_entity(
entity_type="governance_policy",
entity_key=DDL_GUARDRAIL_POLICY_KEY,
display_name=DDL_GUARDRAIL_POLICY_NAME,
payload={
"policy_code": "ddl_via_interface_only",
"enforcement_level": "hard_block",
"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES,
"required_execution_path": "api_or_migration_interface",
"enforced_by": [
"backend/app/database.py",
"backend/app/services/llm.py",
"backend/app/config/ai_prompts.py",
],
},
source_type="governance",
source_ref="backend/app/database.py",
)
counters["policies"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.",
entity_id=int(policy_entity.id),
source_type="governance",
source_ref="backend/app/database.py",
metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES},
)
counters["evidences"] += 1
interface_specs = [
{
"entity_key": "interface:api:/ai-steward/command/dispatch",
"display_name": "POST /ai-steward/command/dispatch",
"payload": {"path": "/ai-steward/command/dispatch", "kind": "api"},
"source_ref": "backend/app/api/ai_steward.py",
},
{
"entity_key": "interface:migration:alembic",
"display_name": "Alembic Migration Interface",
"payload": {"path": "alembic", "kind": "migration"},
"source_ref": "backend/alembic/versions",
},
]
interface_ids: list[int] = []
for item in interface_specs:
interface_entity = await self.upsert_entity(
entity_type="interface",
entity_key=str(item["entity_key"]),
display_name=str(item["display_name"]),
payload=dict(item["payload"]),
source_type="governance",
source_ref=str(item["source_ref"]),
)
counters["interfaces"] += 1
interface_ids.append(int(interface_entity.id))
rel = await self.upsert_relation(
relation_type="policy_enforced_by_interface",
subject_entity_id=int(policy_entity.id),
object_entity_id=int(interface_entity.id),
source_type="governance",
source_ref=str(item["source_ref"]),
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}",
relation_id=int(rel.id),
source_type="governance",
source_ref=str(item["source_ref"]),
metadata=dict(item["payload"]),
)
counters["evidences"] += 1
for tier in ("low", "high"):
spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {}
model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5")
provider = str(spec.get("provider") or "").strip()
api_env = str(spec.get("api_env") or "").strip()
source = str(spec.get("source") or "env_default").strip()
source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255]
entity = await self.upsert_entity(
entity_type="ai_model",
entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}",
display_name=model_name[:255],
payload={
"tier": tier,
"provider": provider,
"model": model_name,
"api_env": api_env,
"source": source,
"routing_rule": str(spec.get("rule_name") or "").strip(),
},
source_type="model_routing",
source_ref=source_ref or None,
)
counters["models"] += 1
rel = await self.upsert_relation(
relation_type="model_must_follow_policy",
subject_entity_id=int(entity.id),
object_entity_id=int(policy_entity.id),
source_type="governance",
source_ref=source_ref or None,
payload={"tier": tier, "policy_code": "ddl_via_interface_only"},
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}",
relation_id=int(rel.id),
source_type="governance",
source_ref=source_ref or None,
metadata={"tier": tier, "provider": provider, "model": model_name},
)
counters["evidences"] += 1
for interface_id in interface_ids:
rel = await self.upsert_relation(
relation_type="model_executes_via_interface",
subject_entity_id=int(entity.id),
object_entity_id=int(interface_id),
source_type="governance",
source_ref=source_ref or None,
payload={"tier": tier},
)
counters["relations"] += 1
await self.add_evidence(
evidence_type="policy_guardrail",
content_excerpt=f"{tier} model {model_name} executes via controlled interface",
relation_id=int(rel.id),
source_type="governance",
source_ref=source_ref or None,
metadata={"tier": tier},
)
counters["evidences"] += 1
return counters
async def sync_model_guardrails_if_due(
self,
*,
interval_hours: int = 6,
) -> dict[str, Any]:
hours = max(1, int(interval_hours))
offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at")
last_sync_at = self._parse_iso_dt(offset_raw)
now = _utc_now_naive()
if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours):
return {
"executed": False,
"reason": "not_due",
"last_sync_at": last_sync_at.isoformat(),
"next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(),
}
payload = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event(
channel="model_guardrail",
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
payload=payload,
status="done",
)
await self.set_ingest_offset(
channel="model_guardrail",
offset_key="last_model_guardrail_sync_at",
offset_value=_utc_now_naive().isoformat(),
payload=payload,
)
await self.db.commit()
return {
"executed": True,
"reason": "due",
"payload": payload,
}
async def sync_model_guardrails_now(self) -> dict[str, Any]:
payload = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event(
channel="model_guardrail",
event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",
payload=payload,
status="done",
)
await self.set_ingest_offset(
channel="model_guardrail",
offset_key="last_model_guardrail_sync_at",
offset_value=_utc_now_naive().isoformat(),
payload=payload,
)
await self.db.commit()
return payload
async def sync_feature_catalog_if_due( async def sync_feature_catalog_if_due(
self, self,
*, *,
@ -1403,8 +1697,18 @@ class ProjectKnowledgeGraphService:
stage_errors["feature_catalog"] = str(exc)[:500] stage_errors["feature_catalog"] = str(exc)[:500]
stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]}
try:
stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due(
interval_hours=feature_scan_hours,
)
success_count += 1
except Exception as exc:
await self.db.rollback()
stage_errors["model_guardrails"] = str(exc)[:500]
stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]}
ended = _utc_now_naive() ended = _utc_now_naive()
if success_count == 6: if success_count == 7:
final_status = "success" final_status = "success"
elif success_count == 0: elif success_count == 0:
final_status = "failed" final_status = "failed"
@ -1622,6 +1926,8 @@ class ProjectKnowledgeGraphService:
) )
counters["evidences"] += 1 counters["evidences"] += 1
counters["model_guardrails"] = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event( await self.enqueue_ingest_event(
channel="db_delta", channel="db_delta",
event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}",

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -57,6 +58,14 @@ class TaskBriefService:
def _project_root() -> Path: def _project_root() -> Path:
return Path(__file__).resolve().parents[3] return Path(__file__).resolve().parents[3]
@staticmethod
def _normalize_template_text(content: str) -> str:
return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip()
@classmethod
def _is_default_template_fallback(cls, content: str) -> bool:
return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK)
@classmethod @classmethod
def _load_default_template_content(cls) -> str: def _load_default_template_content(cls) -> str:
template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md" template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md"
@ -67,6 +76,53 @@ class TaskBriefService:
return DEFAULT_TEMPLATE_FALLBACK return DEFAULT_TEMPLATE_FALLBACK
return DEFAULT_TEMPLATE_FALLBACK return DEFAULT_TEMPLATE_FALLBACK
@classmethod
def _resolve_git_ref(cls, ref: str) -> str:
try:
output = subprocess.check_output(
["git", "rev-parse", "--short", ref],
cwd=str(cls._project_root()),
stderr=subprocess.DEVNULL,
text=True,
timeout=1.5,
).strip()
return output or "-"
except Exception:
return "-"
@staticmethod
def _to_markdown_list(items: list[str], *, empty_text: str) -> str:
cleaned = [str(item).strip() for item in items if str(item).strip()]
if not cleaned:
return f"- {empty_text}"
return "\n".join(f"- {item}" for item in cleaned)
@classmethod
def _render_template_content(
cls,
*,
template_content: str,
generated_at: str,
base_ref: str,
head_ref: str,
changed_files: list[str],
scope_summary: str,
risks: list[str],
) -> str:
content = str(template_content or "").strip() or cls._load_default_template_content()
replacements = {
"{{GENERATED_AT}}": str(generated_at or "-"),
"{{BASE_REF}}": str(base_ref or "-"),
"{{HEAD_REF}}": str(head_ref or "-"),
"{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"),
"{{SCOPE_SUMMARY}}": str(scope_summary or "-"),
"{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"),
}
rendered = content
for token, value in replacements.items():
rendered = rendered.replace(token, value)
return rendered.strip()
@classmethod @classmethod
def _build_default_template(cls) -> dict[str, Any]: def _build_default_template(cls) -> dict[str, Any]:
now_text = now_cn_naive().isoformat() now_text = now_cn_naive().isoformat()
@ -227,6 +283,26 @@ class TaskBriefService:
if not existing_templates: if not existing_templates:
bundle["templates"] = [cls._build_default_template()] bundle["templates"] = [cls._build_default_template()]
changed = True changed = True
else:
upgraded_default_template = False
latest_default_content = cls._load_default_template_content()
for item in existing_templates:
if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID:
continue
current_content = str(item.get("content") or "").strip()
if current_content and not cls._is_default_template_fallback(current_content):
break
item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME
item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION
item["content"] = latest_default_content
item["updated_at"] = now_cn_naive().isoformat()
if item.get("updated_by") is None:
item["updated_by"] = 0
upgraded_default_template = True
break
if upgraded_default_template:
bundle["templates"] = existing_templates
changed = True
existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)] existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)]
existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)] existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)]
@ -355,9 +431,25 @@ class TaskBriefService:
brief_id = f"brief_{uuid4().hex[:12]}" brief_id = f"brief_{uuid4().hex[:12]}"
run_id = f"run_{uuid4().hex[:12]}" run_id = f"run_{uuid4().hex[:12]}"
content = str((template_obj or {}).get("content") or "").strip()
file_lines = [str(path).strip() for path in changed_files if str(path).strip()] file_lines = [str(path).strip() for path in changed_files if str(path).strip()]
summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更" summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更"
risks = (
[
"跨文件改动可能引发契约/类型不一致,请补充联动核对。",
"合入前需完成 build/lint/test 并记录结果。",
]
if file_lines
else ["未提供变更文件列表,风险评估信息不足,请人工补充。"]
)
content = cls._render_template_content(
template_content=str((template_obj or {}).get("content") or "").strip(),
generated_at=now_text,
base_ref=cls._resolve_git_ref("HEAD~1"),
head_ref=cls._resolve_git_ref("HEAD"),
changed_files=file_lines,
scope_summary=summary,
risks=risks,
)
run = { run = {
"id": run_id, "id": run_id,

View File

@ -6,7 +6,7 @@ from tempfile import NamedTemporaryFile
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import ( from app.models import (
@ -206,6 +206,20 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(intent.name, "ignore") self.assertEqual(intent.name, "ignore")
def test_intent_resolver_prefers_signal_create_for_operation_with_query_words(self) -> None:
intent = IntentResolver.resolve(
text="给AI劝退助手加一个需求,劝退完后第二天问情况如何跟进?",
project_id=1,
)
self.assertEqual(intent.name, INTENT_SIGNAL_CREATE)
def test_intent_resolver_negated_write_statement_prefers_query(self) -> None:
intent = IntentResolver.resolve(
text="别乱创建信号,说明你是谁",
project_id=1,
)
self.assertEqual(intent.name, INTENT_QUERY)
async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)
@ -270,6 +284,86 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(bool(work_item.review_summary.get("verified"))) self.assertTrue(bool(work_item.review_summary.get("verified")))
self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id)) self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id))
async def test_process_ingress_negated_write_statement_forces_query(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
AsyncMock(return_value={"answer": "我是 PMFlow 项目协同助手。", "sources": []}),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="msg-negated-write-1",
message_text="别乱创建信号,说明你是谁",
sender="老板",
channel_ref="pmflow",
)
work_item = (
await db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == 1,
WorkItem.source_message_id == "msg-negated-write-1",
)
)
)
).scalar_one()
signal_count = (
await db.execute(
select(func.count()).select_from(Signal).where(Signal.tenant_id == 1)
)
).scalar_one()
self.assertEqual(result["status"], "answered")
self.assertEqual(work_item.intent, INTENT_QUERY)
self.assertEqual(int(signal_count or 0), 0)
async def test_process_query_message_returns_failed_safe_when_rag_raises(self) -> None:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch(
"app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question",
AsyncMock(side_effect=RuntimeError("模型调用失败:模型调用失败:function=adaptive_rag_answer_normal")),
):
result = await service.process_ingress(
source_system="pmflow_manual",
source_message_id="msg-query-failed-safe-1",
message_text="项目ID: 1 现在状态如何?",
sender="老板",
channel_ref="pmflow",
project_hint_id=1,
)
self.assertTrue(result["handled"])
self.assertEqual(result["status"], "captured")
self.assertEqual(result["classification"], "question")
reply_text = str(result.get("direct_reply_text") or "")
self.assertIn("模型调用失败:function=adaptive_rag_answer_normal", reply_text)
self.assertNotIn("模型调用失败:模型调用失败:", reply_text)
async def test_resolve_intent_prefers_rule_for_query_without_llm_call(self) -> None:
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
try:
async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
with patch.object(service, "_resolve_intent_with_llm", AsyncMock(return_value=None)) as llm_mock:
intent, source = await service._resolve_intent(
message_text="你是谁?",
session=None,
world_model={"resolved_project_id": 1},
)
self.assertEqual(intent.name, INTENT_QUERY)
self.assertEqual(source, "rule_preferred")
llm_mock.assert_not_awaited()
finally:
if prev is None:
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
else:
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
async def test_duplicate_message_is_filtered(self) -> None: async def test_duplicate_message_is_filtered(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)

View File

@ -0,0 +1,88 @@
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.ai_steward import FeishuCaptureRequest, capture_feishu_signal
from app.models import Base, FeishuReplyOutbox, User
class CaptureFeishuSignalApiTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
tmp = tempfile.NamedTemporaryFile(prefix="pmflow-ai-steward-capture-", suffix=".db", delete=False)
tmp.close()
self.db_path = tmp.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def asyncTearDown(self) -> None:
await self.engine.dispose()
if getattr(self, "db_path", None):
try:
os.remove(self.db_path)
except FileNotFoundError:
pass
async def test_capture_feishu_signal_enqueues_outbox_when_direct_reply_required(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="capture_admin",
email="capture_admin@example.com",
password_hash="hash",
name="捕获管理员",
role="ADMIN",
status="active",
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
with patch(
"app.api.ai_steward.ProactiveSignalCaptureService.capture_from_feishu",
AsyncMock(
return_value={
"handled": True,
"status": "captured",
"classification": "signal",
"ai_trace_id": "ai-pmo-123",
"direct_reply_required": True,
"direct_reply_text": "已收到,正在执行。",
}
),
):
payload = await capture_feishu_signal(
request=FeishuCaptureRequest(
group_chat_id="oc_test_001",
message_content="给 AI 劝退助手加一个复杂需求",
sender="老板",
feishu_message_id="om_test_001",
),
current_user=user,
db=db,
tenant_id=1,
)
self.assertTrue(payload["reply_outbox_enqueued"])
self.assertIsInstance(payload["reply_outbox_id"], int)
row = (
await db.execute(
select(FeishuReplyOutbox).where(
and_(
FeishuReplyOutbox.tenant_id == 1,
FeishuReplyOutbox.source_message_id == "om_test_001",
)
)
)
).scalar_one_or_none()
self.assertIsNotNone(row)
assert row is not None
self.assertEqual(row.chat_id, "oc_test_001")
self.assertEqual(row.message_text, "已收到,正在执行。")

View File

@ -77,6 +77,11 @@ class FeishuRoutingReceiptsTest(unittest.TestCase):
"系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。", "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。",
) )
def test_build_capability_reply_returns_stable_text(self) -> None:
reply = FeishuIntegrationService._build_capability_reply("别乱创建信号,说明你是谁")
self.assertIsNotNone(reply)
self.assertIn("PMFlow 的项目协同助手", str(reply))
def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None: def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None:
service = FeishuIntegrationService(db=None) # type: ignore[arg-type] service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
event = { event = {
@ -121,6 +126,53 @@ class FeishuRoutingReceiptsTest(unittest.TestCase):
reply_mock.assert_not_awaited() reply_mock.assert_not_awaited()
def test_handle_message_receive_capability_reply_overrides_execute_direct_reply(self) -> None:
service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
event = {
"message": {
"message_id": "om_capability_guard",
"message_type": "text",
"chat_id": "oc_test",
"chat_type": "group",
"content": '{"text":"@PMFlow 别乱创建信号,说明你是谁"}',
},
"chat": {
"chat_id": "oc_test",
"chat_type": "group",
},
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_test"},
"sender_name": "林宇航",
},
"type": "im.message.receive_v1",
}
with (
patch.object(service, "_record_ingress_trace", AsyncMock()),
patch.object(service, "_record_message_trace", AsyncMock()),
patch.object(service, "_resolve_sender_display_name", AsyncMock(return_value="林宇航")),
patch.object(service, "_safe_reply_text", AsyncMock()) as reply_mock,
patch(
"app.services.feishu_integration.ProactiveSignalCaptureService.capture_from_feishu",
AsyncMock(
return_value={
"status": "captured",
"classification": "signal",
"direct_reply_required": True,
"direct_reply_text": "我在等 Interactive Card 的内容。",
"results": [{"route_status": "queued_for_decision"}],
}
),
),
):
asyncio.run(service._handle_message_receive(event))
reply_mock.assert_awaited_once()
sent_text = str(reply_mock.await_args.kwargs.get("text") or "")
self.assertIn("PMFlow 的项目协同助手", sent_text)
self.assertNotIn("Interactive Card", sent_text)
def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None: def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None:
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type] service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]

View File

@ -1,5 +1,6 @@
import os
import unittest import unittest
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, MagicMock, patch
from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult
@ -203,5 +204,151 @@ class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(calls), 1) self.assertEqual(len(calls), 1)
class LLMQueryFailureBehaviorTest(unittest.IsolatedAsyncioTestCase):
async def test_generate_strict_query_retries_high_tier_even_when_rule_not_low(self) -> None:
service = LLMService()
service._resolve_engine_for_call = AsyncMock(
return_value={
"provider": "glm",
"model": "GLM-4.7",
"api_key": "fake",
"base_url": "http://unit.test",
}
)
service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""})
service._is_low_tier_function = MagicMock(return_value=False)
service._resolve_high_tier_engine_for_escalation = AsyncMock(
return_value={
"provider": "openai",
"model": "GLM-5",
"api_key": "fake-openai",
"base_url": "http://unit.test/openai",
}
)
service._chat_completion = AsyncMock(
side_effect=[ValueError("provider_circuit_open:glm:GLM-4.7"), "高阶模型正常响应"]
)
service._try_safety_engine = AsyncMock(return_value=("", None))
service._log_ai_call = AsyncMock()
text = await service.generate(
"query prompt",
db=object(),
tenant_id=1,
function_name="adaptive_rag_answer_normal",
use_prompt_profile=False,
)
self.assertEqual(text, "高阶模型正常响应")
self.assertEqual(service._chat_completion.await_count, 2)
async def test_generate_raises_runtime_error_for_query_answer_fail_fast(self) -> None:
service = LLMService()
service._resolve_engine_for_call = AsyncMock(
return_value={
"provider": "glm",
"model": "GLM-4.7",
"api_key": "fake",
"base_url": "http://unit.test",
}
)
service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""})
service._chat_completion = AsyncMock(side_effect=ValueError("primary_engine_empty_response"))
service._resolve_high_tier_engine_for_escalation = AsyncMock(return_value=None)
service._try_safety_engine = AsyncMock(return_value=("", None))
service._log_ai_call = AsyncMock()
with self.assertRaises(RuntimeError) as ctx:
await service.generate(
"query prompt",
db=object(),
tenant_id=1,
function_name="adaptive_rag_answer_normal",
use_prompt_profile=False,
)
self.assertIn("模型调用失败:", str(ctx.exception))
service._try_safety_engine.assert_not_awaited()
async def test_generate_keeps_stub_behavior_for_non_query_function(self) -> None:
service = LLMService()
service._resolve_engine_for_call = AsyncMock(
return_value={
"provider": "glm",
"model": "GLM-4.7",
"api_key": "fake",
"base_url": "http://unit.test",
}
)
service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""})
service._chat_completion = AsyncMock(side_effect=ValueError("primary_engine_empty_response"))
service._log_ai_call = AsyncMock()
text = await service.generate(
"reply prompt",
db=object(),
tenant_id=1,
function_name="feishu_humanized_reply",
use_prompt_profile=False,
)
self.assertIn("模型服务异常", text)
class LLMRoutingSelectionTest(unittest.TestCase):
def test_inject_function_guardrails_for_dual_model_functions(self) -> None:
injected = LLMService._inject_function_guardrails(
system_prompt="",
function_name="classify_intent_complex",
)
self.assertIn("ddl_api_required", injected)
self.assertIn("DDL", injected)
def test_merge_prompt_with_profile_appends_guardrail_for_openclaw_dispatch(self) -> None:
merged = LLMService._merge_prompt_with_profile(
prompt='{"instruction":"db ddl"}',
prompt_profile={"role_name": "planner", "system_prompt": "你是规划器。"},
function_name="openclaw_command_dispatch",
)
self.assertIn("你是规划器。", merged)
self.assertIn("ddl_api_required", merged)
def test_resolve_tier_engine_keeps_configured_openai_base_url(self) -> None:
service = LLMService()
env_updates = {
"OPENCLAW_ROUTING_ENABLED": "1",
"OPENAI_BASE_URL": "https://api.edgefn.net/v1",
"LLM_ROUTING_HIGH_PROVIDER": "openai",
}
with patch.dict(os.environ, env_updates, clear=False):
engine = service._resolve_tier_engine(
tier="high",
engines=[],
active_engine={},
)
self.assertIsNotNone(engine)
assert engine is not None
self.assertEqual(str(engine.get("base_url")), "https://api.edgefn.net/v1")
def test_should_retry_empty_chat_response_for_reasoning_length(self) -> None:
should_retry = LLMService._should_retry_empty_chat_response(
structured_output=False,
message={"content": "", "reasoning_content": "先分析上下文"},
choice={"finish_reason": "length"},
current_max_tokens=900,
)
self.assertTrue(should_retry)
def test_should_not_retry_empty_chat_response_when_not_length(self) -> None:
should_retry = LLMService._should_retry_empty_chat_response(
structured_output=False,
message={"content": "", "reasoning_content": "先分析上下文"},
choice={"finish_reason": "stop"},
current_max_tokens=900,
)
self.assertFalse(should_retry)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,117 @@
import json
import os
import tempfile
import unittest
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models import Base, KGEntity, KGRelation
from app.services.project_knowledge_graph import DDL_GUARDRAIL_POLICY_KEY, ProjectKnowledgeGraphService
class ProjectKnowledgeGraphGuardrailSyncTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._old_rules_path = os.environ.get("OPENCLAW_ROUTING_RULES_PATH")
tmp_db = tempfile.NamedTemporaryFile(prefix="pmflow-kg-guardrail-", suffix=".db", delete=False)
tmp_db.close()
self.db_path = tmp_db.name
self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
tmp_rules = tempfile.NamedTemporaryFile(prefix="pmflow-routing-rules-", suffix=".json", delete=False)
tmp_rules.write(
json.dumps(
{
"rules": [
{
"name": "low-intent-rule",
"enabled": True,
"priority": 300,
"tier": "low",
"provider": "glm",
"model": "GLM-4.7",
"api_env": "GLM_API_KEY",
},
{
"name": "high-intent-rule",
"enabled": True,
"priority": 280,
"tier": "high",
"provider": "openai",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
},
]
}
).encode("utf-8")
)
tmp_rules.flush()
tmp_rules.close()
self.rules_path = tmp_rules.name
os.environ["OPENCLAW_ROUTING_RULES_PATH"] = self.rules_path
async def asyncTearDown(self) -> None:
await self.engine.dispose()
for path in (getattr(self, "db_path", None), getattr(self, "rules_path", None)):
if path:
try:
os.remove(path)
except FileNotFoundError:
pass
if self._old_rules_path is None:
os.environ.pop("OPENCLAW_ROUTING_RULES_PATH", None)
else:
os.environ["OPENCLAW_ROUTING_RULES_PATH"] = self._old_rules_path
async def test_sync_model_guardrails_now_builds_dual_model_policy_graph(self) -> None:
async with self.session_factory() as db:
service = ProjectKnowledgeGraphService(db, tenant_id=1)
payload = await service.sync_model_guardrails_now()
self.assertEqual(payload["models"], 2)
self.assertGreaterEqual(payload["relations"], 4)
policy_entity = (
await db.execute(
select(KGEntity).where(
and_(
KGEntity.tenant_id == 1,
KGEntity.entity_type == "governance_policy",
KGEntity.entity_key == DDL_GUARDRAIL_POLICY_KEY,
)
)
)
).scalar_one_or_none()
self.assertIsNotNone(policy_entity)
model_entities = (
await db.execute(
select(KGEntity).where(
and_(
KGEntity.tenant_id == 1,
KGEntity.entity_type == "ai_model",
)
)
)
).scalars().all()
self.assertEqual(len(model_entities), 2)
model_names = {str(row.display_name or "") for row in model_entities}
self.assertEqual(model_names, {"GLM-4.7", "GLM-5"})
policy_relations = (
await db.execute(
select(KGRelation).where(
and_(
KGRelation.tenant_id == 1,
KGRelation.relation_type == "model_must_follow_policy",
KGRelation.object_entity_id == int(policy_entity.id),
)
)
)
).scalars().all()
self.assertEqual(len(policy_relations), 2)

View File

@ -14,7 +14,8 @@ from app.api.task_briefs import (
list_task_brief_runs, list_task_brief_runs,
list_task_brief_templates, list_task_brief_templates,
) )
from app.models import Base, User from app.models import Base, Integration, User
from app.services.task_brief_service import DEFAULT_TEMPLATE_FALLBACK
class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase): class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
@ -95,3 +96,67 @@ class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(sync_board["code"], 0) self.assertEqual(sync_board["code"], 0)
self.assertIn("all_synced", sync_board["data"]) self.assertIn("all_synced", sync_board["data"])
self.assertIsInstance(sync_board["data"].get("files"), list) self.assertIsInstance(sync_board["data"].get("files"), list)
async def test_default_template_upgrade_and_placeholder_render(self) -> None:
async with self.session_factory() as db:
user = User(
tenant_id=1,
username="brief_admin_upgrade",
email="brief_admin_upgrade@example.com",
password_hash="hash",
name="模板管理员",
role="ADMIN",
status="active",
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
db.add(
Integration(
tenant_id=1,
type="task_briefs",
is_active=True,
config={
"templates": [
{
"id": "tpl_default_signal_planner",
"name": "Signal Planner 默认模板",
"description": "系统初始化模板(可按需修改)",
"content": DEFAULT_TEMPLATE_FALLBACK,
"updated_at": "2026-01-01T00:00:00",
"updated_by": 0,
"created_at": "2026-01-01T00:00:00",
}
],
"runs": [],
"briefs": [],
},
)
)
await db.commit()
templates = await list_task_brief_templates(db=db, current_user=user)
self.assertEqual(templates["code"], 0)
default_tpl = next((item for item in templates["data"]["items"] if item.get("id") == "tpl_default_signal_planner"), None)
self.assertIsNotNone(default_tpl)
self.assertIn("## Verification", str(default_tpl["content"]))
self.assertNotEqual(str(default_tpl["content"]).strip(), DEFAULT_TEMPLATE_FALLBACK.strip())
generated = await generate_task_brief(
body=GenerateTaskBriefRequest(
title="Default Template Render",
changed_files=["backend/app/services/task_brief_service.py"],
scope_summary="验证默认模板占位符渲染",
),
db=db,
current_user=user,
)
self.assertEqual(generated["code"], 0)
content = str(generated["data"]["brief"]["content"] or "")
self.assertIn("backend/app/services/task_brief_service.py", content)
self.assertIn("验证默认模板占位符渲染", content)
self.assertNotIn("{{CHANGED_FILES}}", content)
self.assertNotIn("{{SCOPE_SUMMARY}}", content)
self.assertNotIn("{{GENERATED_AT}}", content)

View File

@ -1,4 +1,4 @@
import { NavLink, useLocation } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
FolderKanban, FolderKanban,
@ -9,11 +9,6 @@ import {
Users, Users,
Sparkles, Sparkles,
Sliders, Sliders,
Files,
LayoutTemplate,
History,
ShieldCheck,
Settings2,
X, X,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -31,28 +26,18 @@ const baseNavItems = [
{ to: "/skills", label: "能力管理", icon: BarChart3 }, { to: "/skills", label: "能力管理", icon: BarChart3 },
]; ];
const configToolItems = [
{ to: "/ai-config/task-brief-center", label: "增量任务包中心", icon: Files },
{ to: "/ai-config/task-brief-templates", label: "任务包模板管理", icon: LayoutTemplate },
{ to: "/ai-config/task-brief-runs", label: "任务包生成记录", icon: History },
{ to: "/ai-config/contract-sync-board", label: "契约同步看板", icon: ShieldCheck },
{ to: "/ai-config/model-routing-config", label: "模型路由配置", icon: Settings2 },
];
interface SidebarProps { interface SidebarProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
export function Sidebar({ open, onClose }: SidebarProps) { export function Sidebar({ open, onClose }: SidebarProps) {
const location = useLocation();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isManager = isManagerRole(user?.role); const isManager = isManagerRole(user?.role);
const navItems = isManager const navItems = isManager
? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }] ? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }]
: baseNavItems; : baseNavItems;
const showConfigTools = location.pathname.startsWith("/ai-config");
return ( return (
<aside <aside
@ -106,34 +91,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{label} {label}
</NavLink> </NavLink>
))} ))}
{showConfigTools && (
<div className="mt-3 rounded-2xl border border-slate-200/80 bg-white/70 p-2 dark:border-slate-800 dark:bg-slate-900/60">
<p className="px-2 pb-2 text-[11px] font-semibold tracking-wide text-slate-500 dark:text-slate-400">
智能配置工具
</p>
<div className="space-y-1">
{configToolItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end
onClick={onClose}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-xl px-2.5 py-2 text-xs font-medium transition-colors",
isActive
? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
)
}
>
<Icon className="h-3.5 w-3.5" />
{label}
</NavLink>
))}
</div>
</div>
)}
</nav> </nav>
<div className="mt-4 rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none"> <div className="mt-4 rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
<Button <Button

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react"; import { ArrowLeft, Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
@ -62,6 +62,17 @@ const SECTION_CHROME: Record<InboxDisplayKind, string> = {
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
}; };
const ROOT_LEVEL_PATHS = new Set([
"/dashboard",
"/workloads",
"/inbox",
"/projects",
"/ai-steward",
"/ai-config",
"/skills",
"/users",
]);
interface TopBarProps { interface TopBarProps {
onMenuToggle: () => void; onMenuToggle: () => void;
} }
@ -80,6 +91,22 @@ function formatRelativeTime(value?: string | null) {
return `${Math.floor(diffMs / day)} 天前`; return `${Math.floor(diffMs / day)} 天前`;
} }
function normalizePathname(pathname: string) {
const normalized = pathname.replace(/\/+$/, "");
return normalized || "/";
}
function resolveFallbackBackPath(pathname: string) {
if (pathname.startsWith("/ai-config/")) return "/ai-config";
if (pathname === "/feishu-config") return "/ai-config";
if (/^\/projects\/\d+\/(?:board|charter\/\d+)$/.test(pathname)) return "/projects";
if (/^\/workloads\/\d+$/.test(pathname)) return "/workloads";
const skillTaskMatch = pathname.match(/^\/users\/(\d+)\/skill-tasks\/\d+$/);
if (skillTaskMatch) return `/users/${skillTaskMatch[1]}/skills`;
if (/^\/users\/\d+\/(?:profile|skills)$/.test(pathname)) return "/users";
return "/dashboard";
}
export function TopBar({ onMenuToggle }: TopBarProps) { export function TopBar({ onMenuToggle }: TopBarProps) {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
@ -94,6 +121,9 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
}); });
const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]); const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
const normalizedPathname = useMemo(() => normalizePathname(location.pathname), [location.pathname]);
const showBackButton = useMemo(() => !ROOT_LEVEL_PATHS.has(normalizedPathname), [normalizedPathname]);
const fallbackBackPath = useMemo(() => resolveFallbackBackPath(normalizedPathname), [normalizedPathname]);
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null; const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox"; const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
const { data: boardOverviewPayload } = useQuery({ const { data: boardOverviewPayload } = useQuery({
@ -166,6 +196,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count; const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
return;
}
navigate(fallbackBackPath, { replace: true });
};
return ( return (
<header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6"> <header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-none sm:px-4 md:px-6">
<div className="flex min-w-0 items-center gap-2 sm:gap-4"> <div className="flex min-w-0 items-center gap-2 sm:gap-4">
@ -179,6 +217,19 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
{showBackButton && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
onClick={handleBack}
aria-label="返回上一层"
title="返回上一层"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div> <div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">PMFlow</div>
<div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div> <div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div>