Update PMFlow docs and services
This commit is contained in:
parent
5b7cf23aeb
commit
8dde37448e
|
|
@ -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` 并进入人工确认
|
||||||
|
|
|
||||||
|
|
@ -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)}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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')}",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, "已收到,正在执行。")
|
||||||
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue