完成 GLM5 信号计划与主题修复任务
This commit is contained in:
parent
25886a25ca
commit
5b7cf23aeb
|
|
@ -0,0 +1,33 @@
|
||||||
|
name: contract-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "backend/app/api/**"
|
||||||
|
- "backend/app/schemas/**"
|
||||||
|
- "backend/app/services/**"
|
||||||
|
- "frontend/src/api/**"
|
||||||
|
- "frontend/src/types/**"
|
||||||
|
- "docs/architecture/**"
|
||||||
|
- "scripts/check_contract_sync.sh"
|
||||||
|
- ".github/workflows/contract-check.yml"
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- "backend/app/api/**"
|
||||||
|
- "backend/app/schemas/**"
|
||||||
|
- "backend/app/services/**"
|
||||||
|
- "frontend/src/api/**"
|
||||||
|
- "frontend/src/types/**"
|
||||||
|
- "docs/architecture/**"
|
||||||
|
- "scripts/check_contract_sync.sh"
|
||||||
|
- ".github/workflows/contract-check.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contract-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run contract sync check
|
||||||
|
run: |
|
||||||
|
bash scripts/check_contract_sync.sh
|
||||||
|
|
@ -68,7 +68,8 @@
|
||||||
"detect_cognitive_conflicts",
|
"detect_cognitive_conflicts",
|
||||||
"rag_db_project_semantic_retrieval",
|
"rag_db_project_semantic_retrieval",
|
||||||
"workload_optimize_prompt",
|
"workload_optimize_prompt",
|
||||||
"analyze_cost_roi"
|
"analyze_cost_roi",
|
||||||
|
"signal_planner_analyze"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -77,7 +78,7 @@
|
||||||
"priority": 200,
|
"priority": 200,
|
||||||
"tier": "high",
|
"tier": "high",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "GLM-5",
|
"model": "MiniMax-M2.5",
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
"analyze_food_calories",
|
"analyze_food_calories",
|
||||||
|
|
@ -95,9 +96,9 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 50,
|
"priority": 50,
|
||||||
"tier": "safety",
|
"tier": "safety",
|
||||||
"provider": "glm",
|
"provider": "openai",
|
||||||
"model": "GLM-4.7",
|
"model": "deepseek-chat",
|
||||||
"api_env": "GLM_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
"match_all": true
|
"match_all": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -27,6 +27,9 @@ AIProjectManger/
|
||||||
└── docs/ # 额外文档
|
└── docs/ # 额外文档
|
||||||
```
|
```
|
||||||
- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单)
|
- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单)
|
||||||
|
- 新增:`docs/architecture/`(架构契约文档目录,含 `MASTER_CONTRACT.md/API_CONTRACTS.md/DATAFLOW.md/TASK_BRIEF_TEMPLATE.md`)
|
||||||
|
- 新增:`docs/task_briefs/`(增量任务包生成产物目录)
|
||||||
|
- 新增:`docs/task_briefs/sidebar_new_entries_20260312.png`、`docs/task_briefs/model_routing_override_effective_20260312.png`(Step 8 页面验收截图)
|
||||||
|
|
||||||
## 4. 后端结构(`backend/`)
|
## 4. 后端结构(`backend/`)
|
||||||
```text
|
```text
|
||||||
|
|
@ -66,8 +69,18 @@ backend/
|
||||||
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
||||||
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
|
- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
|
||||||
- 新增:`backend/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
|
- 新增:`backend/tests/test_ai_pmo_work_item_decision_api.py`(AI PMO 审批动作接口回归测试)
|
||||||
|
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
|
||||||
|
- 新增:`backend/app/services/signal_strategic_analysis_service.py`(Signal Planner 写回收口)
|
||||||
|
- 新增:`backend/app/services/feishu_outbox_service.py`(复用 reply_hash 的 outbox 入队收口)
|
||||||
|
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
|
- 新增:`backend/app/services/model_routing_service.py`(函数级模型路由配置服务)
|
||||||
|
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
|
||||||
|
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
|
||||||
|
- 新增:`backend/app/api/task_briefs.py`(任务包中心接口)
|
||||||
|
- 新增:`backend/tests/test_model_routing_api.py`(模型路由接口回归测试)
|
||||||
|
- 新增:`backend/tests/test_task_briefs_api.py`(任务包中心接口回归测试)
|
||||||
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
|
- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`)
|
||||||
- 移除:`backend/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`)
|
- 新增:`backend/.openclaw/pmflow-routing-rules.json`(容器内兼容同步副本,与根目录规则保持一致)
|
||||||
|
|
||||||
## 5. 前端结构(`frontend/`)
|
## 5. 前端结构(`frontend/`)
|
||||||
```text
|
```text
|
||||||
|
|
@ -94,6 +107,13 @@ frontend/
|
||||||
- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化)
|
- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化)
|
||||||
- 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制)
|
- 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制)
|
||||||
- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N)
|
- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N)
|
||||||
|
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
|
||||||
|
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
||||||
|
- 新增:`frontend/src/pages/TaskBriefCenterPage.tsx`(增量任务包中心页面)
|
||||||
|
- 新增:`frontend/src/pages/TaskBriefTemplatePage.tsx`(任务包模板管理页面)
|
||||||
|
- 新增:`frontend/src/pages/TaskBriefRunsPage.tsx`(任务包生成记录页面)
|
||||||
|
- 新增:`frontend/src/pages/ContractSyncBoardPage.tsx`(契约同步看板页面)
|
||||||
|
- 新增:`frontend/src/pages/ModelRoutingConfigPage.tsx`(模型路由配置页面)
|
||||||
|
|
||||||
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
||||||
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
|
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
|
||||||
|
|
@ -145,6 +165,8 @@ frontend/
|
||||||
- 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路)
|
- 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路)
|
||||||
- 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘)
|
- 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘)
|
||||||
- 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型)
|
- 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型)
|
||||||
|
- 新增:`scripts/gen_task_brief.sh`(按 git diff 自动生成增量任务包文档)
|
||||||
|
- 新增:`scripts/check_contract_sync.sh`(后端契约变更时强制校验前端类型与架构文档同步)
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -178,6 +200,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
## 9. 新增代码的参考模式
|
## 9. 新增代码的参考模式
|
||||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||||
|
- 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`)
|
||||||
|
- 任务包中心接口:参考 `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`)
|
||||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
||||||
|
|
@ -212,6 +236,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程
|
- AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程
|
||||||
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
|
- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取
|
||||||
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取
|
- OpenClaw 路由规则统一由根目录 `.openclaw/pmflow-routing-rules.json` 管理,容器通过 `OPENCLAW_ROUTING_RULES_PATH` 相对路径 `../.openclaw/pmflow-routing-rules.json` 读取
|
||||||
|
- Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时回退 OpenClaw 默认规则并记录告警
|
||||||
|
- 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
|
||||||
- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分
|
- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"updated_at": "2026-03-11T09:10:00Z",
|
||||||
|
"source_of_truth": "openclaw",
|
||||||
|
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "engine_low_project_draft_glm47_fallback",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 340,
|
||||||
|
"tier": "low",
|
||||||
|
"provider": "glm",
|
||||||
|
"model": "GLM-4.7",
|
||||||
|
"api_env": "GLM_API_KEY",
|
||||||
|
"match_any": [
|
||||||
|
"project_draft_analysis_glm47_fallback",
|
||||||
|
"project_draft_signal_glm47_fallback",
|
||||||
|
"project_draft_charter_glm47_fallback",
|
||||||
|
"project_draft_task_chain_glm47_fallback"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_intent_complex_exact",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 320,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "GLM-5",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"function_exact": "classify_intent_complex"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_low_execute_glm_flash",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 300,
|
||||||
|
"tier": "low",
|
||||||
|
"provider": "glm",
|
||||||
|
"model": "GLM-4.7",
|
||||||
|
"api_env": "GLM_API_KEY",
|
||||||
|
"match_any": [
|
||||||
|
"feishu_level_classification",
|
||||||
|
"feishu_signal_extraction",
|
||||||
|
"rag_intent_classification",
|
||||||
|
"classify_intent",
|
||||||
|
"feishu_humanized_reply",
|
||||||
|
"extract_context_insights"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_reasoning_glm5",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 220,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "GLM-5",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"match_any": [
|
||||||
|
"adaptive_rag_answer",
|
||||||
|
"classify_intent_complex",
|
||||||
|
"feishu_project_prefill",
|
||||||
|
"feishu_task_orchestration",
|
||||||
|
"generate_task_breakdown",
|
||||||
|
"generate_charter_from_signal",
|
||||||
|
"decompose_tasks_from_charter",
|
||||||
|
"recommend_assignees_for_task",
|
||||||
|
"evaluate_model_requirement",
|
||||||
|
"calculate_alignment_score",
|
||||||
|
"detect_cognitive_conflicts",
|
||||||
|
"rag_db_project_semantic_retrieval",
|
||||||
|
"workload_optimize_prompt",
|
||||||
|
"analyze_cost_roi",
|
||||||
|
"signal_planner_analyze"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_high_extended_reasoning",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 200,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "MiniMax-M2.5",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"match_any": [
|
||||||
|
"analyze_food_calories",
|
||||||
|
"feishu_new_project_brief",
|
||||||
|
"feishu_project_signal_classification",
|
||||||
|
"sstc_ai_breakdown",
|
||||||
|
"sstc_charter_localize_zh",
|
||||||
|
"sstc_charter_prefill",
|
||||||
|
"skill_score_update",
|
||||||
|
"test_engine_connection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "engine_safety_fallback",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 50,
|
||||||
|
"tier": "safety",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"match_all": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""add signal planner fields
|
||||||
|
|
||||||
|
Revision ID: add_signal_planner_fields
|
||||||
|
Revises: expand_user_workload_ratio_precision
|
||||||
|
Create Date: 2026-03-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "add_signal_planner_fields"
|
||||||
|
down_revision = "expand_user_workload_ratio_precision"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
for column in inspector.get_columns(table_name):
|
||||||
|
if str(column.get("name") or "") == column_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "signals"):
|
||||||
|
return
|
||||||
|
|
||||||
|
with op.batch_alter_table("signals") as batch_op:
|
||||||
|
if not _column_exists(bind, "signals", "planner_status"):
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"planner_status",
|
||||||
|
sa.String(length=20),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not _column_exists(bind, "signals", "planner_analyzed_at"):
|
||||||
|
batch_op.add_column(sa.Column("planner_analyzed_at", sa.TIMESTAMP(), nullable=True))
|
||||||
|
if not _column_exists(bind, "signals", "planner_assignee_user_id"):
|
||||||
|
batch_op.add_column(sa.Column("planner_assignee_user_id", sa.BigInteger(), nullable=True))
|
||||||
|
|
||||||
|
if not _index_exists(bind, "signals", "ix_signals_planner_status"):
|
||||||
|
op.create_index("ix_signals_planner_status", "signals", ["planner_status"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _table_exists(bind, "signals"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _index_exists(bind, "signals", "ix_signals_planner_status"):
|
||||||
|
op.drop_index("ix_signals_planner_status", table_name="signals")
|
||||||
|
|
||||||
|
with op.batch_alter_table("signals") as batch_op:
|
||||||
|
if _column_exists(bind, "signals", "planner_assignee_user_id"):
|
||||||
|
batch_op.drop_column("planner_assignee_user_id")
|
||||||
|
if _column_exists(bind, "signals", "planner_analyzed_at"):
|
||||||
|
batch_op.drop_column("planner_analyzed_at")
|
||||||
|
if _column_exists(bind, "signals", "planner_status"):
|
||||||
|
batch_op.drop_column("planner_status")
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""模型路由配置 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import error_response, success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.model_routing_service import ModelRoutingService
|
||||||
|
from ..services.operation_log_service import log_operation
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingUpdateRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
api_env: str
|
||||||
|
tier: str = Field(default="high")
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/functions")
|
||||||
|
async def list_model_routing(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
items = await ModelRoutingService.list_effective_routing(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/functions/{function_name}")
|
||||||
|
async def get_model_routing(
|
||||||
|
function_name: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
data = await ModelRoutingService.get_effective_routing(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_name=function_name,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/functions/{function_name}")
|
||||||
|
async def update_model_routing(
|
||||||
|
function_name: str,
|
||||||
|
body: ModelRoutingUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
|
||||||
|
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
before = await ModelRoutingService.get_effective_routing(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_name=function_name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
after = await ModelRoutingService.upsert_override(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_name=function_name,
|
||||||
|
provider=body.provider,
|
||||||
|
model=body.model,
|
||||||
|
api_env=body.api_env,
|
||||||
|
tier=body.tier,
|
||||||
|
base_url=body.base_url,
|
||||||
|
updated_by=int(current_user.id),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||||
|
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
project_id=None,
|
||||||
|
entity_type="model_routing",
|
||||||
|
entity_id=str(function_name),
|
||||||
|
action="update",
|
||||||
|
before_data=before,
|
||||||
|
after_data=after,
|
||||||
|
remark="更新函数级模型路由",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return success_response(after)
|
||||||
|
|
@ -35,6 +35,8 @@ from .signal_sources import router as signal_sources_router
|
||||||
from .operation_catalog import router as operation_catalog_router
|
from .operation_catalog import router as operation_catalog_router
|
||||||
from .ai_pmo import router as ai_pmo_router
|
from .ai_pmo import router as ai_pmo_router
|
||||||
from .audit import router as audit_router
|
from .audit import router as audit_router
|
||||||
|
from .model_routing import router as model_routing_router
|
||||||
|
from .task_briefs import router as task_briefs_router
|
||||||
|
|
||||||
# 创建主 API 路由器
|
# 创建主 API 路由器
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
@ -69,3 +71,5 @@ api_router.include_router(signal_sources_router)
|
||||||
api_router.include_router(operation_catalog_router)
|
api_router.include_router(operation_catalog_router)
|
||||||
api_router.include_router(ai_pmo_router)
|
api_router.include_router(ai_pmo_router)
|
||||||
api_router.include_router(audit_router)
|
api_router.include_router(audit_router)
|
||||||
|
api_router.include_router(model_routing_router)
|
||||||
|
api_router.include_router(task_briefs_router)
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,13 @@ def _signal_source_context(signal: Signal) -> dict:
|
||||||
return source_context if isinstance(source_context, dict) else {}
|
return source_context if isinstance(source_context, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_planner_status(value: Any) -> str:
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if raw in {"pending", "completed", "failed"}:
|
||||||
|
return raw
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value: Any) -> Optional[int]:
|
def _safe_int(value: Any) -> Optional[int]:
|
||||||
try:
|
try:
|
||||||
return int(str(value).strip())
|
return int(str(value).strip())
|
||||||
|
|
@ -1785,6 +1792,7 @@ async def list_signals(
|
||||||
)
|
)
|
||||||
rows = []
|
rows = []
|
||||||
for s in signals:
|
for s in signals:
|
||||||
|
strategic_payload = _as_dict(s.strategic_analysis)
|
||||||
ai_analysis = _signal_ai_analysis(s)
|
ai_analysis = _signal_ai_analysis(s)
|
||||||
source_context = _signal_source_context(s)
|
source_context = _signal_source_context(s)
|
||||||
project_scope = _signal_project_scope(s)
|
project_scope = _signal_project_scope(s)
|
||||||
|
|
@ -1821,6 +1829,14 @@ async def list_signals(
|
||||||
"charter_name": linked_signal_charter_name.get(sid),
|
"charter_name": linked_signal_charter_name.get(sid),
|
||||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
"attachments": _extract_signal_attachments(s),
|
"attachments": _extract_signal_attachments(s),
|
||||||
|
"strategic_analysis": strategic_payload,
|
||||||
|
"planner_status": _normalize_planner_status(getattr(s, "planner_status", None)),
|
||||||
|
"planner_analyzed_at": (
|
||||||
|
s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None
|
||||||
|
),
|
||||||
|
"planner_assignee_user_id": (
|
||||||
|
str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
created_at_dt = s.created_at
|
created_at_dt = s.created_at
|
||||||
if created_at_dt is not None and created_at_dt.tzinfo is not None:
|
if created_at_dt is not None and created_at_dt.tzinfo is not None:
|
||||||
|
|
@ -2308,6 +2324,14 @@ async def get_charter_detail(
|
||||||
"priority": s.priority,
|
"priority": s.priority,
|
||||||
"impact_score": s.impact_score,
|
"impact_score": s.impact_score,
|
||||||
"attachments": _extract_signal_attachments(s),
|
"attachments": _extract_signal_attachments(s),
|
||||||
|
"strategic_analysis": _as_dict(s.strategic_analysis),
|
||||||
|
"planner_status": _normalize_planner_status(getattr(s, "planner_status", None)),
|
||||||
|
"planner_analyzed_at": (
|
||||||
|
s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None
|
||||||
|
),
|
||||||
|
"planner_assignee_user_id": (
|
||||||
|
str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for s in signal_rows
|
for s in signal_rows
|
||||||
],
|
],
|
||||||
|
|
@ -2319,6 +2343,18 @@ async def get_charter_detail(
|
||||||
"priority": source_signal.priority,
|
"priority": source_signal.priority,
|
||||||
"content": source_signal.content,
|
"content": source_signal.content,
|
||||||
"attachments": _extract_signal_attachments(source_signal),
|
"attachments": _extract_signal_attachments(source_signal),
|
||||||
|
"strategic_analysis": _as_dict(source_signal.strategic_analysis),
|
||||||
|
"planner_status": _normalize_planner_status(getattr(source_signal, "planner_status", None)),
|
||||||
|
"planner_analyzed_at": (
|
||||||
|
source_signal.planner_analyzed_at.isoformat()
|
||||||
|
if getattr(source_signal, "planner_analyzed_at", None)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"planner_assignee_user_id": (
|
||||||
|
str(source_signal.planner_assignee_user_id)
|
||||||
|
if getattr(source_signal, "planner_assignee_user_id", None) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if source_signal
|
if source_signal
|
||||||
else None
|
else None
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""增量任务包中心 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.response import error_response, success_response
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..services.auth import has_any_role
|
||||||
|
from ..services.operation_log_service import log_operation
|
||||||
|
from ..services.task_brief_service import TaskBriefService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/task-briefs", tags=["task_briefs"])
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCreateRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
content: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUpdateRequest(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateTaskBriefRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
template_id: Optional[str] = None
|
||||||
|
changed_files: list[str] = Field(default_factory=list)
|
||||||
|
scope_summary: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(current_user: User) -> Optional[JSONResponse]:
|
||||||
|
if has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]):
|
||||||
|
return None
|
||||||
|
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可执行该操作"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/center")
|
||||||
|
async def list_task_brief_center(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
items = await TaskBriefService.list_center(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def list_task_brief_templates(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
items = await TaskBriefService.list_templates(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates")
|
||||||
|
async def create_task_brief_template(
|
||||||
|
body: TemplateCreateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
denied = _require_manager(current_user)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
item = await TaskBriefService.create_template(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operator_user_id=int(current_user.id),
|
||||||
|
name=body.name,
|
||||||
|
content=body.content,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
project_id=None,
|
||||||
|
entity_type="task_brief_template",
|
||||||
|
entity_id=str(item.get("id") or ""),
|
||||||
|
action="create",
|
||||||
|
before_data=None,
|
||||||
|
after_data=item,
|
||||||
|
remark="创建任务包模板",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return success_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/templates/{template_id}")
|
||||||
|
async def update_task_brief_template(
|
||||||
|
template_id: str,
|
||||||
|
body: TemplateUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
denied = _require_manager(current_user)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
before_items = await TaskBriefService.list_templates(db, tenant_id=tenant_id)
|
||||||
|
before = next((item for item in before_items if str(item.get("id") or "") == str(template_id)), None)
|
||||||
|
item = await TaskBriefService.update_template(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operator_user_id=int(current_user.id),
|
||||||
|
template_id=template_id,
|
||||||
|
name=body.name,
|
||||||
|
content=body.content,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
await db.rollback()
|
||||||
|
return JSONResponse(status_code=404, content=error_response(404, "模板不存在"))
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
project_id=None,
|
||||||
|
entity_type="task_brief_template",
|
||||||
|
entity_id=str(template_id),
|
||||||
|
action="update",
|
||||||
|
before_data=before,
|
||||||
|
after_data=item,
|
||||||
|
remark="更新任务包模板",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return success_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs")
|
||||||
|
async def list_task_brief_runs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
items = await TaskBriefService.list_runs(db, tenant_id=tenant_id)
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_task_brief(
|
||||||
|
body: GenerateTaskBriefRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
denied = _require_manager(current_user)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
result = await TaskBriefService.generate_brief(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
operator_user_id=int(current_user.id),
|
||||||
|
title=body.title,
|
||||||
|
template_id=body.template_id,
|
||||||
|
changed_files=body.changed_files,
|
||||||
|
scope_summary=body.scope_summary,
|
||||||
|
)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
project_id=None,
|
||||||
|
entity_type="task_brief_run",
|
||||||
|
entity_id=str((result.get("run") or {}).get("id") or ""),
|
||||||
|
action="generate",
|
||||||
|
before_data=None,
|
||||||
|
after_data=result,
|
||||||
|
remark="生成增量任务包",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contract-sync")
|
||||||
|
async def get_task_brief_contract_sync(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
tenant_id = int(current_user.tenant_id)
|
||||||
|
data = await TaskBriefService.get_contract_sync_status(db, tenant_id=tenant_id)
|
||||||
|
return success_response(data)
|
||||||
|
|
@ -980,6 +980,50 @@ PROMPT_ANALYZE_FOOD_CALORIES = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Signal Planner(成员匹配 + 执行建议)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
PROMPT_SIGNAL_PLANNER = """
|
||||||
|
你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 信号ID:{signal_id}
|
||||||
|
- 信号标题:{signal_title}
|
||||||
|
- 信号摘要:{signal_summary}
|
||||||
|
- 信号正文:{signal_content}
|
||||||
|
- 项目ID:{project_id}
|
||||||
|
- 项目标题:{project_title}
|
||||||
|
- 项目归属判定:{project_scope}
|
||||||
|
- 最近相关信号(JSON):{recent_signals}
|
||||||
|
- 项目成员能力画像(JSON):{member_profiles}
|
||||||
|
- 当前时间(北京时间):{now_cn}
|
||||||
|
|
||||||
|
输出 JSON(严格只输出 JSON):
|
||||||
|
{{
|
||||||
|
"summary": "本次分析摘要(<=120字)",
|
||||||
|
"priority": "high|medium|low",
|
||||||
|
"assignee_user_id": 123,
|
||||||
|
"assignee_reason": "为什么推荐该成员",
|
||||||
|
"execution_plan": [
|
||||||
|
"第一步动作",
|
||||||
|
"第二步动作"
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"风险1",
|
||||||
|
"风险2"
|
||||||
|
],
|
||||||
|
"confidence": 0.0
|
||||||
|
}}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。
|
||||||
|
2. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
||||||
|
3. confidence 范围 0-1,不能省略。
|
||||||
|
4. 只输出 JSON,不要输出 Markdown 或解释文本。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AI 角色 Prompt 配置(支持前端可视化设置)
|
# AI 角色 Prompt 配置(支持前端可视化设置)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""信号模型"""
|
"""信号模型"""
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean, ForeignKey
|
from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean, ForeignKey, TIMESTAMP
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,5 +27,8 @@ class Signal(Base):
|
||||||
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
|
recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="待处理", index=True)
|
status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="待处理", index=True)
|
||||||
|
planner_status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
|
||||||
|
planner_analyzed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
planner_assignee_user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan")
|
feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan")
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ from .openclaw_commander_service import OpenClawCommanderService
|
||||||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||||
from .llm import llm_service
|
from .llm import llm_service
|
||||||
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||||
|
from .signal_planner_service import SignalPlannerService
|
||||||
|
|
||||||
from ..utils.timezone import now_cn_naive
|
from ..utils.timezone import now_cn_naive
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
@ -999,6 +1000,7 @@ class TypedExecutor:
|
||||||
operator_user_id: Optional[int],
|
operator_user_id: Optional[int],
|
||||||
source_system: str,
|
source_system: str,
|
||||||
source_message_id: str,
|
source_message_id: str,
|
||||||
|
channel_ref: Optional[str] = None,
|
||||||
) -> 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)
|
||||||
|
|
@ -1048,6 +1050,19 @@ class TypedExecutor:
|
||||||
intent=intent,
|
intent=intent,
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
)
|
)
|
||||||
|
strategic_payload = (
|
||||||
|
semantic_payload.get("strategic_analysis")
|
||||||
|
if isinstance(semantic_payload.get("strategic_analysis"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
source_context = (
|
||||||
|
strategic_payload.get("source_context")
|
||||||
|
if isinstance(strategic_payload.get("source_context"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
source_context["source_message_id"] = source_message_id
|
||||||
|
source_context["channel_ref"] = channel_ref
|
||||||
|
strategic_payload["source_context"] = source_context
|
||||||
signal = await self._create_signal(
|
signal = await self._create_signal(
|
||||||
title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"),
|
title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"),
|
||||||
summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text),
|
summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text),
|
||||||
|
|
@ -1062,8 +1077,8 @@ class TypedExecutor:
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
strategic_analysis=(
|
strategic_analysis=(
|
||||||
semantic_payload.get("strategic_analysis")
|
strategic_payload
|
||||||
if isinstance(semantic_payload.get("strategic_analysis"), dict)
|
if isinstance(strategic_payload, dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -1088,6 +1103,19 @@ class TypedExecutor:
|
||||||
}
|
}
|
||||||
|
|
||||||
if intent.name == INTENT_SIGNAL_CREATE:
|
if intent.name == INTENT_SIGNAL_CREATE:
|
||||||
|
strategic_analysis = {
|
||||||
|
"source_context": {
|
||||||
|
"source_message_id": source_message_id,
|
||||||
|
"channel_ref": channel_ref,
|
||||||
|
},
|
||||||
|
"ai_analysis": {
|
||||||
|
"summary": str(intent.signal_summary or message_text)[:300],
|
||||||
|
"intent": INTENT_SIGNAL_CREATE,
|
||||||
|
"requirement_project_scope": (
|
||||||
|
"existing_project" if intent.project_id is not None else "pending_classification"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
signal = await self._create_signal(
|
signal = await self._create_signal(
|
||||||
title=intent.signal_title or "新信号",
|
title=intent.signal_title or "新信号",
|
||||||
summary=intent.signal_summary or message_text,
|
summary=intent.signal_summary or message_text,
|
||||||
|
|
@ -1096,11 +1124,13 @@ class TypedExecutor:
|
||||||
source="PMFlow",
|
source="PMFlow",
|
||||||
signal_type="新需求",
|
signal_type="新需求",
|
||||||
recommended_action="enter_only",
|
recommended_action="enter_only",
|
||||||
|
strategic_analysis=strategic_analysis,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"signal_id": int(signal.id),
|
"signal_id": int(signal.id),
|
||||||
"project_id": intent.project_id,
|
"project_id": intent.project_id,
|
||||||
|
"summary": str(intent.signal_summary or message_text)[:300],
|
||||||
"auto_tasks": [],
|
"auto_tasks": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1580,6 +1610,7 @@ class AIPMOKernelService:
|
||||||
operator_user_id=_safe_int(getattr(operator, "id", None)),
|
operator_user_id=_safe_int(getattr(operator, "id", None)),
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
source_message_id=synthetic_message_id,
|
source_message_id=synthetic_message_id,
|
||||||
|
channel_ref=channel_ref,
|
||||||
)
|
)
|
||||||
_log_perf("TypedExecutor.execute", stage_started_at)
|
_log_perf("TypedExecutor.execute", stage_started_at)
|
||||||
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
|
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
|
||||||
|
|
@ -1655,6 +1686,20 @@ class AIPMOKernelService:
|
||||||
last_message_text=clean_text[:200],
|
last_message_text=clean_text[:200],
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
if (
|
||||||
|
intent.name == INTENT_SIGNAL_CREATE
|
||||||
|
and work_item.state == STATE_SUCCEEDED
|
||||||
|
and _safe_int(execution_result.get("signal_id")) is not None
|
||||||
|
):
|
||||||
|
asyncio.create_task(
|
||||||
|
SignalPlannerService.analyze(
|
||||||
|
signal_id=int(execution_result["signal_id"]),
|
||||||
|
tenant_id=int(self.tenant_id),
|
||||||
|
source_message_id=synthetic_message_id,
|
||||||
|
source_chat_id=channel_ref,
|
||||||
|
dedupe_seed=f"signal-planner-failed:{int(execution_result['signal_id'])}",
|
||||||
|
)
|
||||||
|
)
|
||||||
stage_started_at = time.perf_counter()
|
stage_started_at = time.perf_counter()
|
||||||
capture_result = self._build_capture_result(
|
capture_result = self._build_capture_result(
|
||||||
work_item=work_item,
|
work_item=work_item,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""飞书 outbox 入队服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import FeishuReplyOutbox
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuOutboxService:
|
||||||
|
"""复用 reply_hash 幂等策略的 outbox 写入入口。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
|
||||||
|
source = str(source_message_id or "").strip()
|
||||||
|
body = str(message_text or "").strip()
|
||||||
|
if not source or not body:
|
||||||
|
return None
|
||||||
|
raw = f"{source}{body}".encode("utf-8")
|
||||||
|
return hashlib.md5(raw, usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def enqueue(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
chat_id: str,
|
||||||
|
text: str,
|
||||||
|
source_message_id: Optional[str],
|
||||||
|
dedupe_seed: Optional[str] = None,
|
||||||
|
trace_id: Optional[str] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
normalized_chat_id = str(chat_id or "").strip()[:128]
|
||||||
|
if not normalized_chat_id:
|
||||||
|
return None
|
||||||
|
normalized_text = str(text or "").strip()[:2000] or "已收到,正在处理。"
|
||||||
|
normalized_source_message_id = (str(source_message_id or "").strip()[:128] or None)
|
||||||
|
hash_source = normalized_source_message_id or (str(dedupe_seed or "").strip()[:128] or None)
|
||||||
|
reply_hash = cls._build_reply_hash(source_message_id=hash_source, message_text=normalized_text)
|
||||||
|
|
||||||
|
if reply_hash:
|
||||||
|
sent_exists = (
|
||||||
|
await db.execute(
|
||||||
|
select(FeishuReplyOutbox.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeishuReplyOutbox.tenant_id == int(tenant_id),
|
||||||
|
FeishuReplyOutbox.reply_hash == reply_hash,
|
||||||
|
FeishuReplyOutbox.status == "sent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sent_exists is not None:
|
||||||
|
return int(sent_exists)
|
||||||
|
|
||||||
|
row = FeishuReplyOutbox(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
trace_id=(str(trace_id or "").strip()[:64] or None),
|
||||||
|
source_message_id=normalized_source_message_id,
|
||||||
|
chat_id=normalized_chat_id,
|
||||||
|
message_text=normalized_text,
|
||||||
|
reply_hash=reply_hash,
|
||||||
|
status="pending",
|
||||||
|
attempts=0,
|
||||||
|
next_retry_at=None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with db.begin_nested():
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
if reply_hash:
|
||||||
|
sent_exists = (
|
||||||
|
await db.execute(
|
||||||
|
select(FeishuReplyOutbox.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeishuReplyOutbox.tenant_id == int(tenant_id),
|
||||||
|
FeishuReplyOutbox.reply_hash == reply_hash,
|
||||||
|
FeishuReplyOutbox.status == "sent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sent_exists is not None:
|
||||||
|
return int(sent_exists)
|
||||||
|
raise
|
||||||
|
return int(row.id) if row.id is not None else None
|
||||||
|
|
@ -107,6 +107,7 @@ DEFAULT_HIGH_TIER_HINTS = (
|
||||||
"cognitive_conflict",
|
"cognitive_conflict",
|
||||||
"blocker",
|
"blocker",
|
||||||
"model_requirement",
|
"model_requirement",
|
||||||
|
"signal_planner_analyze",
|
||||||
)
|
)
|
||||||
DEFAULT_LOW_TIER_HINTS = (
|
DEFAULT_LOW_TIER_HINTS = (
|
||||||
"feishu_level_classification",
|
"feishu_level_classification",
|
||||||
|
|
@ -145,6 +146,7 @@ STRUCTURED_OUTPUT_FUNCTION_HINTS = (
|
||||||
"sstc_ai_breakdown",
|
"sstc_ai_breakdown",
|
||||||
"sstc_charter_localize_zh",
|
"sstc_charter_localize_zh",
|
||||||
"sstc_charter_prefill",
|
"sstc_charter_prefill",
|
||||||
|
"signal_planner_analyze",
|
||||||
)
|
)
|
||||||
LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = (
|
LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = (
|
||||||
"不知道",
|
"不知道",
|
||||||
|
|
@ -239,6 +241,20 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||||
"project_id": ("number_or_none",),
|
"project_id": ("number_or_none",),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"signal_planner_analyze": {
|
||||||
|
"required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"),
|
||||||
|
"type_checks": {
|
||||||
|
"summary": "str",
|
||||||
|
"priority": "str",
|
||||||
|
"execution_plan": "list",
|
||||||
|
"risks": "list",
|
||||||
|
"confidence": "number",
|
||||||
|
},
|
||||||
|
"optional_type_checks": {
|
||||||
|
"assignee_user_id": ("number_or_none",),
|
||||||
|
"assignee_reason": ("str_or_none",),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1475,6 +1491,16 @@ class LLMService:
|
||||||
active_engine = self._ensure_openclaw_control_plane_engine(active_engine)
|
active_engine = self._ensure_openclaw_control_plane_engine(active_engine)
|
||||||
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
||||||
|
|
||||||
|
override_engine = await self._resolve_function_override_engine(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_name=function_name,
|
||||||
|
engines=engines,
|
||||||
|
active_engine=active_engine,
|
||||||
|
)
|
||||||
|
if override_engine is not None:
|
||||||
|
return override_engine
|
||||||
|
|
||||||
openclaw_routed = self._resolve_openclaw_engine(
|
openclaw_routed = self._resolve_openclaw_engine(
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
engines=engines,
|
engines=engines,
|
||||||
|
|
@ -1537,6 +1563,70 @@ class LLMService:
|
||||||
)
|
)
|
||||||
return self._ensure_openclaw_control_plane_engine(routed)
|
return self._ensure_openclaw_control_plane_engine(routed)
|
||||||
|
|
||||||
|
async def _resolve_function_override_engine(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
function_name: str,
|
||||||
|
engines: list[dict[str, Any]],
|
||||||
|
active_engine: dict[str, Any],
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
fn = str(function_name or "").strip().lower()
|
||||||
|
if not fn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Integration).where(
|
||||||
|
and_(
|
||||||
|
Integration.tenant_id == int(tenant_id),
|
||||||
|
Integration.type == "model_routing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
bundle = _safe_json(row.config if row else {})
|
||||||
|
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
|
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
||||||
|
if not override or not bool(override.get("enabled", True)):
|
||||||
|
return None
|
||||||
|
rule = {
|
||||||
|
"tier": str(override.get("tier") or "high").strip().lower() or "high",
|
||||||
|
"provider": str(override.get("provider") or "").strip().lower(),
|
||||||
|
"model": str(override.get("model") or "").strip(),
|
||||||
|
"api_env": str(override.get("api_env") or "").strip(),
|
||||||
|
"base_url": str(override.get("base_url") or "").strip(),
|
||||||
|
}
|
||||||
|
routed = self._resolve_engine_from_rule(
|
||||||
|
rule=rule,
|
||||||
|
engines=engines,
|
||||||
|
active_engine=active_engine,
|
||||||
|
)
|
||||||
|
if routed is None:
|
||||||
|
return None
|
||||||
|
provider = str(routed.get("provider") or "")
|
||||||
|
if provider != "ollama" and not str(routed.get("api_key") or "").strip():
|
||||||
|
_runtime_log(
|
||||||
|
"LLM路由",
|
||||||
|
(
|
||||||
|
"函数覆盖路由命中但缺少 API Key,回退默认路由:"
|
||||||
|
f"function={fn}, provider={provider}, model={routed.get('model')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
_runtime_log(
|
||||||
|
"LLM路由",
|
||||||
|
(
|
||||||
|
"函数覆盖路由命中:"
|
||||||
|
f"function={fn}, provider={routed.get('provider')}, model={routed.get('model')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self._ensure_openclaw_control_plane_engine(routed)
|
||||||
|
except Exception as exc:
|
||||||
|
_runtime_log("LLM路由", f"函数覆盖路由读取失败:function={fn}, error={exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]:
|
def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]:
|
||||||
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
||||||
active_engine_id = str(bundle.get("active_engine_id") or "")
|
active_engine_id = str(bundle.get("active_engine_id") or "")
|
||||||
|
|
@ -2082,6 +2172,8 @@ class LLMService:
|
||||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
if expected == "number_or_none":
|
if expected == "number_or_none":
|
||||||
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
|
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
|
||||||
|
if expected == "str_or_none":
|
||||||
|
return value is None or isinstance(value, str)
|
||||||
if expected == "dict_or_none":
|
if expected == "dict_or_none":
|
||||||
return value is None or isinstance(value, dict)
|
return value is None or isinstance(value, dict)
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
"""函数级模型路由配置服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Integration
|
||||||
|
from ..utils.timezone import now_cn_naive
|
||||||
|
|
||||||
|
ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"}
|
||||||
|
ALLOWED_TIERS = {"low", "high", "safety"}
|
||||||
|
DEFAULT_FUNCTIONS = ("signal_planner_analyze",)
|
||||||
|
INTEGRATION_TYPE = "model_routing"
|
||||||
|
DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingService:
|
||||||
|
@staticmethod
|
||||||
|
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
# 深拷贝,避免浅拷贝后嵌套 dict 原地修改导致 SQLAlchemy JSON 变更丢失。
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_function_name(function_name: str) -> str:
|
||||||
|
fn = str(function_name or "").strip().lower()
|
||||||
|
if not fn:
|
||||||
|
raise ValueError("function_name 不能为空")
|
||||||
|
return fn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_provider(provider: str) -> str:
|
||||||
|
p = str(provider or "").strip().lower()
|
||||||
|
if p not in ALLOWED_PROVIDERS:
|
||||||
|
raise ValueError("provider 仅支持 openai/glm/deepseek/ollama")
|
||||||
|
return p
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_tier(tier: str) -> str:
|
||||||
|
t = str(tier or "").strip().lower() or "high"
|
||||||
|
if t not in ALLOWED_TIERS:
|
||||||
|
raise ValueError("tier 仅支持 low/high/safety")
|
||||||
|
return t
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rules_path() -> Path:
|
||||||
|
custom = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", DEFAULT_RULES_RELATIVE_PATH) or "").strip()
|
||||||
|
raw = custom or DEFAULT_RULES_RELATIVE_PATH
|
||||||
|
path = Path(raw)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = (Path(__file__).resolve().parents[2] / path).resolve()
|
||||||
|
return path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool:
|
||||||
|
if bool(rule.get("match_all", False)):
|
||||||
|
return True
|
||||||
|
fn = cls._normalize_function_name(function_name)
|
||||||
|
exact = str(rule.get("function_exact") or "").strip().lower()
|
||||||
|
if exact and exact == fn:
|
||||||
|
return True
|
||||||
|
prefix = str(rule.get("function_prefix") or "").strip().lower()
|
||||||
|
if prefix in {"*", "all"}:
|
||||||
|
return True
|
||||||
|
if prefix and fn.startswith(prefix):
|
||||||
|
return True
|
||||||
|
match_any = rule.get("match_any") if isinstance(rule.get("match_any"), list) else []
|
||||||
|
for item in match_any:
|
||||||
|
token = str(item or "").strip().lower()
|
||||||
|
if token and token in fn:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None:
|
||||||
|
path = cls._rules_path()
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
rules = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else []
|
||||||
|
indexed: list[tuple[int, int, dict[str, Any]]] = []
|
||||||
|
for idx, row in enumerate(rules):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
if not bool(row.get("enabled", True)):
|
||||||
|
continue
|
||||||
|
if not cls._rule_matches_function(rule=row, function_name=function_name):
|
||||||
|
continue
|
||||||
|
indexed.append((int(row.get("priority") or 0), -idx, row))
|
||||||
|
if not indexed:
|
||||||
|
return None
|
||||||
|
indexed.sort(reverse=True)
|
||||||
|
return dict(indexed[0][2])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Integration).where(
|
||||||
|
and_(
|
||||||
|
Integration.tenant_id == int(tenant_id),
|
||||||
|
Integration.type == INTEGRATION_TYPE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = Integration(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
type=INTEGRATION_TYPE,
|
||||||
|
is_active=True,
|
||||||
|
config={"mappings": {}},
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
bundle = cls._safe_dict(row.config)
|
||||||
|
if not isinstance(bundle.get("mappings"), dict):
|
||||||
|
bundle["mappings"] = {}
|
||||||
|
return row, bundle
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _effective_from_override(cls, *, function_name: str, override: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"function_name": cls._normalize_function_name(function_name),
|
||||||
|
"provider": str(override.get("provider") or "").strip().lower(),
|
||||||
|
"model": str(override.get("model") or "").strip(),
|
||||||
|
"api_env": str(override.get("api_env") or "").strip(),
|
||||||
|
"tier": str(override.get("tier") or "high").strip().lower(),
|
||||||
|
"base_url": str(override.get("base_url") or "").strip(),
|
||||||
|
"source": "override",
|
||||||
|
"updated_at": str(override.get("updated_at") or ""),
|
||||||
|
"updated_by": override.get("updated_by"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _effective_from_rule(cls, *, function_name: str, rule: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if rule is None:
|
||||||
|
return {
|
||||||
|
"function_name": cls._normalize_function_name(function_name),
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "GLM-5",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"tier": "high",
|
||||||
|
"base_url": "",
|
||||||
|
"source": "default",
|
||||||
|
"updated_at": "",
|
||||||
|
"updated_by": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"function_name": cls._normalize_function_name(function_name),
|
||||||
|
"provider": str(rule.get("provider") or "").strip().lower(),
|
||||||
|
"model": str(rule.get("model") or "").strip(),
|
||||||
|
"api_env": str(rule.get("api_env") or "").strip(),
|
||||||
|
"tier": str(rule.get("tier") or "high").strip().lower(),
|
||||||
|
"base_url": str(rule.get("base_url") or "").strip(),
|
||||||
|
"source": "default",
|
||||||
|
"updated_at": "",
|
||||||
|
"updated_by": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_effective_routing(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
function_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
fn = cls._normalize_function_name(function_name)
|
||||||
|
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
||||||
|
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
|
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
||||||
|
if override and bool(override.get("enabled", True)):
|
||||||
|
return cls._effective_from_override(function_name=fn, override=override)
|
||||||
|
return cls._effective_from_rule(function_name=fn, rule=cls._load_default_rule(function_name=fn))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_effective_routing(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
||||||
|
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
|
keys = {cls._normalize_function_name(k) for k in mappings.keys() if str(k).strip()}
|
||||||
|
keys.update(DEFAULT_FUNCTIONS)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for fn in sorted(keys):
|
||||||
|
items.append(await cls.get_effective_routing(db, tenant_id=int(tenant_id), function_name=fn))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def upsert_override(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
function_name: str,
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
api_env: str,
|
||||||
|
tier: str,
|
||||||
|
updated_by: int,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
fn = cls._normalize_function_name(function_name)
|
||||||
|
normalized_provider = cls._normalize_provider(provider)
|
||||||
|
normalized_tier = cls._normalize_tier(tier)
|
||||||
|
normalized_model = str(model or "").strip()
|
||||||
|
if not normalized_model:
|
||||||
|
raise ValueError("model 不能为空")
|
||||||
|
normalized_api_env = str(api_env or "").strip()
|
||||||
|
if normalized_provider != "ollama" and not normalized_api_env:
|
||||||
|
raise ValueError("api_env 不能为空")
|
||||||
|
|
||||||
|
row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
|
||||||
|
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||||||
|
mappings[fn] = {
|
||||||
|
"provider": normalized_provider,
|
||||||
|
"model": normalized_model,
|
||||||
|
"api_env": normalized_api_env,
|
||||||
|
"tier": normalized_tier,
|
||||||
|
"base_url": str(base_url or "").strip(),
|
||||||
|
"enabled": True,
|
||||||
|
"updated_at": now_cn_naive().isoformat(),
|
||||||
|
"updated_by": int(updated_by),
|
||||||
|
}
|
||||||
|
bundle["mappings"] = mappings
|
||||||
|
row.config = bundle
|
||||||
|
await db.flush()
|
||||||
|
return cls._effective_from_override(function_name=fn, override=mappings[fn])
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
"""Signal Planner 异步分析服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config.ai_prompts import PROMPT_SIGNAL_PLANNER
|
||||||
|
from ..core.logging import get_logger
|
||||||
|
from ..database import AsyncSessionLocal
|
||||||
|
from ..models import Project, Signal, SkillTag, User, UserProject, UserSkillBinding, UserWorkload
|
||||||
|
from ..utils.timezone import now_cn_naive
|
||||||
|
from .feishu_outbox_service import FeishuOutboxService
|
||||||
|
from .llm import llm_service
|
||||||
|
from .model_routing_service import ModelRoutingService
|
||||||
|
from .signal_strategic_analysis_service import SignalStrategicAnalysisService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
PLANNER_MAX_WAIT_SECONDS = 25.0
|
||||||
|
|
||||||
|
|
||||||
|
class SignalPlannerService:
|
||||||
|
"""对单条 signal 进行异步成员匹配与计划建议。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_int(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _load_signal(cls, db: AsyncSession, *, tenant_id: int, signal_id: int) -> Signal | None:
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(Signal).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == int(tenant_id),
|
||||||
|
Signal.id == int(signal_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _load_project_title(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
if project_id is None:
|
||||||
|
return ""
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project.title).where(
|
||||||
|
and_(
|
||||||
|
Project.tenant_id == int(tenant_id),
|
||||||
|
Project.id == int(project_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return str(row or "")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _load_recent_signal_summaries(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int],
|
||||||
|
exclude_signal_id: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if project_id is None:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Signal.id, Signal.title, Signal.summary, Signal.priority)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == int(tenant_id),
|
||||||
|
Signal.matched_project_id == int(project_id),
|
||||||
|
Signal.id != int(exclude_signal_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Signal.created_at.desc())
|
||||||
|
.limit(8)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": int(row[0]),
|
||||||
|
"title": str(row[1] or "")[:120],
|
||||||
|
"summary": str(row[2] or "")[:180],
|
||||||
|
"priority": str(row[3] or "")[:20],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _load_member_profiles(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if project_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
User.id,
|
||||||
|
User.name,
|
||||||
|
User.role,
|
||||||
|
UserWorkload.workload_ratio,
|
||||||
|
SkillTag.name,
|
||||||
|
UserSkillBinding.proficiency,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
UserProject,
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == int(tenant_id),
|
||||||
|
UserProject.user_id == User.id,
|
||||||
|
UserProject.project_id == int(project_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
UserWorkload,
|
||||||
|
and_(
|
||||||
|
UserWorkload.tenant_id == int(tenant_id),
|
||||||
|
UserWorkload.user_id == User.id,
|
||||||
|
),
|
||||||
|
isouter=True,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
UserSkillBinding,
|
||||||
|
and_(
|
||||||
|
UserSkillBinding.tenant_id == int(tenant_id),
|
||||||
|
UserSkillBinding.user_id == User.id,
|
||||||
|
),
|
||||||
|
isouter=True,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
SkillTag,
|
||||||
|
and_(
|
||||||
|
SkillTag.tenant_id == int(tenant_id),
|
||||||
|
SkillTag.id == UserSkillBinding.skill_tag_id,
|
||||||
|
),
|
||||||
|
isouter=True,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.tenant_id == int(tenant_id),
|
||||||
|
User.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(User.id.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
by_user: dict[int, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
user_id = cls._safe_int(row[0])
|
||||||
|
if user_id is None:
|
||||||
|
continue
|
||||||
|
profile = by_user.setdefault(
|
||||||
|
int(user_id),
|
||||||
|
{
|
||||||
|
"user_id": int(user_id),
|
||||||
|
"name": str(row[1] or "成员"),
|
||||||
|
"role": str(row[2] or ""),
|
||||||
|
"workload_ratio": float(row[3] or 0.0),
|
||||||
|
"skills": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
skill_name = str(row[4] or "").strip()
|
||||||
|
proficiency = cls._safe_int(row[5])
|
||||||
|
if skill_name:
|
||||||
|
profile["skills"].append(
|
||||||
|
{
|
||||||
|
"name": skill_name,
|
||||||
|
"proficiency": proficiency,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items = list(by_user.values())
|
||||||
|
for item in items:
|
||||||
|
item["skills"] = item["skills"][:10]
|
||||||
|
return items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_outbox_targets(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
signal: Signal,
|
||||||
|
source_message_id: Optional[str],
|
||||||
|
source_chat_id: Optional[str],
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
strategic = cls._as_dict(signal.strategic_analysis)
|
||||||
|
source_context = strategic.get("source_context") if isinstance(strategic.get("source_context"), dict) else {}
|
||||||
|
resolved_chat_id = (
|
||||||
|
str(source_chat_id or "").strip()
|
||||||
|
or str(source_context.get("source_chat_id") or "").strip()
|
||||||
|
or str(source_context.get("chat_id") or "").strip()
|
||||||
|
or str(source_context.get("channel_ref") or "").strip()
|
||||||
|
or str(source_context.get("group_chat_id") or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
resolved_source_message_id = (
|
||||||
|
str(source_message_id or "").strip()
|
||||||
|
or str(source_context.get("source_message_id") or "").strip()
|
||||||
|
or str(source_context.get("message_id") or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
return resolved_chat_id, resolved_source_message_id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_prompt(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
signal: Signal,
|
||||||
|
project_title: str,
|
||||||
|
recent_signals: list[dict[str, Any]],
|
||||||
|
members: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
strategic = cls._as_dict(signal.strategic_analysis)
|
||||||
|
ai_analysis = strategic.get("ai_analysis") if isinstance(strategic.get("ai_analysis"), dict) else {}
|
||||||
|
return PROMPT_SIGNAL_PLANNER.format(
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
signal_title=str(signal.title or ""),
|
||||||
|
signal_summary=str(signal.summary or ai_analysis.get("summary") or ""),
|
||||||
|
signal_content=str(signal.content or ""),
|
||||||
|
project_id=(str(signal.matched_project_id) if signal.matched_project_id is not None else ""),
|
||||||
|
project_title=str(project_title or ""),
|
||||||
|
project_scope=str(ai_analysis.get("requirement_project_scope") or ""),
|
||||||
|
recent_signals=json.dumps(recent_signals, ensure_ascii=False),
|
||||||
|
member_profiles=json.dumps(members, ensure_ascii=False),
|
||||||
|
now_cn=now_cn_naive().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _pick_fallback_assignee_user_id(cls, *, members: list[dict[str, Any]]) -> Optional[int]:
|
||||||
|
candidates: list[tuple[float, int]] = []
|
||||||
|
for item in members:
|
||||||
|
uid = cls._safe_int(item.get("user_id"))
|
||||||
|
workload_ratio = cls._safe_float(item.get("workload_ratio"))
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
candidates.append((float(workload_ratio if workload_ratio is not None else 1000.0), int(uid)))
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda row: (row[0], row[1]))
|
||||||
|
return int(candidates[0][1])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_fallback_planner_result(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
signal: Signal,
|
||||||
|
members: list[dict[str, Any]],
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
fallback_assignee = cls._pick_fallback_assignee_user_id(members=members)
|
||||||
|
summary_seed = (
|
||||||
|
str(signal.summary or "").strip()
|
||||||
|
or str(signal.title or "").strip()
|
||||||
|
or str(signal.content or "").strip()
|
||||||
|
or "信号已入库,模型结构化输出异常,已自动降级生成初始执行建议。"
|
||||||
|
)
|
||||||
|
execution_plan: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"step": "澄清信号目标与边界",
|
||||||
|
"owner_user_id": fallback_assignee,
|
||||||
|
"eta": "4h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "拆解最小可执行任务并确认优先级",
|
||||||
|
"owner_user_id": fallback_assignee,
|
||||||
|
"eta": "1d",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"summary": summary_seed[:180],
|
||||||
|
"priority": str(signal.priority or "medium").strip() or "medium",
|
||||||
|
"execution_plan": execution_plan,
|
||||||
|
"risks": [
|
||||||
|
"模型返回非预期结构,已使用降级策略生成建议,请人工复核。",
|
||||||
|
],
|
||||||
|
"confidence": 0.2,
|
||||||
|
"assignee_user_id": fallback_assignee,
|
||||||
|
"assignee_reason": "按当前最低负载成员自动指派(降级模式)",
|
||||||
|
"fallback_reason": str(reason or "structured_schema_invalid")[:200],
|
||||||
|
"fallback_mode": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def analyze(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
signal_id: int,
|
||||||
|
tenant_id: int,
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
source_chat_id: Optional[str] = None,
|
||||||
|
dedupe_seed: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
signal: Signal | None = None
|
||||||
|
try:
|
||||||
|
signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id))
|
||||||
|
if signal is None:
|
||||||
|
logger.warning(
|
||||||
|
"signal_planner_signal_missing",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
signal_id=int(signal_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
project_id = cls._safe_int(getattr(signal, "matched_project_id", None))
|
||||||
|
project_title = await cls._load_project_title(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
recent_signals = await cls._load_recent_signal_summaries(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
project_id=project_id,
|
||||||
|
exclude_signal_id=int(signal.id),
|
||||||
|
)
|
||||||
|
members = await cls._load_member_profiles(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
prompt = cls._build_prompt(
|
||||||
|
signal=signal,
|
||||||
|
project_title=project_title,
|
||||||
|
recent_signals=recent_signals,
|
||||||
|
members=members,
|
||||||
|
)
|
||||||
|
route = await ModelRoutingService.get_effective_routing(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
function_name="signal_planner_analyze",
|
||||||
|
)
|
||||||
|
if str(route.get("source") or "") != "override":
|
||||||
|
logger.warning(
|
||||||
|
"signal_planner_routing_override_missing",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
fallback_provider=str(route.get("provider") or ""),
|
||||||
|
fallback_model=str(route.get("model") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
extraction = None
|
||||||
|
fallback_reason = ""
|
||||||
|
try:
|
||||||
|
extraction = await asyncio.wait_for(
|
||||||
|
llm_service.generate_structured_object(
|
||||||
|
prompt,
|
||||||
|
db=db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
function_name="signal_planner_analyze",
|
||||||
|
temperature=0.0,
|
||||||
|
max_tokens=1600,
|
||||||
|
),
|
||||||
|
timeout=PLANNER_MAX_WAIT_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
fallback_reason = f"planner_wait_timeout_{int(PLANNER_MAX_WAIT_SECONDS)}s"
|
||||||
|
|
||||||
|
payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None
|
||||||
|
if payload is None:
|
||||||
|
if not fallback_reason:
|
||||||
|
fallback_reason = str((extraction.error_code if extraction is not None else "") or "planner_empty_payload")
|
||||||
|
payload = cls._build_fallback_planner_result(
|
||||||
|
signal=signal,
|
||||||
|
members=members,
|
||||||
|
reason=fallback_reason,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"signal_planner_schema_fallback",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
reason=fallback_reason,
|
||||||
|
raw_text=str((extraction.raw_text if extraction is not None else "") or "")[:300],
|
||||||
|
)
|
||||||
|
|
||||||
|
await SignalStrategicAnalysisService.merge_and_save(
|
||||||
|
db,
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
planner_result=payload,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
"signal_planner_completed",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
if signal is None:
|
||||||
|
signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id))
|
||||||
|
if signal is not None:
|
||||||
|
await SignalStrategicAnalysisService.mark_failed(
|
||||||
|
db,
|
||||||
|
signal_id=int(signal.id),
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
chat_id, source_mid = cls._resolve_outbox_targets(
|
||||||
|
signal=signal,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
)
|
||||||
|
if chat_id:
|
||||||
|
await FeishuOutboxService.enqueue(
|
||||||
|
db,
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
chat_id=chat_id,
|
||||||
|
text="信号已入库,AI 分析暂时失败,系统将自动重试。",
|
||||||
|
source_message_id=source_mid,
|
||||||
|
dedupe_seed=dedupe_seed,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.warning(
|
||||||
|
"signal_planner_failed",
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
signal_id=int(signal_id),
|
||||||
|
error=str(exc)[:1000],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Signal Planner 战略分析写回服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Signal
|
||||||
|
from ..utils.timezone import now_cn_naive
|
||||||
|
|
||||||
|
|
||||||
|
class SignalStrategicAnalysisService:
|
||||||
|
"""收口 Planner 写回逻辑,避免调用链散落裸写。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_int(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def merge_and_save(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
signal_id: int,
|
||||||
|
planner_result: dict[str, Any],
|
||||||
|
tenant_id: int,
|
||||||
|
) -> Signal | None:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Signal).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == int(tenant_id),
|
||||||
|
Signal.id == int(signal_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
strategic = cls._as_dict(row.strategic_analysis)
|
||||||
|
strategic["ai_planner"] = planner_result if isinstance(planner_result, dict) else {}
|
||||||
|
strategic["ai_planner_meta"] = {
|
||||||
|
"updated_at": now_cn_naive().isoformat(),
|
||||||
|
"function_name": "signal_planner_analyze",
|
||||||
|
}
|
||||||
|
|
||||||
|
row.strategic_analysis = json.dumps(strategic, ensure_ascii=False)
|
||||||
|
row.planner_status = "completed"
|
||||||
|
row.planner_analyzed_at = now_cn_naive()
|
||||||
|
assignee_user_id = cls._safe_int((planner_result or {}).get("assignee_user_id"))
|
||||||
|
row.planner_assignee_user_id = assignee_user_id
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_failed(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
signal_id: int,
|
||||||
|
tenant_id: int,
|
||||||
|
reason: str,
|
||||||
|
) -> Signal | None:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Signal).where(
|
||||||
|
and_(
|
||||||
|
Signal.tenant_id == int(tenant_id),
|
||||||
|
Signal.id == int(signal_id),
|
||||||
|
)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
strategic = cls._as_dict(row.strategic_analysis)
|
||||||
|
strategic["ai_planner_error"] = {
|
||||||
|
"reason": str(reason or "planner_failed")[:1000],
|
||||||
|
"failed_at": now_cn_naive().isoformat(),
|
||||||
|
}
|
||||||
|
row.strategic_analysis = json.dumps(strategic, ensure_ascii=False)
|
||||||
|
row.planner_status = "failed"
|
||||||
|
row.planner_analyzed_at = now_cn_naive()
|
||||||
|
await db.flush()
|
||||||
|
return row
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
"""增量任务包管理服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Integration
|
||||||
|
from ..utils.timezone import now_cn_naive
|
||||||
|
|
||||||
|
INTEGRATION_TYPE = "task_briefs"
|
||||||
|
DEFAULT_TEMPLATE_ID = "tpl_default_signal_planner"
|
||||||
|
DEFAULT_TEMPLATE_NAME = "Signal Planner 默认模板"
|
||||||
|
DEFAULT_TEMPLATE_DESCRIPTION = "系统初始化模板(可按需修改)"
|
||||||
|
DEFAULT_TEMPLATE_FALLBACK = """# Task Brief
|
||||||
|
|
||||||
|
## Meta
|
||||||
|
- Generated At: {{GENERATED_AT}}
|
||||||
|
- Base Ref: {{BASE_REF}}
|
||||||
|
- Head Ref: {{HEAD_REF}}
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
{{CHANGED_FILES}}
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
{{SCOPE_SUMMARY}}
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
{{RISKS}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBriefService:
|
||||||
|
@staticmethod
|
||||||
|
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
# 深拷贝,避免浅拷贝触发 JSON 嵌套对象原地修改导致的“提交不落库”。
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
except Exception:
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_default_template_content(cls) -> str:
|
||||||
|
template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md"
|
||||||
|
if template_path.exists():
|
||||||
|
try:
|
||||||
|
return template_path.read_text(encoding="utf-8").strip() or DEFAULT_TEMPLATE_FALLBACK
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_TEMPLATE_FALLBACK
|
||||||
|
return DEFAULT_TEMPLATE_FALLBACK
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_default_template(cls) -> dict[str, Any]:
|
||||||
|
now_text = now_cn_naive().isoformat()
|
||||||
|
return {
|
||||||
|
"id": DEFAULT_TEMPLATE_ID,
|
||||||
|
"name": DEFAULT_TEMPLATE_NAME,
|
||||||
|
"description": DEFAULT_TEMPLATE_DESCRIPTION,
|
||||||
|
"content": cls._load_default_template_content(),
|
||||||
|
"updated_at": now_text,
|
||||||
|
"updated_by": 0,
|
||||||
|
"created_at": now_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]:
|
||||||
|
target = f"## {heading}".lower()
|
||||||
|
lines = content.splitlines()
|
||||||
|
in_section = False
|
||||||
|
collected: list[str] = []
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if line.lower().startswith("## "):
|
||||||
|
if line.lower() == target:
|
||||||
|
in_section = True
|
||||||
|
continue
|
||||||
|
if in_section:
|
||||||
|
break
|
||||||
|
if in_section and line:
|
||||||
|
collected.append(line)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_markdown_meta_value(content: str, *, key: str) -> str | None:
|
||||||
|
prefix = f"- {key}:"
|
||||||
|
for raw in content.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if line.startswith(prefix):
|
||||||
|
return line.split(":", 1)[1].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_generated_at(cls, raw_generated: str | None, fallback: datetime) -> str:
|
||||||
|
if not raw_generated:
|
||||||
|
return fallback.isoformat()
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S %z",):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw_generated, fmt).isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw_generated).isoformat()
|
||||||
|
except Exception:
|
||||||
|
return raw_generated
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_seed_history(cls) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
docs_dir = cls._project_root() / "docs/task_briefs"
|
||||||
|
if not docs_dir.exists():
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
briefs: list[dict[str, Any]] = []
|
||||||
|
runs: list[dict[str, Any]] = []
|
||||||
|
for file_path in sorted(docs_dir.glob("task_brief_*.md")):
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not content.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
stem = "".join(ch for ch in file_path.stem if ch.isalnum() or ch in {"_", "-"})
|
||||||
|
brief_id = f"brief_seed_{stem}"[:64]
|
||||||
|
run_id = f"run_seed_{stem}"[:64]
|
||||||
|
fallback_time = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||||
|
generated_at = cls._normalize_generated_at(
|
||||||
|
cls._extract_markdown_meta_value(content, key="Generated At"),
|
||||||
|
fallback=fallback_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
title = "导入任务包"
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.strip().startswith("# "):
|
||||||
|
title = line.strip()[2:].strip() or title
|
||||||
|
break
|
||||||
|
|
||||||
|
changed_section = cls._extract_markdown_section_lines(content, heading="Changed Files")
|
||||||
|
changed_files = [line[2:].strip() for line in changed_section if line.startswith("- ") and line[2:].strip()]
|
||||||
|
|
||||||
|
scope_section = cls._extract_markdown_section_lines(content, heading="Scope")
|
||||||
|
scope_items = [line[2:].strip() if line.startswith("- ") else line for line in scope_section]
|
||||||
|
scope_summary = ";".join([item for item in scope_items if item][:2]).strip()
|
||||||
|
if not scope_summary:
|
||||||
|
scope_summary = "从 docs/task_briefs 自动导入"
|
||||||
|
|
||||||
|
run = {
|
||||||
|
"id": run_id,
|
||||||
|
"brief_id": brief_id,
|
||||||
|
"title": title,
|
||||||
|
"template_id": DEFAULT_TEMPLATE_ID,
|
||||||
|
"template_name": DEFAULT_TEMPLATE_NAME,
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"generated_by": 0,
|
||||||
|
"status": "imported",
|
||||||
|
"changed_files": changed_files,
|
||||||
|
"scope_summary": scope_summary,
|
||||||
|
}
|
||||||
|
brief = {
|
||||||
|
"id": brief_id,
|
||||||
|
"title": title,
|
||||||
|
"status": "ready",
|
||||||
|
"created_at": generated_at,
|
||||||
|
"updated_at": generated_at,
|
||||||
|
"latest_run_id": run_id,
|
||||||
|
"template_id": DEFAULT_TEMPLATE_ID,
|
||||||
|
"template_name": DEFAULT_TEMPLATE_NAME,
|
||||||
|
"summary": scope_summary,
|
||||||
|
"content": content.strip(),
|
||||||
|
"changed_files": changed_files,
|
||||||
|
}
|
||||||
|
runs.append(run)
|
||||||
|
briefs.append(brief)
|
||||||
|
return briefs, runs
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Integration).where(
|
||||||
|
and_(
|
||||||
|
Integration.tenant_id == int(tenant_id),
|
||||||
|
Integration.type == INTEGRATION_TYPE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = Integration(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
type=INTEGRATION_TYPE,
|
||||||
|
is_active=True,
|
||||||
|
config={
|
||||||
|
"templates": [],
|
||||||
|
"runs": [],
|
||||||
|
"briefs": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
bundle = cls._safe_dict(row.config)
|
||||||
|
changed = False
|
||||||
|
for key in ("templates", "runs", "briefs"):
|
||||||
|
if not isinstance(bundle.get(key), list):
|
||||||
|
bundle[key] = []
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)]
|
||||||
|
if not existing_templates:
|
||||||
|
bundle["templates"] = [cls._build_default_template()]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)]
|
||||||
|
existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)]
|
||||||
|
if not existing_briefs and not existing_runs:
|
||||||
|
seed_briefs, seed_runs = cls._load_seed_history()
|
||||||
|
if seed_briefs and seed_runs:
|
||||||
|
bundle["briefs"] = seed_briefs
|
||||||
|
bundle["runs"] = seed_runs
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
row.config = bundle
|
||||||
|
await db.flush()
|
||||||
|
return row, bundle
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sort_by_time_desc(items: list[dict[str, Any]], key: str = "created_at") -> list[dict[str, Any]]:
|
||||||
|
return sorted(items, key=lambda x: str(x.get(key) or ""), reverse=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_center(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
|
||||||
|
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
briefs = [dict(item) for item in bundle.get("briefs", []) if isinstance(item, dict)]
|
||||||
|
return cls._sort_by_time_desc(briefs, key="created_at")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_templates(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
|
||||||
|
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
templates = [dict(item) for item in bundle.get("templates", []) if isinstance(item, dict)]
|
||||||
|
return cls._sort_by_time_desc(templates, key="updated_at")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_template(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
operator_user_id: int,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
now_text = now_cn_naive().isoformat()
|
||||||
|
item = {
|
||||||
|
"id": f"tpl_{uuid4().hex[:12]}",
|
||||||
|
"name": str(name or "").strip()[:120] or "默认模板",
|
||||||
|
"description": str(description or "").strip()[:300] or None,
|
||||||
|
"content": str(content or "").strip(),
|
||||||
|
"updated_at": now_text,
|
||||||
|
"updated_by": int(operator_user_id),
|
||||||
|
"created_at": now_text,
|
||||||
|
}
|
||||||
|
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
|
||||||
|
templates.append(item)
|
||||||
|
bundle["templates"] = templates
|
||||||
|
row.config = bundle
|
||||||
|
await db.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update_template(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
operator_user_id: int,
|
||||||
|
template_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
content: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
|
||||||
|
target = None
|
||||||
|
for item in templates:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("id") or "") == str(template_id or ""):
|
||||||
|
target = item
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
if name is not None:
|
||||||
|
target["name"] = str(name or "").strip()[:120] or target.get("name") or "默认模板"
|
||||||
|
if content is not None:
|
||||||
|
target["content"] = str(content or "").strip()
|
||||||
|
if description is not None:
|
||||||
|
target["description"] = str(description or "").strip()[:300] or None
|
||||||
|
target["updated_at"] = now_cn_naive().isoformat()
|
||||||
|
target["updated_by"] = int(operator_user_id)
|
||||||
|
bundle["templates"] = templates
|
||||||
|
row.config = bundle
|
||||||
|
await db.flush()
|
||||||
|
return dict(target)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_runs(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]:
|
||||||
|
_, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
runs = [dict(item) for item in bundle.get("runs", []) if isinstance(item, dict)]
|
||||||
|
return cls._sort_by_time_desc(runs, key="generated_at")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def generate_brief(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
operator_user_id: int,
|
||||||
|
title: str,
|
||||||
|
template_id: Optional[str],
|
||||||
|
changed_files: list[str],
|
||||||
|
scope_summary: Optional[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id)
|
||||||
|
now_text = now_cn_naive().isoformat()
|
||||||
|
templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else []
|
||||||
|
template_obj = None
|
||||||
|
if template_id:
|
||||||
|
for item in templates:
|
||||||
|
if isinstance(item, dict) and str(item.get("id") or "") == str(template_id):
|
||||||
|
template_obj = item
|
||||||
|
break
|
||||||
|
if template_obj is None:
|
||||||
|
template_obj = next((item for item in templates if isinstance(item, dict)), None)
|
||||||
|
|
||||||
|
brief_id = f"brief_{uuid4().hex[:12]}"
|
||||||
|
run_id = f"run_{uuid4().hex[:12]}"
|
||||||
|
content = str((template_obj or {}).get("content") or "").strip()
|
||||||
|
file_lines = [str(path).strip() for path in changed_files if str(path).strip()]
|
||||||
|
summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更"
|
||||||
|
|
||||||
|
run = {
|
||||||
|
"id": run_id,
|
||||||
|
"brief_id": brief_id,
|
||||||
|
"title": str(title or "").strip()[:160] or "增量任务包",
|
||||||
|
"template_id": str((template_obj or {}).get("id") or "") or None,
|
||||||
|
"template_name": str((template_obj or {}).get("name") or "") or None,
|
||||||
|
"generated_at": now_text,
|
||||||
|
"generated_by": int(operator_user_id),
|
||||||
|
"status": "completed",
|
||||||
|
"changed_files": file_lines,
|
||||||
|
"scope_summary": summary,
|
||||||
|
}
|
||||||
|
brief = {
|
||||||
|
"id": brief_id,
|
||||||
|
"title": run["title"],
|
||||||
|
"status": "ready",
|
||||||
|
"created_at": now_text,
|
||||||
|
"updated_at": now_text,
|
||||||
|
"latest_run_id": run_id,
|
||||||
|
"template_id": run["template_id"],
|
||||||
|
"template_name": run["template_name"],
|
||||||
|
"summary": summary,
|
||||||
|
"content": content,
|
||||||
|
"changed_files": file_lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs = bundle.get("runs") if isinstance(bundle.get("runs"), list) else []
|
||||||
|
briefs = bundle.get("briefs") if isinstance(bundle.get("briefs"), list) else []
|
||||||
|
runs.append(run)
|
||||||
|
briefs = [item for item in briefs if isinstance(item, dict) and str(item.get("id") or "") != brief_id]
|
||||||
|
briefs.append(brief)
|
||||||
|
|
||||||
|
bundle["runs"] = runs
|
||||||
|
bundle["briefs"] = briefs
|
||||||
|
row.config = bundle
|
||||||
|
await db.flush()
|
||||||
|
return {"run": run, "brief": brief}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_contract_sync_status(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]:
|
||||||
|
_ = db
|
||||||
|
_ = tenant_id
|
||||||
|
root = Path(__file__).resolve().parents[3]
|
||||||
|
contract_files = [
|
||||||
|
root / "docs/architecture/MASTER_CONTRACT.md",
|
||||||
|
root / "docs/architecture/API_CONTRACTS.md",
|
||||||
|
root / "docs/architecture/DATAFLOW.md",
|
||||||
|
]
|
||||||
|
statuses = []
|
||||||
|
for file_path in contract_files:
|
||||||
|
exists = file_path.exists()
|
||||||
|
statuses.append(
|
||||||
|
{
|
||||||
|
"file": str(file_path.relative_to(root)),
|
||||||
|
"exists": exists,
|
||||||
|
"updated_at": (
|
||||||
|
datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
|
||||||
|
if exists
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
all_synced = all(bool(item.get("exists")) for item in statuses)
|
||||||
|
return {
|
||||||
|
"all_synced": all_synced,
|
||||||
|
"files": statuses,
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,103 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.api.model_routing import ModelRoutingUpdateRequest, get_model_routing, update_model_routing
|
||||||
|
from app.models import Base, User
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingApiTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
tmp_db = tempfile.NamedTemporaryFile(prefix="pmflow-model-routing-", 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": "planner-default",
|
||||||
|
"enabled": True,
|
||||||
|
"priority": 100,
|
||||||
|
"tier": "high",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "GLM-5",
|
||||||
|
"api_env": "OPENAI_API_KEY",
|
||||||
|
"function_exact": "signal_planner_analyze",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
).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
|
||||||
|
os.environ.pop("OPENCLAW_ROUTING_RULES_PATH", None)
|
||||||
|
|
||||||
|
async def test_get_and_update_model_routing(self) -> None:
|
||||||
|
async with self.session_factory() as db:
|
||||||
|
user = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="routing_admin",
|
||||||
|
email="routing_admin@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="路由管理员",
|
||||||
|
role="BOSS",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
current = await get_model_routing(
|
||||||
|
function_name="signal_planner_analyze",
|
||||||
|
db=db,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
self.assertEqual(current["code"], 0)
|
||||||
|
self.assertEqual(current["data"]["source"], "default")
|
||||||
|
|
||||||
|
updated = await update_model_routing(
|
||||||
|
function_name="signal_planner_analyze",
|
||||||
|
body=ModelRoutingUpdateRequest(
|
||||||
|
provider="glm",
|
||||||
|
model="GLM-4.7",
|
||||||
|
api_env="GLM_API_KEY",
|
||||||
|
tier="low",
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
self.assertEqual(updated["code"], 0)
|
||||||
|
self.assertEqual(updated["data"]["source"], "override")
|
||||||
|
self.assertEqual(updated["data"]["provider"], "glm")
|
||||||
|
self.assertEqual(updated["data"]["model"], "GLM-4.7")
|
||||||
|
|
||||||
|
refreshed = await get_model_routing(
|
||||||
|
function_name="signal_planner_analyze",
|
||||||
|
db=db,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
self.assertEqual(refreshed["code"], 0)
|
||||||
|
self.assertEqual(refreshed["data"]["source"], "override")
|
||||||
|
self.assertEqual(refreshed["data"]["tier"], "low")
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.api.task_briefs import (
|
||||||
|
GenerateTaskBriefRequest,
|
||||||
|
TemplateCreateRequest,
|
||||||
|
create_task_brief_template,
|
||||||
|
generate_task_brief,
|
||||||
|
get_task_brief_contract_sync,
|
||||||
|
list_task_brief_center,
|
||||||
|
list_task_brief_runs,
|
||||||
|
list_task_brief_templates,
|
||||||
|
)
|
||||||
|
from app.models import Base, User
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBriefApiTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
tmp = tempfile.NamedTemporaryFile(prefix="pmflow-task-brief-api-", 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_template_generate_and_list(self) -> None:
|
||||||
|
async with self.session_factory() as db:
|
||||||
|
user = User(
|
||||||
|
tenant_id=1,
|
||||||
|
username="brief_admin",
|
||||||
|
email="brief_admin@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
name="模板管理员",
|
||||||
|
role="ADMIN",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
created = await create_task_brief_template(
|
||||||
|
body=TemplateCreateRequest(
|
||||||
|
name="默认增量模板",
|
||||||
|
description="用于 planner 增量任务包",
|
||||||
|
content="# Template Body",
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
self.assertEqual(created["code"], 0)
|
||||||
|
template_id = str(created["data"]["id"])
|
||||||
|
|
||||||
|
templates = await list_task_brief_templates(db=db, current_user=user)
|
||||||
|
self.assertEqual(templates["code"], 0)
|
||||||
|
self.assertGreaterEqual(len(templates["data"]["items"]), 1)
|
||||||
|
|
||||||
|
generated = await generate_task_brief(
|
||||||
|
body=GenerateTaskBriefRequest(
|
||||||
|
title="Signal Planner Increment",
|
||||||
|
template_id=template_id,
|
||||||
|
changed_files=[
|
||||||
|
"backend/app/services/signal_planner_service.py",
|
||||||
|
"frontend/src/pages/ModelRoutingConfigPage.tsx",
|
||||||
|
],
|
||||||
|
scope_summary="后端 Planner + 前端配置页联动",
|
||||||
|
),
|
||||||
|
db=db,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
self.assertEqual(generated["code"], 0)
|
||||||
|
self.assertEqual(generated["data"]["run"]["status"], "completed")
|
||||||
|
|
||||||
|
center = await list_task_brief_center(db=db, current_user=user)
|
||||||
|
self.assertEqual(center["code"], 0)
|
||||||
|
self.assertGreaterEqual(len(center["data"]["items"]), 1)
|
||||||
|
|
||||||
|
runs = await list_task_brief_runs(db=db, current_user=user)
|
||||||
|
self.assertEqual(runs["code"], 0)
|
||||||
|
self.assertGreaterEqual(len(runs["data"]["items"]), 1)
|
||||||
|
|
||||||
|
sync_board = await get_task_brief_contract_sync(db=db, current_user=user)
|
||||||
|
self.assertEqual(sync_board["code"], 0)
|
||||||
|
self.assertIn("all_synced", sync_board["data"])
|
||||||
|
self.assertIsInstance(sync_board["data"].get("files"), list)
|
||||||
|
|
@ -3,229 +3,248 @@
|
||||||
> 使用规则:严格按步骤推进;每个步骤完成后再勾选;未完成禁止进入下一步。
|
> 使用规则:严格按步骤推进;每个步骤完成后再勾选;未完成禁止进入下一步。
|
||||||
|
|
||||||
## 0. 全局硬约束(执行前必须确认)
|
## 0. 全局硬约束(执行前必须确认)
|
||||||
- [ ] 所有业务写操作必须通过 service/repository 入口调用。
|
- [x] 所有业务写操作必须通过 service/repository 入口调用。
|
||||||
- [ ] 严禁在调用链路散落裸 ORM `insert/update/commit`。
|
- [x] 严禁在调用链路散落裸 ORM `insert/update/commit`。
|
||||||
- [ ] 数据库结构改动仅通过 Alembic migration。
|
- [x] 数据库结构改动仅通过 Alembic migration。
|
||||||
- [ ] API 统一包络:`{code, message, data}`。
|
- [x] API 统一包络:`{code, message, data}`。
|
||||||
- [ ] 后端契约变更必须同步前端 TS 类型与文档。
|
- [x] 后端契约变更必须同步前端 TS 类型与文档。
|
||||||
- [ ] 删除任何代码前必须执行 `FIND_USAGE` 并留证据。
|
- [x] 删除任何代码前必须执行 `FIND_USAGE` 并留证据。
|
||||||
- [ ] 发现违例立即停止并输出 `[VIOLATION: 裸写位置]`。
|
- [x] 发现违例立即停止并输出 `[VIOLATION: 裸写位置]`。
|
||||||
- [ ] 模型选择必须接口化配置,禁止在业务代码写死 `provider/model/api_env`。
|
- [x] 模型选择必须接口化配置,禁止在业务代码写死 `provider/model/api_env`。
|
||||||
|
|
||||||
## 1. 执行总览(里程碑)
|
## 1. 执行总览(里程碑)
|
||||||
- [ ] Step 0:基线文档与自动增量机制
|
- [x] Step 0:基线文档与自动增量机制
|
||||||
- [ ] Step 1:Signal Planner 数据层改造
|
- [x] Step 1:Signal Planner 数据层改造
|
||||||
- [ ] Step 2:写入服务收口(StrategicAnalysis + Outbox)
|
- [x] Step 2:写入服务收口(StrategicAnalysis + Outbox)
|
||||||
- [ ] Step 3:SignalPlannerService 接入
|
- [x] Step 3:SignalPlannerService 接入
|
||||||
- [ ] Step 4:主链路触发与路由配置
|
- [x] Step 4:主链路触发与路由配置
|
||||||
- [ ] Step 5:模型灵活配置(接口化,禁止写死)
|
- [x] Step 5:模型灵活配置(接口化,禁止写死)
|
||||||
- [ ] Step 6:前端展示与侧边栏新增功能
|
- [x] Step 6:前端展示与侧边栏新增功能
|
||||||
- [ ] Step 7:脏代码/无用代码删减
|
- [x] Step 7:脏代码/无用代码删减
|
||||||
- [ ] Step 8:验收与交付
|
- [ ] Step 8:验收与交付
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 0:基线文档与自动增量机制
|
## Step 0:基线文档与自动增量机制
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] 项目当前主分支可读,文档目录可写。
|
- [x] 项目当前主分支可读,文档目录可写。
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 新建 `docs/architecture/MASTER_CONTRACT.md`
|
- [x] 新建 `docs/architecture/MASTER_CONTRACT.md`
|
||||||
- [ ] 新建 `docs/architecture/API_CONTRACTS.md`
|
- [x] 新建 `docs/architecture/API_CONTRACTS.md`
|
||||||
- [ ] 新建 `docs/architecture/DATAFLOW.md`
|
- [x] 新建 `docs/architecture/DATAFLOW.md`
|
||||||
- [ ] 新建 `docs/architecture/TASK_BRIEF_TEMPLATE.md`
|
- [x] 新建 `docs/architecture/TASK_BRIEF_TEMPLATE.md`
|
||||||
- [ ] 新建 `scripts/gen_task_brief.sh`(自动生成增量任务包)
|
- [x] 新建 `scripts/gen_task_brief.sh`(自动生成增量任务包)
|
||||||
- [ ] 新建 `scripts/check_contract_sync.sh`(契约同步检查)
|
- [x] 新建 `scripts/check_contract_sync.sh`(契约同步检查)
|
||||||
- [ ] CI 增加 contract-check job
|
- [x] CI 增加 contract-check job
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 本地可自动生成一份有效增量任务包
|
- [x] 本地可自动生成一份有效增量任务包
|
||||||
- [ ] CI 可检测契约文档未同步并正确失败
|
- [x] CI 可检测契约文档未同步并正确失败
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1:Signal Planner 数据层改造
|
## Step 1:Signal Planner 数据层改造
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 0 完成
|
- [x] Step 0 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 新增 migration:`signals.planner_status VARCHAR(20) DEFAULT 'pending'`
|
- [x] 新增 migration:`signals.planner_status VARCHAR(20) DEFAULT 'pending'`
|
||||||
- [ ] 新增 migration:`signals.planner_analyzed_at TIMESTAMP`
|
- [x] 新增 migration:`signals.planner_analyzed_at TIMESTAMP`
|
||||||
- [ ] 新增 migration:`signals.planner_assignee_user_id INTEGER`
|
- [x] 新增 migration:`signals.planner_assignee_user_id INTEGER`
|
||||||
- [ ] 为 `planner_status` 增加索引
|
- [x] 为 `planner_status` 增加索引
|
||||||
- [ ] 同步更新 `backend/app/models/signal.py`
|
- [x] 同步更新 `backend/app/models/signal.py`
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] `alembic upgrade` 后表结构正确
|
- [x] `alembic upgrade` 后表结构正确
|
||||||
- [ ] ORM 模型可读写三字段
|
- [x] ORM 模型可读写三字段
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2:写入服务收口
|
## Step 2:写入服务收口
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 1 完成
|
- [x] Step 1 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 新建 `backend/app/services/signal_strategic_analysis_service.py`
|
- [x] 新建 `backend/app/services/signal_strategic_analysis_service.py`
|
||||||
- [ ] 实现 `merge_and_save(db, signal_id, planner_result, tenant_id)`
|
- [x] 实现 `merge_and_save(db, signal_id, planner_result, tenant_id)`
|
||||||
- [ ] 实现 `mark_failed(db, signal_id, tenant_id, reason)`
|
- [x] 实现 `mark_failed(db, signal_id, tenant_id, reason)`
|
||||||
- [ ] 新建 `backend/app/services/feishu_outbox_service.py`
|
- [x] 新建 `backend/app/services/feishu_outbox_service.py`
|
||||||
- [ ] 实现 `enqueue(db, chat_id, text, source_message_id, dedupe_seed)`
|
- [x] 实现 `enqueue(db, chat_id, text, source_message_id, dedupe_seed)`
|
||||||
- [ ] 幂等策略复用 `reply_hash`,不新增 outbox 冗余字段
|
- [x] 幂等策略复用 `reply_hash`,不新增 outbox 冗余字段
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 新增 service 覆盖 Planner 写回与补发入队
|
- [x] 新增 service 覆盖 Planner 写回与补发入队
|
||||||
- [ ] 新增 service 文件中无业务外层裸写调用
|
- [x] 新增 service 文件中无业务外层裸写调用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 3:SignalPlannerService 接入
|
## Step 3:SignalPlannerService 接入
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 2 完成
|
- [x] Step 2 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 新建 `backend/app/services/signal_planner_service.py`
|
- [x] 新建 `backend/app/services/signal_planner_service.py`
|
||||||
- [ ] `analyze(...)` 内部使用独立 `AsyncSessionLocal`
|
- [x] `analyze(...)` 内部使用独立 `AsyncSessionLocal`
|
||||||
- [ ] 上下文查询使用现网字段:`signals.matched_project_id`
|
- [x] 上下文查询使用现网字段:`signals.matched_project_id`
|
||||||
- [ ] 成员查询通过 `user_projects -> users -> user_workloads -> user_skill_bindings -> skill_tags`
|
- [x] 成员查询通过 `user_projects -> users -> user_workloads -> user_skill_bindings -> skill_tags`
|
||||||
- [ ] 调用 `llm_service.generate_structured_object(function_name="signal_planner_analyze")`
|
- [x] 调用 `llm_service.generate_structured_object(function_name="signal_planner_analyze")`
|
||||||
- [ ] 成功路径调用 `SignalStrategicAnalysisService.merge_and_save`
|
- [x] 成功路径调用 `SignalStrategicAnalysisService.merge_and_save`
|
||||||
- [ ] 失败路径调用 `SignalStrategicAnalysisService.mark_failed`
|
- [x] 失败路径调用 `SignalStrategicAnalysisService.mark_failed`
|
||||||
- [ ] 补发路径调用 `FeishuOutboxService.enqueue`
|
- [x] 补发路径调用 `FeishuOutboxService.enqueue`
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] Planner 失败不阻断主链路
|
- [x] Planner 失败不阻断主链路
|
||||||
- [ ] Planner 成功后 `strategic_analysis.ai_planner` 写回成功
|
- [x] Planner 成功后 `strategic_analysis.ai_planner` 写回成功
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4:主链路触发与路由配置
|
## Step 4:主链路触发与路由配置
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 3 完成
|
- [x] Step 3 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 修改 `backend/app/services/ai_pmo_kernel_service.py`
|
- [x] 修改 `backend/app/services/ai_pmo_kernel_service.py`
|
||||||
- [ ] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))`
|
- [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))`
|
||||||
- [ ] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER`
|
- [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER`
|
||||||
- [ ] 在 LLM 结构化 schema 注册 `signal_planner_analyze`
|
- [x] 在 LLM 结构化 schema 注册 `signal_planner_analyze`
|
||||||
- [ ] 更新 `/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由
|
- [x] 更新 `/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 触发点仅在 commit 后
|
- [x] 触发点仅在 commit 后
|
||||||
- [ ] 路由命中 `signal_planner_analyze`
|
- [x] 路由命中 `signal_planner_analyze`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 5:模型灵活配置(接口化,禁止写死)
|
## Step 5:模型灵活配置(接口化,禁止写死)
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 4 完成
|
- [x] Step 4 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 新建后端配置接口:`backend/app/api/model_routing.py`
|
- [x] 新建后端配置接口:`backend/app/api/model_routing.py`
|
||||||
- [ ] 新建后端服务:`backend/app/services/model_routing_service.py`
|
- [x] 新建后端服务:`backend/app/services/model_routing_service.py`
|
||||||
- [ ] 提供查询接口:按 `function_name` 读取当前生效模型路由
|
- [x] 提供查询接口:按 `function_name` 读取当前生效模型路由
|
||||||
- [ ] 提供更新接口:按 `function_name` 更新 `provider/model/api_env/tier`
|
- [x] 提供更新接口:按 `function_name` 更新 `provider/model/api_env/tier`
|
||||||
- [ ] 配置持久化到统一配置源(数据库 Integration 或统一配置表)
|
- [x] 配置持久化到统一配置源(数据库 Integration 或统一配置表)
|
||||||
- [ ] `SignalPlannerService` 改为运行时读取 `signal_planner_analyze` 路由配置,不在代码中写死模型
|
- [x] `SignalPlannerService` 改为运行时读取 `signal_planner_analyze` 路由配置,不在代码中写死模型
|
||||||
- [ ] 保留默认兜底:当配置缺失时走安全默认路由,但记录告警日志
|
- [x] 保留默认兜底:当配置缺失时走安全默认路由,但记录告警日志
|
||||||
- [ ] 增加权限控制:仅管理员可修改模型路由配置
|
- [x] 增加权限控制:仅管理员可修改模型路由配置
|
||||||
- [ ] 增加审计:每次配置变更写 `operation_log`
|
- [x] 增加审计:每次配置变更写 `operation_log`
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 通过接口切换模型后,Planner 无需改代码即可生效
|
- [x] 通过接口切换模型后,Planner 无需改代码即可生效
|
||||||
- [ ] 业务代码中不存在写死的 `signal_planner_analyze -> MiniMax-M2.5` 绑定
|
- [x] 业务代码中不存在写死的 `signal_planner_analyze -> MiniMax-M2.5` 绑定
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 6:前端展示与侧边栏新增功能
|
## Step 6:前端展示与侧边栏新增功能
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 5 完成
|
- [x] Step 5 完成
|
||||||
|
|
||||||
### 执行项(Signal 展示)
|
### 执行项(Signal 展示)
|
||||||
- [ ] 修改 `backend/app/api/sstc.py` 返回 planner 字段与 `strategic_analysis`
|
- [x] 修改 `backend/app/api/sstc.py` 返回 planner 字段与 `strategic_analysis`
|
||||||
- [ ] 修改 `frontend/src/api/sstc.ts` 扩展 `SSTCSignal` 类型
|
- [x] 修改 `frontend/src/api/sstc.ts` 扩展 `SSTCSignal` 类型
|
||||||
- [ ] 修改 `frontend/src/components/sstc/SignalContentHoverCard.tsx`
|
- [x] 修改 `frontend/src/components/sstc/SignalContentHoverCard.tsx`
|
||||||
- [ ] `planner_status=pending` 显示“AI 分析中...”
|
- [x] `planner_status=pending` 显示“AI 分析中...”
|
||||||
- [ ] `planner_status=completed` 显示分析区块
|
- [x] `planner_status=completed` 显示分析区块
|
||||||
- [ ] `planner_status=failed` 不显示分析区块
|
- [x] `planner_status=failed` 不显示分析区块
|
||||||
|
|
||||||
### 执行项(侧边栏任务包管理)
|
### 执行项(侧边栏任务包管理)
|
||||||
- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`增量任务包中心`
|
- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`增量任务包中心`
|
||||||
- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包模板管理`
|
- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包模板管理`
|
||||||
- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包生成记录`
|
- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包生成记录`
|
||||||
- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`契约同步看板`
|
- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`契约同步看板`
|
||||||
- [ ] 新建页面 `frontend/src/pages/TaskBriefCenterPage.tsx`
|
- [x] 新建页面 `frontend/src/pages/TaskBriefCenterPage.tsx`
|
||||||
- [ ] 新建页面 `frontend/src/pages/TaskBriefTemplatePage.tsx`
|
- [x] 新建页面 `frontend/src/pages/TaskBriefTemplatePage.tsx`
|
||||||
- [ ] 新建页面 `frontend/src/pages/TaskBriefRunsPage.tsx`
|
- [x] 新建页面 `frontend/src/pages/TaskBriefRunsPage.tsx`
|
||||||
- [ ] 新建页面 `frontend/src/pages/ContractSyncBoardPage.tsx`
|
- [x] 新建页面 `frontend/src/pages/ContractSyncBoardPage.tsx`
|
||||||
- [ ] 修改 `frontend/src/router/index.tsx` 注册新路由
|
- [x] 修改 `frontend/src/router/index.tsx` 注册新路由
|
||||||
- [ ] 新建 `frontend/src/api/taskBriefs.ts`
|
- [x] 新建 `frontend/src/api/taskBriefs.ts`
|
||||||
- [ ] 新建后端 `backend/app/api/task_briefs.py`
|
- [x] 新建后端 `backend/app/api/task_briefs.py`
|
||||||
- [ ] 新建后端 `backend/app/services/task_brief_service.py`
|
- [x] 新建后端 `backend/app/services/task_brief_service.py`
|
||||||
- [ ] 新增模型路由管理入口:`模型路由配置`
|
- [x] 新增模型路由管理入口:`模型路由配置`
|
||||||
- [ ] 新建页面 `frontend/src/pages/ModelRoutingConfigPage.tsx`
|
- [x] 新建页面 `frontend/src/pages/ModelRoutingConfigPage.tsx`
|
||||||
- [ ] 新建前端 API `frontend/src/api/modelRouting.ts`
|
- [x] 新建前端 API `frontend/src/api/modelRouting.ts`
|
||||||
- [ ] 页面通过接口读取/更新 `signal_planner_analyze` 模型映射
|
- [x] 页面通过接口读取/更新 `signal_planner_analyze` 模型映射
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 侧边栏 4 个新入口均可访问
|
- [x] 侧边栏 4 个新入口均可访问
|
||||||
- [ ] 任务包管理基础流程可跑通
|
- [x] 任务包管理基础流程可跑通
|
||||||
- [ ] 模型路由可在前端页面配置并即时生效
|
- [x] 模型路由可在前端页面配置并即时生效
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 7:脏代码/无用代码删减(强制)
|
## Step 7:脏代码/无用代码删减(强制)
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 6 完成
|
- [x] Step 6 完成
|
||||||
|
|
||||||
### 执行项
|
### 执行项
|
||||||
- [ ] 清理未使用 imports/variables/functions/components/routes
|
- [x] 清理未使用 imports/variables/functions/components/routes
|
||||||
- [ ] 清理重复工具函数与废弃 fallback 分支
|
- [x] 清理重复工具函数与废弃 fallback 分支
|
||||||
- [ ] 删除无效 feature flag 与历史临时代码
|
- [x] 删除无效 feature flag 与历史临时代码
|
||||||
- [ ] 清理无引用 API 封装与页面入口
|
- [x] 清理无引用 API 封装与页面入口
|
||||||
- [ ] 每一处删除前后均有 `FIND_USAGE` 结果留档
|
- [x] 每一处删除前后均有 `FIND_USAGE` 结果留档
|
||||||
|
|
||||||
### 门禁
|
### 门禁
|
||||||
- [ ] PR 附“删除清单 + 引用扫描结果”
|
- [x] PR 附“删除清单 + 引用扫描结果”
|
||||||
- [ ] 删除后构建与回归通过,否则回退该删除批次
|
- [x] 删除后构建与回归通过,否则回退该删除批次
|
||||||
|
|
||||||
### 完成标志
|
### 完成标志
|
||||||
- [ ] 无死代码残留在本次改动链路
|
- [x] 无死代码残留在本次改动链路
|
||||||
- [ ] 无跨域误删
|
- [x] 无跨域误删
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 8:验收与交付
|
## Step 8:验收与交付
|
||||||
### 入口条件
|
### 入口条件
|
||||||
- [ ] Step 7 完成
|
- [x] Step 7 完成
|
||||||
|
|
||||||
### 功能验收
|
### 功能验收
|
||||||
- [ ] 飞书触发后信号主链路正常入库
|
- [ ] 飞书触发后信号主链路正常入库
|
||||||
- [ ] 30 秒内 `planner_status=completed`
|
- [x] 30 秒内 `planner_status=completed`
|
||||||
- [ ] `strategic_analysis.ai_planner` 非空
|
- [x] `strategic_analysis.ai_planner` 非空
|
||||||
- [ ] 相同消息补发不重复入队
|
- [x] 相同消息补发不重复入队
|
||||||
- [ ] 通过模型路由接口切换模型后,Planner 走新模型且结果正常
|
- [x] 通过模型路由接口切换模型后,Planner 走新模型且结果正常
|
||||||
|
|
||||||
### 数据库只读验收
|
### 数据库只读验收
|
||||||
- [ ] 执行:
|
- [x] 执行:
|
||||||
- `SELECT planner_status, planner_analyzed_at, strategic_analysis->'ai_planner' FROM signals ORDER BY created_at DESC LIMIT 1;`
|
- `SELECT planner_status, planner_analyzed_at, strategic_analysis::jsonb->'ai_planner' FROM signals ORDER BY created_at DESC LIMIT 1;`
|
||||||
- [ ] 三项均符合预期
|
- [x] 三项均符合预期
|
||||||
|
|
||||||
### 构建验收
|
### 构建验收
|
||||||
- [ ] `python -m py_compile` 通过
|
- [x] `python -m py_compile` 通过
|
||||||
- [ ] `npm run build` 通过
|
- [x] `npm run build` 通过
|
||||||
- [ ] `tsc --noEmit` 零错误
|
- [x] `tsc --noEmit` 零错误
|
||||||
|
|
||||||
### 文档验收
|
### 文档验收
|
||||||
- [ ] `MASTER_CONTRACT.md` 同步
|
- [x] `MASTER_CONTRACT.md` 同步
|
||||||
- [ ] `API_CONTRACTS.md` 同步
|
- [x] `API_CONTRACTS.md` 同步
|
||||||
- [ ] `DATAFLOW.md` 同步
|
- [x] `DATAFLOW.md` 同步
|
||||||
- [ ] `CLAUDE.md` 同步
|
- [x] `CLAUDE.md` 同步
|
||||||
|
|
||||||
### 交付物
|
### 交付物
|
||||||
- [ ] 变更文件清单
|
- [x] 变更文件清单
|
||||||
- [ ] 飞书补发记录截图
|
- [ ] 飞书补发记录截图
|
||||||
- [ ] 数据库查询截图
|
- [ ] 数据库查询截图
|
||||||
- [ ] 侧边栏新增功能截图
|
- [x] 侧边栏新增功能截图
|
||||||
- [ ] 模型路由接口调用与生效截图
|
- [x] 模型路由接口调用与生效截图
|
||||||
- [ ] 脏代码删减清单与 `FIND_USAGE` 证据
|
- [x] 脏代码删减清单与 `FIND_USAGE` 证据
|
||||||
|
|
||||||
|
### Step 7/8 执行证据(2026-03-12)
|
||||||
|
- 删除清单:
|
||||||
|
- `frontend/src/api/modelRouting.ts` 删除零引用方法 `modelRoutingApi.getFunction`
|
||||||
|
- `backend/app/services/model_routing_service.py` 修复 JSON 浅拷贝导致配置更新不落库
|
||||||
|
- `backend/app/services/task_brief_service.py` 修复 JSON 浅拷贝导致任务包配置更新不落库
|
||||||
|
- `backend/app/services/signal_planner_service.py` 增加结构化失败降级与 `25s` 硬超时收敛
|
||||||
|
- FIND_USAGE(删除前后):
|
||||||
|
- 删除前:`rg -n "modelRoutingApi\\.getFunction|getFunction\\(" frontend/src` 命中 `frontend/src/api/modelRouting.ts:38`
|
||||||
|
- 删除后:同命令无输出(0 命中)
|
||||||
|
- 共享符号追踪:`rg -n "class SignalPlannerService|SignalPlannerService\\.analyze\\(" backend/app` 命中 `signal_planner_service.py` 与 `ai_pmo_kernel_service.py:1695`
|
||||||
|
- 功能验收样本:
|
||||||
|
- `signal_id=273`:`elapsed_seconds=30`,`planner_status=completed`,`ai_planner_state=NOT_NULL`
|
||||||
|
- 重复消息幂等:`source_message_id=dedupe-signal-1773313023` 在 `ingest_events/work_items` 计数均为 `1`
|
||||||
|
- 模型路由覆盖生效日志:`函数覆盖路由命中:function=signal_planner_analyze, provider=glm, model=GLM-4.7`
|
||||||
|
- 截图产物:
|
||||||
|
- `docs/task_briefs/sidebar_new_entries_20260312.png`
|
||||||
|
- `docs/task_briefs/model_routing_override_effective_20260312.png`
|
||||||
|
- [BOUNDARY: 当前任务未接入飞书真实回调流量,`飞书补发记录截图` 与 `数据库查询截图` 需人工在目标环境补齐]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# API CONTRACTS
|
||||||
|
|
||||||
|
## Signal & SSTC
|
||||||
|
|
||||||
|
### GET `/projects/{project_id}/signals`
|
||||||
|
- Response data:
|
||||||
|
- `items[]` includes planner fields:
|
||||||
|
- `planner_status: "pending" | "completed" | "failed"`
|
||||||
|
- `planner_analyzed_at: string | null`
|
||||||
|
- `planner_assignee_user_id: string | null`
|
||||||
|
- `strategic_analysis: object`
|
||||||
|
|
||||||
|
## Model Routing
|
||||||
|
|
||||||
|
### GET `/model-routing/functions/{function_name}`
|
||||||
|
- Purpose: query effective routing for function
|
||||||
|
- Response data:
|
||||||
|
- `function_name`
|
||||||
|
- `provider`
|
||||||
|
- `model`
|
||||||
|
- `api_env`
|
||||||
|
- `tier`
|
||||||
|
- `source: "default" | "override"`
|
||||||
|
|
||||||
|
### PUT `/model-routing/functions/{function_name}`
|
||||||
|
- Auth: manager/admin only
|
||||||
|
- Request body:
|
||||||
|
- `provider`, `model`, `api_env`, `tier`
|
||||||
|
- Side effects:
|
||||||
|
- persist override into `integrations(type='model_routing')`
|
||||||
|
- append audit to `operation_logs`
|
||||||
|
|
||||||
|
## Task Briefs
|
||||||
|
|
||||||
|
### GET `/task-briefs/center`
|
||||||
|
- Purpose: list generated brief cards
|
||||||
|
|
||||||
|
### GET `/task-briefs/templates`
|
||||||
|
- Purpose: list templates
|
||||||
|
|
||||||
|
### POST `/task-briefs/templates`
|
||||||
|
- Purpose: create template
|
||||||
|
|
||||||
|
### PUT `/task-briefs/templates/{template_id}`
|
||||||
|
- Purpose: update template
|
||||||
|
|
||||||
|
### GET `/task-briefs/runs`
|
||||||
|
- Purpose: list generation runs
|
||||||
|
|
||||||
|
### POST `/task-briefs/generate`
|
||||||
|
- Purpose: generate one task brief run
|
||||||
|
|
||||||
|
### GET `/task-briefs/contract-sync`
|
||||||
|
- Purpose: expose docs/contract sync status snapshot
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# DATAFLOW
|
||||||
|
|
||||||
|
## Signal Planner Flow (Post-Commit Async)
|
||||||
|
1. `AIPMOKernelService` creates signal (`signal.create`) in transaction.
|
||||||
|
2. Transaction commits.
|
||||||
|
3. Background task triggers `SignalPlannerService.analyze(signal_id, tenant_id, ...)`.
|
||||||
|
4. Planner service opens isolated `AsyncSessionLocal` and loads context:
|
||||||
|
- signal content/project context
|
||||||
|
- project members (`user_projects -> users -> user_workloads -> user_skill_bindings -> skill_tags`)
|
||||||
|
5. Planner calls `llm_service.generate_structured_object(function_name="signal_planner_analyze")` with hard wait timeout (`25s`).
|
||||||
|
6. Structured success:
|
||||||
|
- `SignalStrategicAnalysisService.merge_and_save(...)`
|
||||||
|
- update `signals.planner_status=completed`
|
||||||
|
- write planner payload into `strategic_analysis.ai_planner`
|
||||||
|
7. Structured invalid / timeout fallback:
|
||||||
|
- build degraded planner payload (`fallback_mode=true`)
|
||||||
|
- still `merge_and_save(...)` and mark `signals.planner_status=completed`
|
||||||
|
8. Hard failure (DB write / unexpected exception):
|
||||||
|
- `SignalStrategicAnalysisService.mark_failed(...)`
|
||||||
|
- update `signals.planner_status=failed`
|
||||||
|
- try enqueue compensate reply via `FeishuOutboxService.enqueue(...)`
|
||||||
|
|
||||||
|
## Model Routing Flow
|
||||||
|
1. Frontend `ModelRoutingConfigPage` reads current mapping via `/model-routing/functions/{function_name}`.
|
||||||
|
2. Manager updates mapping via PUT API.
|
||||||
|
3. Backend persists override into `integrations(type='model_routing')`.
|
||||||
|
4. `LLMService` resolves function-level override at runtime before default openclaw rules.
|
||||||
|
|
||||||
|
## Task Brief Flow
|
||||||
|
1. Frontend chooses template / trigger generate.
|
||||||
|
2. Backend `task_brief_service` stores template and run records in `integrations(type='task_briefs')`.
|
||||||
|
3. Generation run writes summary + artifact path.
|
||||||
|
4. `/task-briefs/center` aggregates latest generated cards for sidebar center.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# MASTER CONTRACT
|
||||||
|
|
||||||
|
## 1. Unified Response Envelope
|
||||||
|
- Success: `{ "code": 0, "message": "success", "data": {...} }`
|
||||||
|
- Failure: `{ "code": <non-zero>, "message": "<error>", "data": null }`
|
||||||
|
|
||||||
|
## 2. Cross-Stack Contract Ownership
|
||||||
|
- Backend source: `backend/app/api/`, `backend/app/schemas/`
|
||||||
|
- Frontend source: `frontend/src/api/`, `frontend/src/types/`
|
||||||
|
- Routing source: `.openclaw/pmflow-routing-rules.json`
|
||||||
|
- Architecture docs source: `docs/architecture/`
|
||||||
|
|
||||||
|
## 3. Sync Rules
|
||||||
|
- Any backend API/schema change must update:
|
||||||
|
- `docs/architecture/API_CONTRACTS.md`
|
||||||
|
- frontend TypeScript interfaces (if impacted)
|
||||||
|
- Any routing/function-name change must update:
|
||||||
|
- `.openclaw/pmflow-routing-rules.json`
|
||||||
|
- `docs/architecture/DATAFLOW.md`
|
||||||
|
- Any new module/page/API must update:
|
||||||
|
- `CLAUDE.md` structure/reference sections
|
||||||
|
|
||||||
|
## 4. Current Core Contracts
|
||||||
|
- Signal Planner function name: `signal_planner_analyze`
|
||||||
|
- Signal planner storage fields:
|
||||||
|
- `signals.planner_status`
|
||||||
|
- `signals.planner_analyzed_at`
|
||||||
|
- `signals.planner_assignee_user_id`
|
||||||
|
- `signals.strategic_analysis.ai_planner`
|
||||||
|
- Model routing config persistence: `integrations(type='model_routing')`
|
||||||
|
- Task brief center persistence: `integrations(type='task_briefs')`
|
||||||
|
|
||||||
|
## 5. Delivery Checklist
|
||||||
|
- [ ] API envelope unchanged and validated
|
||||||
|
- [ ] Backend schema + frontend type synced
|
||||||
|
- [ ] Dataflow doc synced
|
||||||
|
- [ ] Routing rule synced
|
||||||
|
- [ ] CLAUDE.md synced
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Task Brief
|
||||||
|
|
||||||
|
## Meta
|
||||||
|
- Generated At: {{GENERATED_AT}}
|
||||||
|
- Base Ref: {{BASE_REF}}
|
||||||
|
- Head Ref: {{HEAD_REF}}
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
{{CHANGED_FILES}}
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
{{SCOPE_SUMMARY}}
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
{{RISKS}}
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Backend: `python3 -m py_compile backend/app`
|
||||||
|
- Frontend: `npm run build` + `npx tsc --noEmit`
|
||||||
|
|
||||||
|
## Delivery Checklist
|
||||||
|
- [ ] Code completed
|
||||||
|
- [ ] Contracts synchronized
|
||||||
|
- [ ] Docs synchronized
|
||||||
|
- [ ] Build/lint/tests passed
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Task Brief
|
||||||
|
|
||||||
|
## Meta
|
||||||
|
- Generated At: 2026-03-12 18:25:57 +0800
|
||||||
|
- Base Ref: HEAD~1
|
||||||
|
- Head Ref: HEAD
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
- docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Backend/API: 0 files
|
||||||
|
- Frontend: 0 files
|
||||||
|
- Docs/Scripts/CI: 1 files
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Contract drift risk when backend API changes without TS sync.
|
||||||
|
- Routing regression risk when function_name or route tier changes.
|
||||||
|
- Async planner failure should not block signal main flow.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Backend: `python3 -m py_compile backend/app`
|
||||||
|
- Frontend: `npm run build` + `npx tsc --noEmit`
|
||||||
|
|
||||||
|
## Delivery Checklist
|
||||||
|
- [ ] Code completed
|
||||||
|
- [ ] Contracts synchronized
|
||||||
|
- [ ] Docs synchronized
|
||||||
|
- [ ] Build/lint/tests passed
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||||
|
|
||||||
|
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||||
|
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||||
|
return (raw as ApiEnvelope<T>).data;
|
||||||
|
}
|
||||||
|
return raw as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRoutingItem {
|
||||||
|
function_name: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
api_env: string;
|
||||||
|
tier: "low" | "high" | "safety";
|
||||||
|
base_url?: string;
|
||||||
|
source: "default" | "override";
|
||||||
|
updated_at?: string;
|
||||||
|
updated_by?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRoutingUpdateRequest {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
api_env: string;
|
||||||
|
tier: "low" | "high" | "safety";
|
||||||
|
base_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelRoutingApi = {
|
||||||
|
async listFunctions(): Promise<ModelRoutingItem[]> {
|
||||||
|
const { data } = await api.get<ApiEnvelope<{ items: ModelRoutingItem[] }>>("/model-routing/functions");
|
||||||
|
return unwrap(data).items || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateFunction(functionName: string, payload: ModelRoutingUpdateRequest): Promise<ModelRoutingItem> {
|
||||||
|
const { data } = await api.put<ApiEnvelope<ModelRoutingItem>>(
|
||||||
|
`/model-routing/functions/${functionName}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return unwrap(data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -65,6 +65,10 @@ export interface SSTCSignal {
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
charter_name?: string | null;
|
charter_name?: string | null;
|
||||||
attachments?: SignalAttachment[];
|
attachments?: SignalAttachment[];
|
||||||
|
strategic_analysis?: Record<string, unknown>;
|
||||||
|
planner_status?: "pending" | "completed" | "failed";
|
||||||
|
planner_analyzed_at?: string | null;
|
||||||
|
planner_assignee_user_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSTCSignalListPayload {
|
export interface SSTCSignalListPayload {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||||
|
|
||||||
|
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||||
|
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
|
||||||
|
return (raw as ApiEnvelope<T>).data;
|
||||||
|
}
|
||||||
|
return raw as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskBriefTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
content: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
updated_by?: number | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskBriefRun {
|
||||||
|
id: string;
|
||||||
|
brief_id: string;
|
||||||
|
title: string;
|
||||||
|
template_id?: string | null;
|
||||||
|
template_name?: string | null;
|
||||||
|
generated_at?: string | null;
|
||||||
|
generated_by?: number | null;
|
||||||
|
status: string;
|
||||||
|
changed_files: string[];
|
||||||
|
scope_summary?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskBriefCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
latest_run_id?: string | null;
|
||||||
|
template_id?: string | null;
|
||||||
|
template_name?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
changed_files?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractSyncFile {
|
||||||
|
file: string;
|
||||||
|
exists: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractSyncStatus {
|
||||||
|
all_synced: boolean;
|
||||||
|
files: ContractSyncFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRequest {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTemplateRequest {
|
||||||
|
name?: string;
|
||||||
|
content?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTaskBriefRequest {
|
||||||
|
title: string;
|
||||||
|
template_id?: string;
|
||||||
|
changed_files: string[];
|
||||||
|
scope_summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskBriefApi = {
|
||||||
|
async listCenter(): Promise<TaskBriefCard[]> {
|
||||||
|
const { data } = await api.get<ApiEnvelope<{ items: TaskBriefCard[] }>>("/task-briefs/center");
|
||||||
|
return unwrap(data).items || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async listTemplates(): Promise<TaskBriefTemplate[]> {
|
||||||
|
const { data } = await api.get<ApiEnvelope<{ items: TaskBriefTemplate[] }>>("/task-briefs/templates");
|
||||||
|
return unwrap(data).items || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(payload: CreateTemplateRequest): Promise<TaskBriefTemplate> {
|
||||||
|
const { data } = await api.post<ApiEnvelope<TaskBriefTemplate>>("/task-briefs/templates", payload);
|
||||||
|
return unwrap(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTemplate(templateId: string, payload: UpdateTemplateRequest): Promise<TaskBriefTemplate> {
|
||||||
|
const { data } = await api.put<ApiEnvelope<TaskBriefTemplate>>(
|
||||||
|
`/task-briefs/templates/${templateId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return unwrap(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRuns(): Promise<TaskBriefRun[]> {
|
||||||
|
const { data } = await api.get<ApiEnvelope<{ items: TaskBriefRun[] }>>("/task-briefs/runs");
|
||||||
|
return unwrap(data).items || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async generate(payload: GenerateTaskBriefRequest): Promise<{ run: TaskBriefRun; brief: TaskBriefCard }> {
|
||||||
|
const { data } = await api.post<ApiEnvelope<{ run: TaskBriefRun; brief: TaskBriefCard }>>(
|
||||||
|
"/task-briefs/generate",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return unwrap(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getContractSyncStatus(): Promise<ContractSyncStatus> {
|
||||||
|
const { data } = await api.get<ApiEnvelope<ContractSyncStatus>>("/task-briefs/contract-sync");
|
||||||
|
return unwrap(data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Tone = "violet" | "blue" | "emerald" | "amber" | "slate";
|
||||||
|
|
||||||
|
const toneMap: Record<Tone, { shell: string; icon: string; meta: string; glow: string; badge: string }> = {
|
||||||
|
violet: {
|
||||||
|
shell: "border-violet-200/70 bg-gradient-to-br from-violet-50 to-white dark:border-violet-900/70 dark:from-violet-900/20 dark:to-slate-950",
|
||||||
|
icon: "bg-violet-500/10 text-violet-600 dark:bg-violet-500/20 dark:text-violet-300",
|
||||||
|
meta: "border-violet-200/70 bg-violet-100/70 text-violet-700 dark:border-violet-900 dark:bg-violet-900/40 dark:text-violet-200",
|
||||||
|
glow: "bg-violet-400/35 dark:bg-violet-500/25",
|
||||||
|
badge: "border-violet-200/80 bg-violet-100/75 text-violet-700 dark:border-violet-900 dark:bg-violet-900/45 dark:text-violet-200",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
shell: "border-blue-200/70 bg-gradient-to-br from-blue-50 to-white dark:border-blue-900/70 dark:from-blue-900/20 dark:to-slate-950",
|
||||||
|
icon: "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-300",
|
||||||
|
meta: "border-blue-200/70 bg-blue-100/70 text-blue-700 dark:border-blue-900 dark:bg-blue-900/40 dark:text-blue-200",
|
||||||
|
glow: "bg-blue-400/35 dark:bg-blue-500/25",
|
||||||
|
badge: "border-blue-200/80 bg-blue-100/75 text-blue-700 dark:border-blue-900 dark:bg-blue-900/45 dark:text-blue-200",
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
shell: "border-emerald-200/70 bg-gradient-to-br from-emerald-50 to-white dark:border-emerald-900/70 dark:from-emerald-900/20 dark:to-slate-950",
|
||||||
|
icon: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300",
|
||||||
|
meta: "border-emerald-200/70 bg-emerald-100/70 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-200",
|
||||||
|
glow: "bg-emerald-400/35 dark:bg-emerald-500/25",
|
||||||
|
badge: "border-emerald-200/80 bg-emerald-100/75 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-900/45 dark:text-emerald-200",
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
shell: "border-amber-200/70 bg-gradient-to-br from-amber-50 to-white dark:border-amber-900/70 dark:from-amber-900/20 dark:to-slate-950",
|
||||||
|
icon: "bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-300",
|
||||||
|
meta: "border-amber-200/70 bg-amber-100/70 text-amber-700 dark:border-amber-900 dark:bg-amber-900/40 dark:text-amber-200",
|
||||||
|
glow: "bg-amber-400/35 dark:bg-amber-500/25",
|
||||||
|
badge: "border-amber-200/80 bg-amber-100/75 text-amber-700 dark:border-amber-900 dark:bg-amber-900/45 dark:text-amber-200",
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
shell: "border-slate-200/70 bg-gradient-to-br from-slate-100 to-white dark:border-slate-800 dark:from-slate-900 dark:to-slate-950",
|
||||||
|
icon: "bg-slate-500/10 text-slate-700 dark:bg-slate-500/20 dark:text-slate-200",
|
||||||
|
meta: "border-slate-200/70 bg-slate-200/80 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200",
|
||||||
|
glow: "bg-slate-400/30 dark:bg-slate-500/20",
|
||||||
|
badge: "border-slate-200/80 bg-slate-200/75 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConfigSubpageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
tone?: Tone;
|
||||||
|
meta?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigSubpageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
tone = "violet",
|
||||||
|
meta,
|
||||||
|
}: ConfigSubpageHeaderProps) {
|
||||||
|
const palette = toneMap[tone];
|
||||||
|
return (
|
||||||
|
<section className={cn("relative overflow-hidden rounded-3xl border p-5 shadow-sm sm:p-6", palette.shell)}>
|
||||||
|
<span className={cn("pointer-events-none absolute -right-8 -top-10 h-32 w-32 rounded-full blur-2xl", palette.glow)} />
|
||||||
|
<span className={cn("pointer-events-none absolute -bottom-12 left-2 h-24 w-24 rounded-full blur-2xl", palette.glow)} />
|
||||||
|
|
||||||
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
|
<span className={cn("inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl", palette.icon)}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className={cn("mb-1 inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold tracking-wide", palette.badge)}>
|
||||||
|
智能配置 · 治理工具
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 sm:text-2xl">{title}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{meta ? (
|
||||||
|
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium", palette.meta)}>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ export default function Layout() {
|
||||||
}, [location.pathname, location.search]);
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen overflow-hidden bg-[#F3F5F7]">
|
<div className="relative flex h-screen overflow-hidden bg-[#F3F5F7] dark:bg-slate-950">
|
||||||
<Sidebar open={mobileSidebarOpen} onClose={() => setMobileSidebarOpen(false)} />
|
<Sidebar open={mobileSidebarOpen} onClose={() => setMobileSidebarOpen(false)} />
|
||||||
{mobileSidebarOpen && (
|
{mobileSidebarOpen && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -25,7 +25,7 @@ export default function Layout() {
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<TopBar onMenuToggle={() => setMobileSidebarOpen((open) => !open)} />
|
<TopBar onMenuToggle={() => setMobileSidebarOpen((open) => !open)} />
|
||||||
<main className="flex-1 overflow-auto bg-[#F3F5F7] p-3 sm:p-4 md:p-6">
|
<main className="flex-1 overflow-auto bg-[#F3F5F7] p-3 dark:bg-slate-950 sm:p-4 md:p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
|
@ -9,6 +9,11 @@ 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";
|
||||||
|
|
@ -26,33 +31,43 @@ 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
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 flex w-[min(18rem,85vw)] flex-col border-r border-slate-200 bg-[#F8F9FA] px-4 py-4 transition-transform duration-200 ease-out md:static md:z-auto md:w-72 md:translate-x-0",
|
"fixed inset-y-0 left-0 z-50 flex w-[min(18rem,85vw)] flex-col border-r border-slate-200 bg-[#F8F9FA] px-4 py-4 transition-transform duration-200 ease-out dark:border-slate-800 dark:bg-slate-950 md:static md:z-auto md:w-72 md:translate-x-0",
|
||||||
open ? "translate-x-0" : "-translate-x-full"
|
open ? "translate-x-0" : "-translate-x-full"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-16 items-center gap-2 rounded-2xl bg-white px-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
<div className="flex h-16 items-center gap-2 rounded-2xl bg-white px-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-sm font-bold text-white">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-sm font-bold text-white">
|
||||||
PM
|
PM
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-base font-bold tracking-tight text-slate-900">PMFlow</span>
|
<span className="text-base font-bold tracking-tight text-slate-900 dark:text-slate-100">PMFlow</span>
|
||||||
<p className="text-[11px] text-slate-500">AI 原生项目操作台</p>
|
<p className="text-[11px] text-slate-500 dark:text-slate-400">AI 原生项目操作台</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -76,22 +91,55 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||||
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-all",
|
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "bg-blue-600 text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)]"
|
? "bg-blue-600 text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)]"
|
||||||
: "text-slate-600 hover:bg-white hover:text-slate-900 hover:shadow-[0_6px_14px_rgba(15,23,42,0.06)]"
|
: "text-slate-600 hover:bg-white hover:text-slate-900 hover:shadow-[0_6px_14px_rgba(15,23,42,0.06)] dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white dark:hover:shadow-none"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={cn("inline-flex h-9 w-9 items-center justify-center rounded-xl", "bg-slate-100")}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 w-9 items-center justify-center rounded-xl",
|
||||||
|
"bg-slate-100 dark:bg-slate-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
{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)]">
|
<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
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-3 rounded-xl text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
className="w-full justify-start gap-3 rounded-xl text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,10 @@ const SECTION_LABELS: Record<InboxDisplayKind, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
||||||
action_required: "bg-amber-50 text-amber-700",
|
action_required: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300",
|
||||||
auto_running: "bg-sky-50 text-sky-700",
|
auto_running: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300",
|
||||||
auto_done: "bg-emerald-50 text-emerald-700",
|
auto_done: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300",
|
||||||
info: "bg-slate-100 text-slate-600",
|
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
|
|
@ -167,7 +167,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
|
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
|
||||||
|
|
||||||
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)] 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">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -180,8 +180,8 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-slate-900">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 sm:block">AI 原生项目管理操作台</div>
|
<div className="hidden text-xs text-slate-500 dark:text-slate-400 sm:block">AI 原生项目管理操作台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
|
@ -198,23 +198,23 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
className="w-[min(460px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)]"
|
className="w-[min(460px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] rounded-2xl border-0 bg-[#F8F9FA] p-3 shadow-[0_14px_30px_rgba(15,23,42,0.12)] dark:bg-slate-950 dark:shadow-none"
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl bg-white px-4 py-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
<div className="rounded-2xl bg-white px-4 py-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-slate-900">{zhCN.bell.title}</div>
|
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{zhCN.bell.title}</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
铃铛只看摘要,完整处理流统一进入 Inbox。
|
铃铛只看摘要,完整处理流统一进入 Inbox。
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
未读 {notifications.unread_count} · 待决策 {inboxData.leaderSummary.decision_count}
|
未读 {notifications.unread_count} · 待决策 {inboxData.leaderSummary.decision_count}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to={inboxHref}>{zhCN.bell.viewAll}</Link>
|
<Link to={inboxHref}>{zhCN.bell.viewAll}</Link>
|
||||||
|
|
@ -227,29 +227,29 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
key: "decision",
|
key: "decision",
|
||||||
label: "需你决策",
|
label: "需你决策",
|
||||||
value: inboxData.leaderSummary.decision_count,
|
value: inboxData.leaderSummary.decision_count,
|
||||||
chrome: "bg-amber-50 text-amber-700",
|
chrome: "bg-amber-50 text-amber-700 dark:bg-amber-900/35 dark:text-amber-300",
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "running",
|
key: "running",
|
||||||
label: "AI处理中",
|
label: "AI处理中",
|
||||||
value: inboxData.leaderSummary.auto_running_count,
|
value: inboxData.leaderSummary.auto_running_count,
|
||||||
chrome: "bg-sky-50 text-sky-700",
|
chrome: "bg-sky-50 text-sky-700 dark:bg-sky-900/35 dark:text-sky-300",
|
||||||
icon: LoaderCircle,
|
icon: LoaderCircle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "done",
|
key: "done",
|
||||||
label: "今日完成",
|
label: "今日完成",
|
||||||
value: inboxData.leaderSummary.auto_done_today,
|
value: inboxData.leaderSummary.auto_done_today,
|
||||||
chrome: "bg-emerald-50 text-emerald-700",
|
chrome: "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-300",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
].map(({ key, label, value, chrome, icon: Icon }) => (
|
].map(({ key, label, value, chrome, icon: Icon }) => (
|
||||||
<div key={key} className="rounded-xl bg-slate-50 px-3 py-3">
|
<div key={key} className="rounded-xl bg-slate-50 px-3 py-3 dark:bg-slate-800">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-slate-500">{label}</div>
|
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400">{label}</div>
|
||||||
<div className="mt-1 text-lg font-bold text-slate-900">{value}</div>
|
<div className="mt-1 text-lg font-bold text-slate-900 dark:text-slate-100">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("inline-flex h-8 w-8 items-center justify-center rounded-full", chrome)}>
|
<span className={cn("inline-flex h-8 w-8 items-center justify-center rounded-full", chrome)}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
|
@ -262,12 +262,15 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
|
|
||||||
<div className="mt-3 max-h-[560px] space-y-3 overflow-auto">
|
<div className="mt-3 max-h-[560px] space-y-3 overflow-auto">
|
||||||
{inboxData.previewSections.every((section) => section.count === 0) ? (
|
{inboxData.previewSections.every((section) => section.count === 0) ? (
|
||||||
<div className="rounded-2xl bg-white p-4 text-sm text-slate-500 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
<div className="rounded-2xl bg-white p-4 text-sm text-slate-500 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:text-slate-400 dark:shadow-none">
|
||||||
当前没有需要进入消息台的收件项。
|
当前没有需要进入消息台的收件项。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
inboxData.previewSections.map((section, index) => (
|
inboxData.previewSections.map((section, index) => (
|
||||||
<section key={section.key} className="rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
<section
|
||||||
|
key={section.key}
|
||||||
|
className="rounded-2xl bg-white p-3 shadow-[0_8px_22px_rgba(15,23,42,0.08)] dark:bg-slate-900 dark:shadow-none"
|
||||||
|
>
|
||||||
{index > 0 && <DropdownMenuSeparator className="hidden" />}
|
{index > 0 && <DropdownMenuSeparator className="hidden" />}
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -279,13 +282,13 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
>
|
>
|
||||||
{SECTION_LABELS[section.key as InboxDisplayKind]}
|
{SECTION_LABELS[section.key as InboxDisplayKind]}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs text-slate-500">前 3 条摘要预览</div>
|
<div className="text-xs text-slate-500 dark:text-slate-400">前 3 条摘要预览</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400">{section.count} 条</div>
|
<div className="text-xs text-slate-400 dark:text-slate-500">{section.count} 条</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{section.items.length === 0 ? (
|
{section.items.length === 0 ? (
|
||||||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||||||
当前分区为空
|
当前分区为空
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -295,7 +298,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openPreviewItem(item)}
|
onClick={() => void openPreviewItem(item)}
|
||||||
className="w-full rounded-xl border border-slate-100 bg-slate-50 p-3 text-left transition hover:border-slate-200 hover:bg-white"
|
className="w-full rounded-xl border border-slate-100 bg-slate-50 p-3 text-left transition hover:border-slate-200 hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-700"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
|
@ -303,17 +306,17 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||||
{SECTION_LABELS[item.display_kind]}
|
{SECTION_LABELS[item.display_kind]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.project_id && (
|
{item.project_id && (
|
||||||
<span className="truncate text-[11px] text-slate-500">
|
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
{item.project_name || `项目 #${item.project_id}`}
|
{item.project_name || `项目 #${item.project_id}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-[11px] text-slate-400">
|
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
{formatRelativeTime(item.updated_at || item.created_at)}
|
{formatRelativeTime(item.updated_at || item.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 line-clamp-1 text-sm font-semibold text-slate-900">{item.title}</div>
|
<div className="mt-2 line-clamp-1 text-sm font-semibold text-slate-900 dark:text-slate-100">{item.title}</div>
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{item.summary}</p>
|
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500 dark:text-slate-400">{item.summary}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,8 @@ export function SSTCSignalStagePanel({
|
||||||
title={s.title}
|
title={s.title}
|
||||||
content={s.content}
|
content={s.content}
|
||||||
proposerName={s.proposer_name}
|
proposerName={s.proposer_name}
|
||||||
|
plannerStatus={s.planner_status}
|
||||||
|
strategicAnalysis={s.strategic_analysis}
|
||||||
attachments={s.attachments || []}
|
attachments={s.attachments || []}
|
||||||
onDownloadAttachment={(attachment) => handleDownloadSignalAttachment(s.id, attachment)}
|
onDownloadAttachment={(attachment) => handleDownloadSignalAttachment(s.id, attachment)}
|
||||||
onUploadAttachment={(file, onProgress) =>
|
onUploadAttachment={(file, onProgress) =>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface SignalContentHoverCardProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
content: string;
|
content: string;
|
||||||
proposerName?: string;
|
proposerName?: string;
|
||||||
|
plannerStatus?: "pending" | "completed" | "failed";
|
||||||
|
strategicAnalysis?: Record<string, unknown>;
|
||||||
attachments?: SignalAttachment[];
|
attachments?: SignalAttachment[];
|
||||||
onDownloadAttachment?: (attachment: SignalAttachment) => Promise<void> | void;
|
onDownloadAttachment?: (attachment: SignalAttachment) => Promise<void> | void;
|
||||||
onUploadAttachment?: (file: File, onProgress?: (percent: number) => void) => Promise<void> | void;
|
onUploadAttachment?: (file: File, onProgress?: (percent: number) => void) => Promise<void> | void;
|
||||||
|
|
@ -46,6 +48,8 @@ export function SignalContentHoverCard({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
proposerName,
|
proposerName,
|
||||||
|
plannerStatus,
|
||||||
|
strategicAnalysis,
|
||||||
attachments = [],
|
attachments = [],
|
||||||
onDownloadAttachment,
|
onDownloadAttachment,
|
||||||
onUploadAttachment,
|
onUploadAttachment,
|
||||||
|
|
@ -60,6 +64,18 @@ export function SignalContentHoverCard({
|
||||||
const fullText = formatSignalContent(content || shortText, proposerName);
|
const fullText = formatSignalContent(content || shortText, proposerName);
|
||||||
const detailTitle = shortText || "信号详情";
|
const detailTitle = shortText || "信号详情";
|
||||||
const canUpload = typeof onUploadAttachment === "function";
|
const canUpload = typeof onUploadAttachment === "function";
|
||||||
|
const plannerPayload =
|
||||||
|
strategicAnalysis && typeof strategicAnalysis === "object" && strategicAnalysis !== null
|
||||||
|
? (strategicAnalysis.ai_planner as Record<string, unknown> | undefined)
|
||||||
|
: undefined;
|
||||||
|
const plannerSummary =
|
||||||
|
plannerPayload && typeof plannerPayload.summary === "string" ? plannerPayload.summary : "";
|
||||||
|
const plannerExecutionPlan =
|
||||||
|
plannerPayload && Array.isArray(plannerPayload.execution_plan)
|
||||||
|
? plannerPayload.execution_plan
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const handleDownloadAttachment = async (attachment: SignalAttachment) => {
|
const handleDownloadAttachment = async (attachment: SignalAttachment) => {
|
||||||
if (!onDownloadAttachment) return;
|
if (!onDownloadAttachment) return;
|
||||||
|
|
@ -200,6 +216,36 @@ export function SignalContentHoverCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plannerStatus === "pending" && (
|
||||||
|
<div className="rounded-2xl border border-blue-200 bg-blue-50 px-4 py-4">
|
||||||
|
<div className="text-xs font-semibold tracking-[0.16em] text-blue-700">AI 规划分析</div>
|
||||||
|
<p className="mt-2 text-sm text-blue-700">AI 分析中...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plannerStatus === "completed" && (
|
||||||
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4">
|
||||||
|
<div className="text-xs font-semibold tracking-[0.16em] text-emerald-700">AI 规划分析</div>
|
||||||
|
{plannerSummary ? (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-emerald-900">
|
||||||
|
{plannerSummary}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-emerald-800">已完成分析,暂无摘要。</p>
|
||||||
|
)}
|
||||||
|
{plannerExecutionPlan.length > 0 && (
|
||||||
|
<div className="mt-3 rounded-xl border border-emerald-200 bg-white px-3 py-3">
|
||||||
|
<div className="text-[11px] font-semibold tracking-[0.12em] text-emerald-700">执行建议</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-slate-700">
|
||||||
|
{plannerExecutionPlan.slice(0, 5).map((item, index) => (
|
||||||
|
<li key={`${index}-${item}`}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Settings as SettingsIcon, Loader2, CheckCircle2, AlertCircle, AlertTriangle,
|
Settings as SettingsIcon, Loader2, CheckCircle2, AlertCircle, AlertTriangle,
|
||||||
MessageSquare, Activity, Github, Globe,
|
MessageSquare, Activity, Github, Globe,
|
||||||
ChevronRight, Info, Sparkles, Building2, Upload, Link2, RefreshCw, Trash2
|
ChevronRight, Info, Sparkles, Building2, Upload, Link2, RefreshCw, Trash2,
|
||||||
|
Files, LayoutTemplate, History, ShieldCheck, Settings2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
|
|
@ -137,6 +138,44 @@ export default function AIConfigPage() {
|
||||||
const [editingWecom, setEditingWecom] = useState(false);
|
const [editingWecom, setEditingWecom] = useState(false);
|
||||||
const [editingGithub, setEditingGithub] = useState(false);
|
const [editingGithub, setEditingGithub] = useState(false);
|
||||||
|
|
||||||
|
const governanceTools = [
|
||||||
|
{
|
||||||
|
id: "brief-center",
|
||||||
|
name: "增量任务包中心",
|
||||||
|
description: "生成与查看任务包卡片",
|
||||||
|
to: "/ai-config/task-brief-center",
|
||||||
|
icon: Files,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brief-template",
|
||||||
|
name: "任务包模板管理",
|
||||||
|
description: "统一维护模板与规范",
|
||||||
|
to: "/ai-config/task-brief-templates",
|
||||||
|
icon: LayoutTemplate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brief-runs",
|
||||||
|
name: "任务包生成记录",
|
||||||
|
description: "查看生成历史与状态",
|
||||||
|
to: "/ai-config/task-brief-runs",
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contract-board",
|
||||||
|
name: "契约同步看板",
|
||||||
|
description: "检查契约文档同步状态",
|
||||||
|
to: "/ai-config/contract-sync-board",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "model-routing",
|
||||||
|
name: "模型路由配置",
|
||||||
|
description: "动态切换函数模型路由",
|
||||||
|
to: "/ai-config/model-routing-config",
|
||||||
|
icon: Settings2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ==================== Integration Sources ====================
|
// ==================== Integration Sources ====================
|
||||||
|
|
||||||
const integrationSources: IntegrationSource[] = [
|
const integrationSources: IntegrationSource[] = [
|
||||||
|
|
@ -803,6 +842,30 @@ export default function AIConfigPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card className="mb-6 border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">配置治理工具</CardTitle>
|
||||||
|
<CardDescription>新增治理页面统一收敛到智能配置中心入口,避免侧栏信息过载。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
{governanceTools.map(({ id, name, description, to, icon: Icon }) => (
|
||||||
|
<Link
|
||||||
|
key={id}
|
||||||
|
to={to}
|
||||||
|
className="group rounded-2xl border border-slate-200 bg-white p-3 transition-all hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800"
|
||||||
|
>
|
||||||
|
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{name}</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{description}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { taskBriefApi } from "@/api/taskBriefs";
|
||||||
|
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function ContractSyncBoardPage() {
|
||||||
|
const syncQuery = useQuery({
|
||||||
|
queryKey: ["task-brief-contract-sync"],
|
||||||
|
queryFn: () => taskBriefApi.getContractSyncStatus(),
|
||||||
|
});
|
||||||
|
const data = syncQuery.data;
|
||||||
|
const files = data?.files || [];
|
||||||
|
const errorText = syncQuery.error instanceof Error ? syncQuery.error.message : "请检查后端服务。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSubpageHeader
|
||||||
|
title="契约同步看板"
|
||||||
|
description="持续检查关键契约文档是否与当前功能实现同步。"
|
||||||
|
icon={<ShieldCheck className="h-5 w-5" />}
|
||||||
|
tone="amber"
|
||||||
|
meta={
|
||||||
|
<span>
|
||||||
|
状态:
|
||||||
|
{syncQuery.isLoading ? "检查中" : syncQuery.isError ? "检查失败" : data?.all_synced ? "已同步" : "存在缺失"}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">契约文件明细</CardTitle>
|
||||||
|
<CardDescription>缺失项会直接影响跨端协作与回归可靠性。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{syncQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||||
|
) : syncQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||||
|
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">同步状态读取失败:{errorText}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void syncQuery.refetch()}
|
||||||
|
disabled={syncQuery.isFetching}
|
||||||
|
>
|
||||||
|
{syncQuery.isFetching ? "重试中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">暂无契约文件记录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>文件</TableHead>
|
||||||
|
<TableHead>存在性</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{files.map((item) => (
|
||||||
|
<TableRow key={item.file} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||||
|
<TableCell className="font-medium">{item.file}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
item.exists
|
||||||
|
? "inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200"
|
||||||
|
: "inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-900/35 dark:text-rose-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.exists ? "已存在" : "缺失"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.updated_at || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { modelRoutingApi, type ModelRoutingItem } from "@/api/modelRouting";
|
||||||
|
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
const PROVIDER_OPTIONS = ["openai", "glm", "deepseek", "ollama"] as const;
|
||||||
|
const TIER_OPTIONS = ["high", "low", "safety"] as const;
|
||||||
|
|
||||||
|
export default function ModelRoutingConfigPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze");
|
||||||
|
const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>("openai");
|
||||||
|
const [model, setModel] = useState("GLM-5");
|
||||||
|
const [apiEnv, setApiEnv] = useState("OPENAI_API_KEY");
|
||||||
|
const [tier, setTier] = useState<(typeof TIER_OPTIONS)[number]>("high");
|
||||||
|
const [baseURL, setBaseURL] = useState("");
|
||||||
|
|
||||||
|
const routingQuery = useQuery({
|
||||||
|
queryKey: ["model-routing-functions"],
|
||||||
|
queryFn: () => modelRoutingApi.listFunctions(),
|
||||||
|
});
|
||||||
|
const items = routingQuery.data || [];
|
||||||
|
|
||||||
|
const current = useMemo(
|
||||||
|
() => items.find((item) => item.function_name === selectedFunction) || null,
|
||||||
|
[items, selectedFunction]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fallback: ModelRoutingItem | null = current || items[0] || null;
|
||||||
|
if (!fallback) return;
|
||||||
|
if (!current && fallback.function_name) {
|
||||||
|
setSelectedFunction(fallback.function_name);
|
||||||
|
}
|
||||||
|
const nextProvider = PROVIDER_OPTIONS.includes(fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||||
|
? (fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||||
|
: "openai";
|
||||||
|
const nextTier = TIER_OPTIONS.includes(fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||||
|
? (fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||||
|
: "high";
|
||||||
|
setProvider(nextProvider);
|
||||||
|
setTier(nextTier);
|
||||||
|
setModel(fallback.model || "");
|
||||||
|
setApiEnv(fallback.api_env || "");
|
||||||
|
setBaseURL(fallback.base_url || "");
|
||||||
|
}, [current, items]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
modelRoutingApi.updateFunction(selectedFunction, {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
api_env: apiEnv,
|
||||||
|
tier,
|
||||||
|
base_url: baseURL || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const routingErrorText = routingQuery.error instanceof Error ? routingQuery.error.message : "请检查后端服务。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSubpageHeader
|
||||||
|
title="模型路由配置"
|
||||||
|
description="按 function_name 动态调整 provider / model / api_env / tier,立即生效。"
|
||||||
|
icon={<Settings2 className="h-5 w-5" />}
|
||||||
|
tone="blue"
|
||||||
|
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">编辑器</CardTitle>
|
||||||
|
<CardDescription>选择函数并更新路由参数。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Input
|
||||||
|
value={selectedFunction}
|
||||||
|
onChange={(e) => setSelectedFunction(e.target.value.trim())}
|
||||||
|
placeholder="function_name"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<select
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => setProvider(e.target.value as (typeof PROVIDER_OPTIONS)[number])}
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
{PROVIDER_OPTIONS.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={tier}
|
||||||
|
onChange={(e) => setTier(e.target.value as (typeof TIER_OPTIONS)[number])}
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
{TIER_OPTIONS.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="模型名" />
|
||||||
|
<Input value={apiEnv} onChange={(e) => setApiEnv(e.target.value)} placeholder="API Key 环境变量名" />
|
||||||
|
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="Base URL(可选)" />
|
||||||
|
<Button
|
||||||
|
onClick={() => updateMutation.mutate()}
|
||||||
|
disabled={updateMutation.isPending || !selectedFunction || !model || !apiEnv}
|
||||||
|
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "保存中..." : "保存路由"}
|
||||||
|
</Button>
|
||||||
|
{updateMutation.isError ? (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
保存失败:{updateMutation.error instanceof Error ? updateMutation.error.message : "请重试"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">生效路由</CardTitle>
|
||||||
|
<CardDescription>选中行即当前编辑目标。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{routingQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||||
|
) : routingQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||||
|
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">路由读取失败:{routingErrorText}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void routingQuery.refetch()}
|
||||||
|
disabled={routingQuery.isFetching}
|
||||||
|
>
|
||||||
|
{routingQuery.isFetching ? "重试中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">暂无路由配置。</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Function</TableHead>
|
||||||
|
<TableHead>Provider</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Tier</TableHead>
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.function_name}
|
||||||
|
className={
|
||||||
|
item.function_name === selectedFunction
|
||||||
|
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||||
|
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{item.function_name}</TableCell>
|
||||||
|
<TableCell>{item.provider}</TableCell>
|
||||||
|
<TableCell>{item.model}</TableCell>
|
||||||
|
<TableCell>{item.tier}</TableCell>
|
||||||
|
<TableCell>{item.source}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Files } from "lucide-react";
|
||||||
|
|
||||||
|
import { taskBriefApi } from "@/api/taskBriefs";
|
||||||
|
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function TaskBriefCenterPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [title, setTitle] = useState("Signal Planner 增量任务包");
|
||||||
|
const [templateId, setTemplateId] = useState<string>("");
|
||||||
|
const [scopeSummary, setScopeSummary] = useState("");
|
||||||
|
const [changedFilesText, setChangedFilesText] = useState("");
|
||||||
|
|
||||||
|
const centerQuery = useQuery({
|
||||||
|
queryKey: ["task-brief-center"],
|
||||||
|
queryFn: () => taskBriefApi.listCenter(),
|
||||||
|
});
|
||||||
|
const cards = centerQuery.data || [];
|
||||||
|
|
||||||
|
const templateQuery = useQuery({
|
||||||
|
queryKey: ["task-brief-templates"],
|
||||||
|
queryFn: () => taskBriefApi.listTemplates(),
|
||||||
|
});
|
||||||
|
const templates = templateQuery.data || [];
|
||||||
|
|
||||||
|
const changedFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
changedFilesText
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
[changedFilesText]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
taskBriefApi.generate({
|
||||||
|
title,
|
||||||
|
template_id: templateId || undefined,
|
||||||
|
changed_files: changedFiles,
|
||||||
|
scope_summary: scopeSummary || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-brief-center"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-brief-runs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerErrorText = centerQuery.error instanceof Error ? centerQuery.error.message : "请检查登录状态或后端服务。";
|
||||||
|
const templateErrorText = templateQuery.error instanceof Error ? templateQuery.error.message : "模板读取失败";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSubpageHeader
|
||||||
|
title="增量任务包中心"
|
||||||
|
description="按模板生成任务包并追踪输出卡片。"
|
||||||
|
icon={<Files className="h-5 w-5" />}
|
||||||
|
tone="emerald"
|
||||||
|
meta={<span>{centerQuery.isError ? "读取失败" : `当前卡片 ${cards.length}`}</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">任务包生成器</CardTitle>
|
||||||
|
<CardDescription>填写变更范围并一键生成任务包。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="任务包标题" />
|
||||||
|
<select
|
||||||
|
value={templateId}
|
||||||
|
onChange={(e) => setTemplateId(e.target.value)}
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">默认模板</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{templateQuery.isError ? (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">模板加载失败:{templateErrorText}</p>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
value={scopeSummary}
|
||||||
|
onChange={(e) => setScopeSummary(e.target.value)}
|
||||||
|
placeholder="范围摘要(可选)"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
rows={6}
|
||||||
|
value={changedFilesText}
|
||||||
|
onChange={(e) => setChangedFilesText(e.target.value)}
|
||||||
|
placeholder={"每行一个变更文件,例如:\nbackend/app/services/signal_planner_service.py\nfrontend/src/pages/ModelRoutingConfigPage.tsx"}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">已解析 {changedFiles.length} 个文件路径</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => generateMutation.mutate()}
|
||||||
|
disabled={generateMutation.isPending || !title.trim()}
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? "生成中..." : "生成任务包"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{generateMutation.isError ? (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
生成失败:{generateMutation.error instanceof Error ? generateMutation.error.message : "请重试"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">任务包卡片</CardTitle>
|
||||||
|
<CardDescription>按更新时间倒序展示。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{centerQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||||
|
) : centerQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||||
|
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">任务包读取失败:{centerErrorText}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void centerQuery.refetch()}
|
||||||
|
disabled={centerQuery.isFetching}
|
||||||
|
>
|
||||||
|
{centerQuery.isFetching ? "重试中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">暂无任务包,请先生成。</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>模板</TableHead>
|
||||||
|
<TableHead>摘要</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<TableRow key={card.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||||
|
<TableCell className="font-medium">{card.title}</TableCell>
|
||||||
|
<TableCell>{card.status}</TableCell>
|
||||||
|
<TableCell>{card.template_name || "-"}</TableCell>
|
||||||
|
<TableCell className="max-w-[460px] truncate">{card.summary || "-"}</TableCell>
|
||||||
|
<TableCell>{card.updated_at || card.created_at || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { History } from "lucide-react";
|
||||||
|
|
||||||
|
import { taskBriefApi } from "@/api/taskBriefs";
|
||||||
|
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function TaskBriefRunsPage() {
|
||||||
|
const runsQuery = useQuery({
|
||||||
|
queryKey: ["task-brief-runs"],
|
||||||
|
queryFn: () => taskBriefApi.listRuns(),
|
||||||
|
});
|
||||||
|
const runs = runsQuery.data || [];
|
||||||
|
const errorText = runsQuery.error instanceof Error ? runsQuery.error.message : "请检查后端服务。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSubpageHeader
|
||||||
|
title="任务包生成记录"
|
||||||
|
description="跟踪任务包生成流水,快速回溯模板与范围。"
|
||||||
|
icon={<History className="h-5 w-5" />}
|
||||||
|
tone="slate"
|
||||||
|
meta={<span>{runsQuery.isError ? "读取失败" : `记录总数 ${runs.length}`}</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">运行列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{runsQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||||
|
) : runsQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||||
|
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">记录读取失败:{errorText}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void runsQuery.refetch()}
|
||||||
|
disabled={runsQuery.isFetching}
|
||||||
|
>
|
||||||
|
{runsQuery.isFetching ? "重试中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">暂无生成记录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>模板</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>变更文件数</TableHead>
|
||||||
|
<TableHead>范围摘要</TableHead>
|
||||||
|
<TableHead>生成时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<TableRow key={run.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||||
|
<TableCell className="font-medium">{run.title}</TableCell>
|
||||||
|
<TableCell>{run.template_name || "-"}</TableCell>
|
||||||
|
<TableCell>{run.status}</TableCell>
|
||||||
|
<TableCell>{run.changed_files?.length || 0}</TableCell>
|
||||||
|
<TableCell className="max-w-[420px] truncate">{run.scope_summary || "-"}</TableCell>
|
||||||
|
<TableCell>{run.generated_at || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { LayoutTemplate } from "lucide-react";
|
||||||
|
|
||||||
|
import { taskBriefApi, type TaskBriefTemplate } from "@/api/taskBriefs";
|
||||||
|
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function TaskBriefTemplatePage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
|
||||||
|
const templatesQuery = useQuery({
|
||||||
|
queryKey: ["task-brief-templates"],
|
||||||
|
queryFn: () => taskBriefApi.listTemplates(),
|
||||||
|
});
|
||||||
|
const templates = templatesQuery.data || [];
|
||||||
|
|
||||||
|
const selectedTemplate = useMemo(
|
||||||
|
() => templates.find((item) => item.id === selectedId) || null,
|
||||||
|
[templates, selectedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
setName(selectedTemplate.name || "");
|
||||||
|
setDescription(selectedTemplate.description || "");
|
||||||
|
setContent(selectedTemplate.content || "");
|
||||||
|
}, [selectedTemplate]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (payload: { name: string; description: string; content: string }) =>
|
||||||
|
taskBriefApi.createTemplate({
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description || undefined,
|
||||||
|
content: payload.content,
|
||||||
|
}),
|
||||||
|
onSuccess: (created: TaskBriefTemplate) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||||
|
setSelectedId(created.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (payload: { id: string; name: string; description: string; content: string }) =>
|
||||||
|
taskBriefApi.updateTemplate(payload.id, {
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description || undefined,
|
||||||
|
content: payload.content,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const templatesErrorText = templatesQuery.error instanceof Error ? templatesQuery.error.message : "请检查后端服务。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSubpageHeader
|
||||||
|
title="任务包模板管理"
|
||||||
|
description="统一维护增量任务包模板,保障跨任务输出结构一致。"
|
||||||
|
icon={<LayoutTemplate className="h-5 w-5" />}
|
||||||
|
tone="violet"
|
||||||
|
meta={<span>{templatesQuery.isError ? "模板读取失败" : `模板总数 ${templates.length}`}</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">模板编辑器</CardTitle>
|
||||||
|
<CardDescription>支持新建与覆盖更新。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="模板名称" />
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="模板描述(可选)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
rows={12}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="模板正文"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !content.trim()}
|
||||||
|
onClick={() => createMutation.mutate({ name, description, content })}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "新建模板"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={updateMutation.isPending || !selectedId || !name.trim() || !content.trim()}
|
||||||
|
onClick={() => updateMutation.mutate({ id: selectedId, name, description, content })}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "保存中..." : "保存当前模板"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{createMutation.isError || updateMutation.isError ? (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
保存失败:
|
||||||
|
{createMutation.error instanceof Error
|
||||||
|
? createMutation.error.message
|
||||||
|
: updateMutation.error instanceof Error
|
||||||
|
? updateMutation.error.message
|
||||||
|
: "请重试"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">模板列表</CardTitle>
|
||||||
|
<CardDescription>点击“编辑”可回填到左侧编辑器。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{templatesQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||||
|
) : templatesQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||||
|
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">模板读取失败:{templatesErrorText}</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void templatesQuery.refetch()}
|
||||||
|
disabled={templatesQuery.isFetching}
|
||||||
|
>
|
||||||
|
{templatesQuery.isFetching ? "重试中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">暂无模板。</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{templates.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={
|
||||||
|
item.id === selectedId
|
||||||
|
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||||
|
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
<TableCell className="max-w-[320px] truncate">{item.description || "-"}</TableCell>
|
||||||
|
<TableCell>{item.updated_at || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setSelectedId(item.id)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,11 @@ import WorkloadsPage from "@/pages/WorkloadsPage";
|
||||||
import WorkloadDetailPage from "@/pages/WorkloadDetailPage";
|
import WorkloadDetailPage from "@/pages/WorkloadDetailPage";
|
||||||
import UserSkillsPage from "@/pages/UserSkillsPage";
|
import UserSkillsPage from "@/pages/UserSkillsPage";
|
||||||
import InboxPage from "@/pages/InboxPage";
|
import InboxPage from "@/pages/InboxPage";
|
||||||
|
import TaskBriefCenterPage from "@/pages/TaskBriefCenterPage";
|
||||||
|
import TaskBriefTemplatePage from "@/pages/TaskBriefTemplatePage";
|
||||||
|
import TaskBriefRunsPage from "@/pages/TaskBriefRunsPage";
|
||||||
|
import ContractSyncBoardPage from "@/pages/ContractSyncBoardPage";
|
||||||
|
import ModelRoutingConfigPage from "@/pages/ModelRoutingConfigPage";
|
||||||
import { canManageTeamWorkload, isManagerRole } from "@/lib/rbac";
|
import { canManageTeamWorkload, isManagerRole } from "@/lib/rbac";
|
||||||
|
|
||||||
function AuthGuard() {
|
function AuthGuard() {
|
||||||
|
|
@ -75,6 +80,16 @@ export const router = createBrowserRouter(
|
||||||
{ path: "projects/:id/charter/:charterId", element: <CharterDetailPage /> },
|
{ path: "projects/:id/charter/:charterId", element: <CharterDetailPage /> },
|
||||||
{ path: "ai-steward", element: <AIStewardPage /> },
|
{ path: "ai-steward", element: <AIStewardPage /> },
|
||||||
{ path: "ai-config", element: <AIConfigPage /> },
|
{ path: "ai-config", element: <AIConfigPage /> },
|
||||||
|
{ path: "ai-config/task-brief-center", element: <TaskBriefCenterPage /> },
|
||||||
|
{ path: "ai-config/task-brief-templates", element: <TaskBriefTemplatePage /> },
|
||||||
|
{ path: "ai-config/task-brief-runs", element: <TaskBriefRunsPage /> },
|
||||||
|
{ path: "ai-config/contract-sync-board", element: <ContractSyncBoardPage /> },
|
||||||
|
{ path: "ai-config/model-routing-config", element: <ModelRoutingConfigPage /> },
|
||||||
|
{ path: "task-brief-center", element: <Navigate to="/ai-config/task-brief-center" replace /> },
|
||||||
|
{ path: "task-brief-templates", element: <Navigate to="/ai-config/task-brief-templates" replace /> },
|
||||||
|
{ path: "task-brief-runs", element: <Navigate to="/ai-config/task-brief-runs" replace /> },
|
||||||
|
{ path: "contract-sync-board", element: <Navigate to="/ai-config/contract-sync-board" replace /> },
|
||||||
|
{ path: "model-routing-config", element: <Navigate to="/ai-config/model-routing-config" replace /> },
|
||||||
{ path: "feishu-config", element: <FeishuConfigPage /> },
|
{ path: "feishu-config", element: <FeishuConfigPage /> },
|
||||||
{ path: "skills", element: <SkillsPage /> },
|
{ path: "skills", element: <SkillsPage /> },
|
||||||
{ path: "users", element: <UsersPage /> },
|
{ path: "users", element: <UsersPage /> },
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
BASE_REF="${1:-}"
|
||||||
|
HEAD_REF="${2:-HEAD}"
|
||||||
|
|
||||||
|
if [[ -z "$BASE_REF" ]]; then
|
||||||
|
BASE_REF="${GITHUB_BASE_REF:-}"
|
||||||
|
if [[ -n "$BASE_REF" ]]; then
|
||||||
|
BASE_REF="origin/$BASE_REF"
|
||||||
|
elif git rev-parse --verify origin/master >/dev/null 2>&1; then
|
||||||
|
BASE_REF="origin/master"
|
||||||
|
elif git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||||
|
BASE_REF="origin/main"
|
||||||
|
else
|
||||||
|
BASE_REF="HEAD~1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGED="$(git diff --name-only "$BASE_REF"..."$HEAD_REF" || true)"
|
||||||
|
if [[ -z "$CHANGED" ]]; then
|
||||||
|
echo "[contract-check] no changes detected"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_CHANGED=0
|
||||||
|
DOC_CHANGED=0
|
||||||
|
TS_CHANGED=0
|
||||||
|
|
||||||
|
if printf '%s\n' "$CHANGED" | grep -E -q '^(backend/app/api/|backend/app/schemas/|backend/app/services/signal_planner_service.py|backend/app/services/model_routing_service.py)'; then
|
||||||
|
API_CHANGED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s\n' "$CHANGED" | grep -E -q '^(frontend/src/api/|frontend/src/types/|frontend/src/pages/ModelRoutingConfigPage.tsx)'; then
|
||||||
|
TS_CHANGED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s\n' "$CHANGED" | grep -E -q '^docs/architecture/(MASTER_CONTRACT.md|API_CONTRACTS.md|DATAFLOW.md)$'; then
|
||||||
|
DOC_CHANGED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$API_CHANGED" -eq 1 && "$TS_CHANGED" -eq 0 ]]; then
|
||||||
|
echo "[contract-check] backend contract changed but frontend TS contract not updated"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$API_CHANGED" -eq 1 && "$DOC_CHANGED" -eq 0 ]]; then
|
||||||
|
echo "[contract-check] backend contract changed but architecture docs not updated"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[contract-check] pass"
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
BASE_REF="${1:-}"
|
||||||
|
HEAD_REF="${2:-HEAD}"
|
||||||
|
|
||||||
|
if [[ -z "$BASE_REF" ]]; then
|
||||||
|
if git rev-parse --verify origin/master >/dev/null 2>&1; then
|
||||||
|
BASE_REF="origin/master"
|
||||||
|
elif git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||||
|
BASE_REF="origin/main"
|
||||||
|
else
|
||||||
|
BASE_REF="HEAD~1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
TS="$(date '+%Y%m%d_%H%M%S')"
|
||||||
|
OUT_DIR="docs/task_briefs"
|
||||||
|
OUT_FILE="$OUT_DIR/task_brief_${TS}.md"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
CHANGED_FILES_RAW="$(git diff --name-only "$BASE_REF"..."$HEAD_REF" || true)"
|
||||||
|
if [[ -z "$CHANGED_FILES_RAW" ]]; then
|
||||||
|
CHANGED_FILES="- (no file changes detected)"
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(printf '%s\n' "$CHANGED_FILES_RAW" | sed 's#^#- #')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCOPE_SUMMARY="- Backend/API: $(printf '%s\n' "$CHANGED_FILES_RAW" | grep -c '^backend/' || true) files\n- Frontend: $(printf '%s\n' "$CHANGED_FILES_RAW" | grep -c '^frontend/' || true) files\n- Docs/Scripts/CI: $(printf '%s\n' "$CHANGED_FILES_RAW" | grep -E -c '^(docs/|scripts/|\.github/)' || true) files"
|
||||||
|
|
||||||
|
RISKS="- Contract drift risk when backend API changes without TS sync.\n- Routing regression risk when function_name or route tier changes.\n- Async planner failure should not block signal main flow."
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Task Brief"
|
||||||
|
echo
|
||||||
|
echo "## Meta"
|
||||||
|
echo "- Generated At: $(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||||
|
echo "- Base Ref: $BASE_REF"
|
||||||
|
echo "- Head Ref: $HEAD_REF"
|
||||||
|
echo
|
||||||
|
echo "## Changed Files"
|
||||||
|
printf '%b\n' "$CHANGED_FILES"
|
||||||
|
echo
|
||||||
|
echo "## Scope"
|
||||||
|
printf '%b\n' "$SCOPE_SUMMARY"
|
||||||
|
echo
|
||||||
|
echo "## Risks"
|
||||||
|
printf '%b\n' "$RISKS"
|
||||||
|
echo
|
||||||
|
echo "## Verification"
|
||||||
|
echo "- Backend: \`python3 -m py_compile backend/app\`"
|
||||||
|
echo "- Frontend: \`npm run build\` + \`npx tsc --noEmit\`"
|
||||||
|
echo
|
||||||
|
echo "## Delivery Checklist"
|
||||||
|
echo "- [ ] Code completed"
|
||||||
|
echo "- [ ] Contracts synchronized"
|
||||||
|
echo "- [ ] Docs synchronized"
|
||||||
|
echo "- [ ] Build/lint/tests passed"
|
||||||
|
} > "$OUT_FILE"
|
||||||
|
|
||||||
|
echo "$OUT_FILE"
|
||||||
Loading…
Reference in New Issue