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/tests/test_model_routing_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/.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 发送任务
- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller`
- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列
- low/high 双模型(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required`
## 9. 新增代码的参考模式
- 新增后端接口:参考 `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/audit.py`(`GET /audit/operations`)
- 脏数据修复接口:参考 `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`)
- 项目删除接口(`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`
@ -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`
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
- 双 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)用于离线回放与分桶评分
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
- `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.adaptive_rag_service import AdaptiveRAGService
from ..services.ai_orchestration_run_service import AIOrchestrationRunService
from ..services.feishu_outbox_service import FeishuOutboxService
from ..services.openclaw_commander_service import OpenClawCommanderService
from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService
from ..services.signal_source_repair_service import (
@ -448,7 +449,31 @@ async def capture_feishu_signal(
tenant_id=tenant_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:
raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}")

View File

@ -422,6 +422,8 @@ PROMPT_OPENCLAW_COMMAND_DISPATCH = """
11. 字段未知时返回空字符串、空数组或 null。
12. 信息不足时也必须返回合法 JSON,operations=[]。
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、代码块。
6. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 signal.create。
7. 只输出 JSON,不要额外文本。
8. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 query.read_only,并在 reasoning 包含 ddl_api_required。
"""

View File

@ -376,6 +376,8 @@ class IntentResolver:
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
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
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"})
if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized):
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):
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {})
if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS):
@ -427,6 +458,15 @@ class IntentResolver:
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):
return CanonicalIntent(
INTENT_QUERY,
@ -436,15 +476,6 @@ class IntentResolver:
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(
INTENT_QUERY,
0.6,
@ -454,6 +485,28 @@ class IntentResolver:
{"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
def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool:
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()
if not normalized:
return False
if IntentResolver._looks_like_signal_create_request(text=normalized):
return False
if any(token in normalized for token in QUESTION_HINTS):
return True
has_question_structure = (
@ -1004,6 +1059,7 @@ class TypedExecutor:
) -> dict[str, Any]:
if intent.name == INTENT_QUERY:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
try:
rag_result = await rag_service.answer_question(
question=message_text,
asked_by=operator_user_id,
@ -1014,6 +1070,12 @@ class TypedExecutor:
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 {
"status": "succeeded",
"answer": str(rag_result.get("answer") or "").strip(),
@ -1139,6 +1201,20 @@ class TypedExecutor:
"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(
self,
*,
@ -1395,6 +1471,18 @@ class AIPMOKernelService:
message_text=clean_text,
closure_payload=closure_payload,
)
if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text):
metadata = dict(intent.metadata or {})
metadata["guard_reason"] = "negated_write_statement"
metadata["guarded_from_intent"] = intent.name
intent = CanonicalIntent(
INTENT_QUERY,
max(float(intent.confidence or 0.0), 0.9),
intent.project_id,
"",
clean_text[:300],
metadata,
)
perf_intent_name = intent.name
_log_perf("_resolve_intent", stage_started_at)
if intent_context_source:
@ -2630,6 +2718,9 @@ class AIPMOKernelService:
) -> tuple[CanonicalIntent, str]:
project_id = _safe_int(world_model.get("resolved_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 session is None:
return rule_intent, "rule_fallback"

View File

@ -371,6 +371,7 @@ class FeishuIntegrationService:
)
reply_route = "none"
capability_reply = self._build_capability_reply(message_content)
if scene == "execute":
if self._is_duplicate_capture_result(result):
self._emit_state_transition(
@ -384,7 +385,7 @@ class FeishuIntegrationService:
state="ROUTED",
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:
deterministic_reply = self._build_execution_receipt_reply(
message_content=message_content,
@ -423,7 +424,18 @@ class FeishuIntegrationService:
state="ROUTED",
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(
chat_id=chat_id,
mention_open_id=sender_open_id,
@ -1024,6 +1036,16 @@ class FeishuIntegrationService:
)
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
def _strip_identity_lead(reply_text: str) -> str:
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_COMPLETE_TIMEOUT_SECONDS = 6.0
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 = (
"primary_engine_empty_response",
"high_tier_empty_response",
@ -189,6 +195,19 @@ FAIL_FAST_ERROR_MARKERS = (
)
CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2
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]] = {
"signal_source_onboarding_plan": {
"required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"),
@ -699,15 +718,21 @@ class LLMService:
routing_decision = "primary_engine"
engine_meta: dict[str, Any] = {}
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
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
provider = str(engine_meta.get("provider") or "")
model = str(engine_meta.get("model") or "")
outcome = "兜底 stub" if used_stub else "显式报错"
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,
provider,
model,
@ -718,7 +743,11 @@ class LLMService:
try:
if use_prompt_profile:
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(
db,
tenant_id=tenant_id,
@ -815,13 +844,18 @@ class LLMService:
return response_text
except Exception as 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"
response_text = self._stub_response(prompt)
_runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}")
_log_stub_failure(exc)
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):
raise upgraded_exc
high_engine = await self._resolve_high_tier_engine_for_escalation(
@ -863,9 +897,29 @@ class LLMService:
response_text = escalated_text
return response_text
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}")
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:
safety_attempted = True
safety_text, safety_engine = await self._try_safety_engine(
@ -931,7 +985,11 @@ class LLMService:
return response_text
finally:
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(
tenant_id=tenant_id,
function_name=function_name,
@ -973,7 +1031,11 @@ class LLMService:
try:
if use_prompt_profile:
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(
db,
tenant_id=tenant_id,
@ -1133,7 +1195,10 @@ class LLMService:
raise ValueError(f"provider_circuit_open:{provider_name}:{model}")
key = api_key.strip()
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:
messages.append({"role": "system", "content": cleaned_system_prompt})
messages.append({"role": "user", "content": prompt})
@ -1183,11 +1248,18 @@ class LLMService:
if not key:
raise ValueError("未配置 LLM API Key")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
current_max_tokens = max(int(max_tokens or 0), 1)
empty_content_retry_budget = 1
while True:
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"max_tokens": current_max_tokens,
}
if is_openclaw_gateway_request:
payload["agents"] = {
@ -1195,10 +1267,6 @@ class LLMService:
"timeoutSeconds": int(openclaw_agents_timeout_seconds),
}
}
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
resp = await client.post(endpoint, headers=headers, json=payload)
if is_openclaw_gateway_request:
@ -1225,25 +1293,51 @@ class LLMService:
choices = data.get("choices") or []
if not choices:
raise ValueError("LLM 响应无 choices")
message = choices[0].get("message") if isinstance(choices[0], dict) else {}
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=choices[0] if isinstance(choices[0], dict) else {},
choice=first_choice,
response=data,
structured_output=structured_output,
)
if not text and not structured_output:
if text or structured_output:
break
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 = ""
try:
choice_preview = json.dumps(choices[0], ensure_ascii=False)[:300]
choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300]
except Exception:
choice_preview = str(choices[0])[:300]
raise ValueError(
choice_preview = str(first_choice)[:300]
empty_error = ValueError(
"LLM 返回空内容: "
f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}"
)
can_retry_empty = (
empty_content_retry_budget > 0
and self._should_retry_empty_chat_response(
structured_output=structured_output,
message=message,
choice=first_choice,
current_max_tokens=current_max_tokens,
)
)
if not 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):
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
@ -1894,8 +1988,38 @@ class LLMService:
return self._looks_like_unanswerable_response(response_text)
@staticmethod
def _merge_prompt_with_profile(*, prompt: str, prompt_profile: dict[str, str]) -> str:
system_prompt = str(prompt_profile.get("system_prompt") or "").strip()
def _requires_ddl_api_guardrail(function_name: str) -> bool:
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:
return prompt
role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown"
@ -1911,6 +2035,36 @@ class LLMService:
return False
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
def _is_fast_local_planner_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
@ -1968,6 +2122,34 @@ class LLMService:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
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:
return not self._prefer_fast_local_fallback(function_name)
@ -2540,7 +2722,8 @@ class LLMService:
if base_url_override:
routed["base_url"] = base_url_override
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 ""):
_runtime_log(

View File

@ -10,6 +10,8 @@
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
@ -41,6 +43,20 @@ def _utc_now_naive() -> datetime:
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:
def __init__(self, db: AsyncSession, *, tenant_id: int) -> None:
self.db = db
@ -1093,6 +1109,284 @@ class ProjectKnowledgeGraphService:
counters["evidences"] += 1
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(
self,
*,
@ -1403,8 +1697,18 @@ class ProjectKnowledgeGraphService:
stage_errors["feature_catalog"] = str(exc)[:500]
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()
if success_count == 6:
if success_count == 7:
final_status = "success"
elif success_count == 0:
final_status = "failed"
@ -1622,6 +1926,8 @@ class ProjectKnowledgeGraphService:
)
counters["evidences"] += 1
counters["model_guardrails"] = await self._sync_model_guardrail_entities()
await self.enqueue_ingest_event(
channel="db_delta",
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
import json
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
@ -57,6 +58,14 @@ class TaskBriefService:
def _project_root() -> Path:
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
def _load_default_template_content(cls) -> str:
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
@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
def _build_default_template(cls) -> dict[str, Any]:
now_text = now_cn_naive().isoformat()
@ -227,6 +283,26 @@ class TaskBriefService:
if not existing_templates:
bundle["templates"] = [cls._build_default_template()]
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_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]}"
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()]
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 = {
"id": run_id,

View File

@ -6,7 +6,7 @@ from tempfile import NamedTemporaryFile
from types import SimpleNamespace
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 app.models import (
@ -206,6 +206,20 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
)
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 with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1)
@ -270,6 +284,86 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(bool(work_item.review_summary.get("verified")))
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 with self.session_factory() as db:
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:
service = FeishuIntegrationService(db=None) # type: ignore[arg-type]
event = {
@ -121,6 +126,53 @@ class FeishuRoutingReceiptsTest(unittest.TestCase):
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:
service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type]

