完成 GLM5 信号计划与主题修复任务

This commit is contained in:
Yuuuu 2026-03-12 21:15:20 +08:00
parent 25886a25ca
commit 5b7cf23aeb
51 changed files with 3976 additions and 181 deletions

33
.github/workflows/contract-check.yml vendored Normal file
View File

@ -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

View File

@ -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
} }
] ]

BIN
.tmp/dark_model_routing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -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)用于离线回放与分桶评分

View File

@ -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
}
]
}

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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 配置(支持前端可视化设置)
# ============================================================================ # ============================================================================

View File

@ -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")

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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],
)

View File

@ -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

View File

@ -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.

View File

@ -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")

View File

@ -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)

View File

@ -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: 当前任务未接入飞书真实回调流量,`飞书补发记录截图` 与 `数据库查询截图` 需人工在目标环境补齐]

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
},
};

View File

@ -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 {

View File

@ -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);
},
};

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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" ? (

View File

@ -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>

View File

@ -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) =>

View File

@ -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>

View File

@ -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]">

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 /> },

View File

@ -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"}

55
scripts/check_contract_sync.sh Executable file
View File

@ -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"

64
scripts/gen_task_brief.sh Executable file
View File

@ -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"