View File

@ -1,5 +1,6 @@
import os
import unittest
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult
@ -203,5 +204,151 @@ class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase):
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__":
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_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):
@ -95,3 +96,67 @@ class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(sync_board["code"], 0)
self.assertIn("all_synced", sync_board["data"])
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 {
LayoutDashboard,
FolderKanban,
@ -9,11 +9,6 @@ import {
Users,
Sparkles,
Sliders,
Files,
LayoutTemplate,
History,
ShieldCheck,
Settings2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -31,28 +26,18 @@ const baseNavItems = [
{ 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 {
open: boolean;
onClose: () => void;
}
export function Sidebar({ open, onClose }: SidebarProps) {
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const user = useAuthStore((s) => s.user);
const isManager = isManagerRole(user?.role);
const navItems = isManager
? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }]
: baseNavItems;
const showConfigTools = location.pathname.startsWith("/ai-config");
return (
<aside
@ -106,34 +91,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{label}
</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>
<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

View File

@ -1,5 +1,5 @@
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 { useQuery } from "@tanstack/react-query";
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",
};
const ROOT_LEVEL_PATHS = new Set([
"/dashboard",
"/workloads",
"/inbox",
"/projects",
"/ai-steward",
"/ai-config",
"/skills",
"/users",
]);
interface TopBarProps {
onMenuToggle: () => void;
}
@ -80,6 +91,22 @@ function formatRelativeTime(value?: string | null) {
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) {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
@ -94,6 +121,9 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
});
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 inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
const { data: boardOverviewPayload } = useQuery({
@ -166,6 +196,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
return;
}
navigate(fallbackBackPath, { replace: true });
};
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">
<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" />
</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="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>