diff --git a/.github/workflows/contract-check.yml b/.github/workflows/contract-check.yml new file mode 100644 index 00000000..65ae838c --- /dev/null +++ b/.github/workflows/contract-check.yml @@ -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 diff --git a/.openclaw/pmflow-routing-rules.json b/.openclaw/pmflow-routing-rules.json index bfa6d13c..0818c1d1 100644 --- a/.openclaw/pmflow-routing-rules.json +++ b/.openclaw/pmflow-routing-rules.json @@ -68,7 +68,8 @@ "detect_cognitive_conflicts", "rag_db_project_semantic_retrieval", "workload_optimize_prompt", - "analyze_cost_roi" + "analyze_cost_roi", + "signal_planner_analyze" ] }, { @@ -77,7 +78,7 @@ "priority": 200, "tier": "high", "provider": "openai", - "model": "GLM-5", + "model": "MiniMax-M2.5", "api_env": "OPENAI_API_KEY", "match_any": [ "analyze_food_calories", @@ -95,9 +96,9 @@ "enabled": true, "priority": 50, "tier": "safety", - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", + "provider": "openai", + "model": "deepseek-chat", + "api_env": "OPENAI_API_KEY", "match_all": true } ] diff --git a/.tmp/dark_model_routing.png b/.tmp/dark_model_routing.png new file mode 100644 index 00000000..199c7107 Binary files /dev/null and b/.tmp/dark_model_routing.png differ diff --git a/.tmp/dark_task_brief_runs.png b/.tmp/dark_task_brief_runs.png new file mode 100644 index 00000000..8dce98d6 Binary files /dev/null and b/.tmp/dark_task_brief_runs.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 6af7c398..71e18028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,9 @@ AIProjectManger/ └── docs/ # 额外文档 ``` - 新增:`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/`) ```text @@ -66,8 +69,18 @@ backend/ - 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) - 新增:`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/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/.openclaw/pmflow-routing-rules.json`(路由规则统一到根目录 `.openclaw/`) +- 新增:`backend/.openclaw/pmflow-routing-rules.json`(容器内兼容同步副本,与根目录规则保持一致) ## 5. 前端结构(`frontend/`) ```text @@ -94,6 +107,13 @@ frontend/ - 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化) - 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制) - 新增:`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/` 目录说明(模块化拆分后) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) @@ -145,6 +165,8 @@ frontend/ - 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路) - 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘) - 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型) +- 新增:`scripts/gen_task_brief.sh`(按 git diff 自动生成增量任务包文档) +- 新增:`scripts/check_contract_sync.sh`(后端契约变更时强制校验前端类型与架构文档同步) ### 前端 ```bash @@ -178,6 +200,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 9. 新增代码的参考模式 - 新增后端接口:参考 `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/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - 配置读取类接口:参考 `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`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程 - 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取 - 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`),不使用供应商前缀模型名 - 双 Agent 路由治理:默认启用 `OPENCLAW_ROUTING_ENABLED=true`、关闭 `LLM_MIXED_ROUTING_ENABLED`,复杂意图使用 `classify_intent_complex` 走 high-tier - 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json new file mode 100644 index 00000000..0818c1d1 --- /dev/null +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -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 + } + ] +} diff --git a/backend/alembic/versions/20260312_add_signal_planner_fields.py b/backend/alembic/versions/20260312_add_signal_planner_fields.py new file mode 100644 index 00000000..91885b0d --- /dev/null +++ b/backend/alembic/versions/20260312_add_signal_planner_fields.py @@ -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") diff --git a/backend/app/api/model_routing.py b/backend/app/api/model_routing.py new file mode 100644 index 00000000..a002d832 --- /dev/null +++ b/backend/app/api/model_routing.py @@ -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) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index a879274d..82067b1e 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -35,6 +35,8 @@ from .signal_sources import router as signal_sources_router from .operation_catalog import router as operation_catalog_router from .ai_pmo import router as ai_pmo_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_router = APIRouter() @@ -69,3 +71,5 @@ api_router.include_router(signal_sources_router) api_router.include_router(operation_catalog_router) api_router.include_router(ai_pmo_router) api_router.include_router(audit_router) +api_router.include_router(model_routing_router) +api_router.include_router(task_briefs_router) diff --git a/backend/app/api/sstc.py b/backend/app/api/sstc.py index a922aa86..9501166e 100644 --- a/backend/app/api/sstc.py +++ b/backend/app/api/sstc.py @@ -235,6 +235,13 @@ def _signal_source_context(signal: Signal) -> dict: 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]: try: return int(str(value).strip()) @@ -1785,6 +1792,7 @@ async def list_signals( ) rows = [] for s in signals: + strategic_payload = _as_dict(s.strategic_analysis) ai_analysis = _signal_ai_analysis(s) source_context = _signal_source_context(s) project_scope = _signal_project_scope(s) @@ -1821,6 +1829,14 @@ async def list_signals( "charter_name": linked_signal_charter_name.get(sid), "created_at": s.created_at.isoformat() if s.created_at else None, "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 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, "impact_score": s.impact_score, "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 ], @@ -2319,6 +2343,18 @@ async def get_charter_detail( "priority": source_signal.priority, "content": source_signal.content, "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 else None diff --git a/backend/app/api/task_briefs.py b/backend/app/api/task_briefs.py new file mode 100644 index 00000000..f5fe252b --- /dev/null +++ b/backend/app/api/task_briefs.py @@ -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) diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 3dc2770d..e62de81c 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -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 配置(支持前端可视化设置) # ============================================================================ diff --git a/backend/app/models/signal.py b/backend/app/models/signal.py index 5d2e0600..458f5be1 100644 --- a/backend/app/models/signal.py +++ b/backend/app/models/signal.py @@ -1,7 +1,7 @@ """信号模型""" from .base import Base 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 @@ -27,5 +27,8 @@ class Signal(Base): confidence: Mapped[Optional[float]] = mapped_column(Float, 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) + 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") diff --git a/backend/app/services/ai_pmo_kernel_service.py b/backend/app/services/ai_pmo_kernel_service.py index d99eaf18..a59d148a 100644 --- a/backend/app/services/ai_pmo_kernel_service.py +++ b/backend/app/services/ai_pmo_kernel_service.py @@ -73,6 +73,7 @@ from .openclaw_commander_service import OpenClawCommanderService from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from .llm import llm_service from .signal_source_orchestration_service import SignalSourceOrchestrationService +from .signal_planner_service import SignalPlannerService from ..utils.timezone import now_cn_naive logger = get_logger(__name__) @@ -999,6 +1000,7 @@ class TypedExecutor: operator_user_id: Optional[int], source_system: str, source_message_id: str, + channel_ref: Optional[str] = None, ) -> dict[str, Any]: if intent.name == INTENT_QUERY: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) @@ -1048,6 +1050,19 @@ class TypedExecutor: intent=intent, 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( title=str(semantic_payload.get("title") or intent.signal_title or "新增信号源接入请求"), summary=str(semantic_payload.get("summary") or intent.signal_summary or message_text), @@ -1062,8 +1077,8 @@ class TypedExecutor: else None ), strategic_analysis=( - semantic_payload.get("strategic_analysis") - if isinstance(semantic_payload.get("strategic_analysis"), dict) + strategic_payload + if isinstance(strategic_payload, dict) else None ), ) @@ -1088,6 +1103,19 @@ class TypedExecutor: } 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( title=intent.signal_title or "新信号", summary=intent.signal_summary or message_text, @@ -1096,11 +1124,13 @@ class TypedExecutor: source="PMFlow", signal_type="新需求", recommended_action="enter_only", + strategic_analysis=strategic_analysis, ) return { "status": "succeeded", "signal_id": int(signal.id), "project_id": intent.project_id, + "summary": str(intent.signal_summary or message_text)[:300], "auto_tasks": [], } @@ -1580,6 +1610,7 @@ class AIPMOKernelService: operator_user_id=_safe_int(getattr(operator, "id", None)), source_system=source_system, source_message_id=synthetic_message_id, + channel_ref=channel_ref, ) _log_perf("TypedExecutor.execute", stage_started_at) 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], ) 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() capture_result = self._build_capture_result( work_item=work_item, diff --git a/backend/app/services/feishu_outbox_service.py b/backend/app/services/feishu_outbox_service.py new file mode 100644 index 00000000..5f808417 --- /dev/null +++ b/backend/app/services/feishu_outbox_service.py @@ -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 diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 4450dcc5..f8f08049 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -107,6 +107,7 @@ DEFAULT_HIGH_TIER_HINTS = ( "cognitive_conflict", "blocker", "model_requirement", + "signal_planner_analyze", ) DEFAULT_LOW_TIER_HINTS = ( "feishu_level_classification", @@ -145,6 +146,7 @@ STRUCTURED_OUTPUT_FUNCTION_HINTS = ( "sstc_ai_breakdown", "sstc_charter_localize_zh", "sstc_charter_prefill", + "signal_planner_analyze", ) LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( "不知道", @@ -239,6 +241,20 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "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) 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( function_name=function_name, engines=engines, @@ -1537,6 +1563,70 @@ class LLMService: ) 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]: 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 "") @@ -2082,6 +2172,8 @@ class LLMService: return isinstance(value, (int, float)) and not isinstance(value, bool) if expected == "number_or_none": 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": return value is None or isinstance(value, dict) return True diff --git a/backend/app/services/model_routing_service.py b/backend/app/services/model_routing_service.py new file mode 100644 index 00000000..af55304b --- /dev/null +++ b/backend/app/services/model_routing_service.py @@ -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]) diff --git a/backend/app/services/signal_planner_service.py b/backend/app/services/signal_planner_service.py new file mode 100644 index 00000000..77c01c88 --- /dev/null +++ b/backend/app/services/signal_planner_service.py @@ -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], + ) diff --git a/backend/app/services/signal_strategic_analysis_service.py b/backend/app/services/signal_strategic_analysis_service.py new file mode 100644 index 00000000..6e07f959 --- /dev/null +++ b/backend/app/services/signal_strategic_analysis_service.py @@ -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 diff --git a/backend/app/services/task_brief_service.py b/backend/app/services/task_brief_service.py new file mode 100644 index 00000000..23a79a00 --- /dev/null +++ b/backend/app/services/task_brief_service.py @@ -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, + } diff --git a/backend/neural_sync.db-shm b/backend/neural_sync.db-shm deleted file mode 100644 index fe9ac284..00000000 Binary files a/backend/neural_sync.db-shm and /dev/null differ diff --git a/backend/neural_sync.db-wal b/backend/neural_sync.db-wal deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/test_model_routing_api.py b/backend/tests/test_model_routing_api.py new file mode 100644 index 00000000..36f9fbb9 --- /dev/null +++ b/backend/tests/test_model_routing_api.py @@ -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") diff --git a/backend/tests/test_task_briefs_api.py b/backend/tests/test_task_briefs_api.py new file mode 100644 index 00000000..7c1ac86c --- /dev/null +++ b/backend/tests/test_task_briefs_api.py @@ -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) diff --git a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md index 51f3d9d5..4abc3ce1 100644 --- a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md +++ b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md @@ -3,229 +3,248 @@ > 使用规则:严格按步骤推进;每个步骤完成后再勾选;未完成禁止进入下一步。 ## 0. 全局硬约束(执行前必须确认) -- [ ] 所有业务写操作必须通过 service/repository 入口调用。 -- [ ] 严禁在调用链路散落裸 ORM `insert/update/commit`。 -- [ ] 数据库结构改动仅通过 Alembic migration。 -- [ ] API 统一包络:`{code, message, data}`。 -- [ ] 后端契约变更必须同步前端 TS 类型与文档。 -- [ ] 删除任何代码前必须执行 `FIND_USAGE` 并留证据。 -- [ ] 发现违例立即停止并输出 `[VIOLATION: 裸写位置]`。 -- [ ] 模型选择必须接口化配置,禁止在业务代码写死 `provider/model/api_env`。 +- [x] 所有业务写操作必须通过 service/repository 入口调用。 +- [x] 严禁在调用链路散落裸 ORM `insert/update/commit`。 +- [x] 数据库结构改动仅通过 Alembic migration。 +- [x] API 统一包络:`{code, message, data}`。 +- [x] 后端契约变更必须同步前端 TS 类型与文档。 +- [x] 删除任何代码前必须执行 `FIND_USAGE` 并留证据。 +- [x] 发现违例立即停止并输出 `[VIOLATION: 裸写位置]`。 +- [x] 模型选择必须接口化配置,禁止在业务代码写死 `provider/model/api_env`。 ## 1. 执行总览(里程碑) -- [ ] Step 0:基线文档与自动增量机制 -- [ ] Step 1:Signal Planner 数据层改造 -- [ ] Step 2:写入服务收口(StrategicAnalysis + Outbox) -- [ ] Step 3:SignalPlannerService 接入 -- [ ] Step 4:主链路触发与路由配置 -- [ ] Step 5:模型灵活配置(接口化,禁止写死) -- [ ] Step 6:前端展示与侧边栏新增功能 -- [ ] Step 7:脏代码/无用代码删减 +- [x] Step 0:基线文档与自动增量机制 +- [x] Step 1:Signal Planner 数据层改造 +- [x] Step 2:写入服务收口(StrategicAnalysis + Outbox) +- [x] Step 3:SignalPlannerService 接入 +- [x] Step 4:主链路触发与路由配置 +- [x] Step 5:模型灵活配置(接口化,禁止写死) +- [x] Step 6:前端展示与侧边栏新增功能 +- [x] Step 7:脏代码/无用代码删减 - [ ] Step 8:验收与交付 --- ## Step 0:基线文档与自动增量机制 ### 入口条件 -- [ ] 项目当前主分支可读,文档目录可写。 +- [x] 项目当前主分支可读,文档目录可写。 ### 执行项 -- [ ] 新建 `docs/architecture/MASTER_CONTRACT.md` -- [ ] 新建 `docs/architecture/API_CONTRACTS.md` -- [ ] 新建 `docs/architecture/DATAFLOW.md` -- [ ] 新建 `docs/architecture/TASK_BRIEF_TEMPLATE.md` -- [ ] 新建 `scripts/gen_task_brief.sh`(自动生成增量任务包) -- [ ] 新建 `scripts/check_contract_sync.sh`(契约同步检查) -- [ ] CI 增加 contract-check job +- [x] 新建 `docs/architecture/MASTER_CONTRACT.md` +- [x] 新建 `docs/architecture/API_CONTRACTS.md` +- [x] 新建 `docs/architecture/DATAFLOW.md` +- [x] 新建 `docs/architecture/TASK_BRIEF_TEMPLATE.md` +- [x] 新建 `scripts/gen_task_brief.sh`(自动生成增量任务包) +- [x] 新建 `scripts/check_contract_sync.sh`(契约同步检查) +- [x] CI 增加 contract-check job ### 完成标志 -- [ ] 本地可自动生成一份有效增量任务包 -- [ ] CI 可检测契约文档未同步并正确失败 +- [x] 本地可自动生成一份有效增量任务包 +- [x] CI 可检测契约文档未同步并正确失败 --- ## Step 1:Signal Planner 数据层改造 ### 入口条件 -- [ ] Step 0 完成 +- [x] Step 0 完成 ### 执行项 -- [ ] 新增 migration:`signals.planner_status VARCHAR(20) DEFAULT 'pending'` -- [ ] 新增 migration:`signals.planner_analyzed_at TIMESTAMP` -- [ ] 新增 migration:`signals.planner_assignee_user_id INTEGER` -- [ ] 为 `planner_status` 增加索引 -- [ ] 同步更新 `backend/app/models/signal.py` +- [x] 新增 migration:`signals.planner_status VARCHAR(20) DEFAULT 'pending'` +- [x] 新增 migration:`signals.planner_analyzed_at TIMESTAMP` +- [x] 新增 migration:`signals.planner_assignee_user_id INTEGER` +- [x] 为 `planner_status` 增加索引 +- [x] 同步更新 `backend/app/models/signal.py` ### 完成标志 -- [ ] `alembic upgrade` 后表结构正确 -- [ ] ORM 模型可读写三字段 +- [x] `alembic upgrade` 后表结构正确 +- [x] ORM 模型可读写三字段 --- ## Step 2:写入服务收口 ### 入口条件 -- [ ] Step 1 完成 +- [x] Step 1 完成 ### 执行项 -- [ ] 新建 `backend/app/services/signal_strategic_analysis_service.py` -- [ ] 实现 `merge_and_save(db, signal_id, planner_result, tenant_id)` -- [ ] 实现 `mark_failed(db, signal_id, tenant_id, reason)` -- [ ] 新建 `backend/app/services/feishu_outbox_service.py` -- [ ] 实现 `enqueue(db, chat_id, text, source_message_id, dedupe_seed)` -- [ ] 幂等策略复用 `reply_hash`,不新增 outbox 冗余字段 +- [x] 新建 `backend/app/services/signal_strategic_analysis_service.py` +- [x] 实现 `merge_and_save(db, signal_id, planner_result, tenant_id)` +- [x] 实现 `mark_failed(db, signal_id, tenant_id, reason)` +- [x] 新建 `backend/app/services/feishu_outbox_service.py` +- [x] 实现 `enqueue(db, chat_id, text, source_message_id, dedupe_seed)` +- [x] 幂等策略复用 `reply_hash`,不新增 outbox 冗余字段 ### 完成标志 -- [ ] 新增 service 覆盖 Planner 写回与补发入队 -- [ ] 新增 service 文件中无业务外层裸写调用 +- [x] 新增 service 覆盖 Planner 写回与补发入队 +- [x] 新增 service 文件中无业务外层裸写调用 --- ## Step 3:SignalPlannerService 接入 ### 入口条件 -- [ ] Step 2 完成 +- [x] Step 2 完成 ### 执行项 -- [ ] 新建 `backend/app/services/signal_planner_service.py` -- [ ] `analyze(...)` 内部使用独立 `AsyncSessionLocal` -- [ ] 上下文查询使用现网字段:`signals.matched_project_id` -- [ ] 成员查询通过 `user_projects -> users -> user_workloads -> user_skill_bindings -> skill_tags` -- [ ] 调用 `llm_service.generate_structured_object(function_name="signal_planner_analyze")` -- [ ] 成功路径调用 `SignalStrategicAnalysisService.merge_and_save` -- [ ] 失败路径调用 `SignalStrategicAnalysisService.mark_failed` -- [ ] 补发路径调用 `FeishuOutboxService.enqueue` +- [x] 新建 `backend/app/services/signal_planner_service.py` +- [x] `analyze(...)` 内部使用独立 `AsyncSessionLocal` +- [x] 上下文查询使用现网字段:`signals.matched_project_id` +- [x] 成员查询通过 `user_projects -> users -> user_workloads -> user_skill_bindings -> skill_tags` +- [x] 调用 `llm_service.generate_structured_object(function_name="signal_planner_analyze")` +- [x] 成功路径调用 `SignalStrategicAnalysisService.merge_and_save` +- [x] 失败路径调用 `SignalStrategicAnalysisService.mark_failed` +- [x] 补发路径调用 `FeishuOutboxService.enqueue` ### 完成标志 -- [ ] Planner 失败不阻断主链路 -- [ ] Planner 成功后 `strategic_analysis.ai_planner` 写回成功 +- [x] Planner 失败不阻断主链路 +- [x] Planner 成功后 `strategic_analysis.ai_planner` 写回成功 --- ## Step 4:主链路触发与路由配置 ### 入口条件 -- [ ] Step 3 完成 +- [x] Step 3 完成 ### 执行项 -- [ ] 修改 `backend/app/services/ai_pmo_kernel_service.py` -- [ ] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))` -- [ ] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER` -- [ ] 在 LLM 结构化 schema 注册 `signal_planner_analyze` -- [ ] 更新 `/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由 +- [x] 修改 `backend/app/services/ai_pmo_kernel_service.py` +- [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))` +- [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER` +- [x] 在 LLM 结构化 schema 注册 `signal_planner_analyze` +- [x] 更新 `/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由 ### 完成标志 -- [ ] 触发点仅在 commit 后 -- [ ] 路由命中 `signal_planner_analyze` +- [x] 触发点仅在 commit 后 +- [x] 路由命中 `signal_planner_analyze` --- ## Step 5:模型灵活配置(接口化,禁止写死) ### 入口条件 -- [ ] Step 4 完成 +- [x] Step 4 完成 ### 执行项 -- [ ] 新建后端配置接口:`backend/app/api/model_routing.py` -- [ ] 新建后端服务:`backend/app/services/model_routing_service.py` -- [ ] 提供查询接口:按 `function_name` 读取当前生效模型路由 -- [ ] 提供更新接口:按 `function_name` 更新 `provider/model/api_env/tier` -- [ ] 配置持久化到统一配置源(数据库 Integration 或统一配置表) -- [ ] `SignalPlannerService` 改为运行时读取 `signal_planner_analyze` 路由配置,不在代码中写死模型 -- [ ] 保留默认兜底:当配置缺失时走安全默认路由,但记录告警日志 -- [ ] 增加权限控制:仅管理员可修改模型路由配置 -- [ ] 增加审计:每次配置变更写 `operation_log` +- [x] 新建后端配置接口:`backend/app/api/model_routing.py` +- [x] 新建后端服务:`backend/app/services/model_routing_service.py` +- [x] 提供查询接口:按 `function_name` 读取当前生效模型路由 +- [x] 提供更新接口:按 `function_name` 更新 `provider/model/api_env/tier` +- [x] 配置持久化到统一配置源(数据库 Integration 或统一配置表) +- [x] `SignalPlannerService` 改为运行时读取 `signal_planner_analyze` 路由配置,不在代码中写死模型 +- [x] 保留默认兜底:当配置缺失时走安全默认路由,但记录告警日志 +- [x] 增加权限控制:仅管理员可修改模型路由配置 +- [x] 增加审计:每次配置变更写 `operation_log` ### 完成标志 -- [ ] 通过接口切换模型后,Planner 无需改代码即可生效 -- [ ] 业务代码中不存在写死的 `signal_planner_analyze -> MiniMax-M2.5` 绑定 +- [x] 通过接口切换模型后,Planner 无需改代码即可生效 +- [x] 业务代码中不存在写死的 `signal_planner_analyze -> MiniMax-M2.5` 绑定 --- ## Step 6:前端展示与侧边栏新增功能 ### 入口条件 -- [ ] Step 5 完成 +- [x] Step 5 完成 ### 执行项(Signal 展示) -- [ ] 修改 `backend/app/api/sstc.py` 返回 planner 字段与 `strategic_analysis` -- [ ] 修改 `frontend/src/api/sstc.ts` 扩展 `SSTCSignal` 类型 -- [ ] 修改 `frontend/src/components/sstc/SignalContentHoverCard.tsx` -- [ ] `planner_status=pending` 显示“AI 分析中...” -- [ ] `planner_status=completed` 显示分析区块 -- [ ] `planner_status=failed` 不显示分析区块 +- [x] 修改 `backend/app/api/sstc.py` 返回 planner 字段与 `strategic_analysis` +- [x] 修改 `frontend/src/api/sstc.ts` 扩展 `SSTCSignal` 类型 +- [x] 修改 `frontend/src/components/sstc/SignalContentHoverCard.tsx` +- [x] `planner_status=pending` 显示“AI 分析中...” +- [x] `planner_status=completed` 显示分析区块 +- [x] `planner_status=failed` 不显示分析区块 ### 执行项(侧边栏任务包管理) -- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`增量任务包中心` -- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包模板管理` -- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包生成记录` -- [ ] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`契约同步看板` -- [ ] 新建页面 `frontend/src/pages/TaskBriefCenterPage.tsx` -- [ ] 新建页面 `frontend/src/pages/TaskBriefTemplatePage.tsx` -- [ ] 新建页面 `frontend/src/pages/TaskBriefRunsPage.tsx` -- [ ] 新建页面 `frontend/src/pages/ContractSyncBoardPage.tsx` -- [ ] 修改 `frontend/src/router/index.tsx` 注册新路由 -- [ ] 新建 `frontend/src/api/taskBriefs.ts` -- [ ] 新建后端 `backend/app/api/task_briefs.py` -- [ ] 新建后端 `backend/app/services/task_brief_service.py` -- [ ] 新增模型路由管理入口:`模型路由配置` -- [ ] 新建页面 `frontend/src/pages/ModelRoutingConfigPage.tsx` -- [ ] 新建前端 API `frontend/src/api/modelRouting.ts` -- [ ] 页面通过接口读取/更新 `signal_planner_analyze` 模型映射 +- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`增量任务包中心` +- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包模板管理` +- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`任务包生成记录` +- [x] 修改 `frontend/src/components/layout/Sidebar.tsx` 增加入口:`契约同步看板` +- [x] 新建页面 `frontend/src/pages/TaskBriefCenterPage.tsx` +- [x] 新建页面 `frontend/src/pages/TaskBriefTemplatePage.tsx` +- [x] 新建页面 `frontend/src/pages/TaskBriefRunsPage.tsx` +- [x] 新建页面 `frontend/src/pages/ContractSyncBoardPage.tsx` +- [x] 修改 `frontend/src/router/index.tsx` 注册新路由 +- [x] 新建 `frontend/src/api/taskBriefs.ts` +- [x] 新建后端 `backend/app/api/task_briefs.py` +- [x] 新建后端 `backend/app/services/task_brief_service.py` +- [x] 新增模型路由管理入口:`模型路由配置` +- [x] 新建页面 `frontend/src/pages/ModelRoutingConfigPage.tsx` +- [x] 新建前端 API `frontend/src/api/modelRouting.ts` +- [x] 页面通过接口读取/更新 `signal_planner_analyze` 模型映射 ### 完成标志 -- [ ] 侧边栏 4 个新入口均可访问 -- [ ] 任务包管理基础流程可跑通 -- [ ] 模型路由可在前端页面配置并即时生效 +- [x] 侧边栏 4 个新入口均可访问 +- [x] 任务包管理基础流程可跑通 +- [x] 模型路由可在前端页面配置并即时生效 --- ## Step 7:脏代码/无用代码删减(强制) ### 入口条件 -- [ ] Step 6 完成 +- [x] Step 6 完成 ### 执行项 -- [ ] 清理未使用 imports/variables/functions/components/routes -- [ ] 清理重复工具函数与废弃 fallback 分支 -- [ ] 删除无效 feature flag 与历史临时代码 -- [ ] 清理无引用 API 封装与页面入口 -- [ ] 每一处删除前后均有 `FIND_USAGE` 结果留档 +- [x] 清理未使用 imports/variables/functions/components/routes +- [x] 清理重复工具函数与废弃 fallback 分支 +- [x] 删除无效 feature flag 与历史临时代码 +- [x] 清理无引用 API 封装与页面入口 +- [x] 每一处删除前后均有 `FIND_USAGE` 结果留档 ### 门禁 -- [ ] PR 附“删除清单 + 引用扫描结果” -- [ ] 删除后构建与回归通过,否则回退该删除批次 +- [x] PR 附“删除清单 + 引用扫描结果” +- [x] 删除后构建与回归通过,否则回退该删除批次 ### 完成标志 -- [ ] 无死代码残留在本次改动链路 -- [ ] 无跨域误删 +- [x] 无死代码残留在本次改动链路 +- [x] 无跨域误删 --- ## Step 8:验收与交付 ### 入口条件 -- [ ] Step 7 完成 +- [x] Step 7 完成 ### 功能验收 - [ ] 飞书触发后信号主链路正常入库 -- [ ] 30 秒内 `planner_status=completed` -- [ ] `strategic_analysis.ai_planner` 非空 -- [ ] 相同消息补发不重复入队 -- [ ] 通过模型路由接口切换模型后,Planner 走新模型且结果正常 +- [x] 30 秒内 `planner_status=completed` +- [x] `strategic_analysis.ai_planner` 非空 +- [x] 相同消息补发不重复入队 +- [x] 通过模型路由接口切换模型后,Planner 走新模型且结果正常 ### 数据库只读验收 -- [ ] 执行: - - `SELECT planner_status, planner_analyzed_at, strategic_analysis->'ai_planner' FROM signals ORDER BY created_at DESC LIMIT 1;` -- [ ] 三项均符合预期 +- [x] 执行: + - `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` 通过 -- [ ] `npm run build` 通过 -- [ ] `tsc --noEmit` 零错误 +- [x] `python -m py_compile` 通过 +- [x] `npm run build` 通过 +- [x] `tsc --noEmit` 零错误 ### 文档验收 -- [ ] `MASTER_CONTRACT.md` 同步 -- [ ] `API_CONTRACTS.md` 同步 -- [ ] `DATAFLOW.md` 同步 -- [ ] `CLAUDE.md` 同步 +- [x] `MASTER_CONTRACT.md` 同步 +- [x] `API_CONTRACTS.md` 同步 +- [x] `DATAFLOW.md` 同步 +- [x] `CLAUDE.md` 同步 ### 交付物 -- [ ] 变更文件清单 +- [x] 变更文件清单 - [ ] 飞书补发记录截图 - [ ] 数据库查询截图 -- [ ] 侧边栏新增功能截图 -- [ ] 模型路由接口调用与生效截图 -- [ ] 脏代码删减清单与 `FIND_USAGE` 证据 +- [x] 侧边栏新增功能截图 +- [x] 模型路由接口调用与生效截图 +- [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: 当前任务未接入飞书真实回调流量,`飞书补发记录截图` 与 `数据库查询截图` 需人工在目标环境补齐] diff --git a/docs/architecture/API_CONTRACTS.md b/docs/architecture/API_CONTRACTS.md new file mode 100644 index 00000000..dad75d56 --- /dev/null +++ b/docs/architecture/API_CONTRACTS.md @@ -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 diff --git a/docs/architecture/DATAFLOW.md b/docs/architecture/DATAFLOW.md new file mode 100644 index 00000000..fe701d75 --- /dev/null +++ b/docs/architecture/DATAFLOW.md @@ -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. diff --git a/docs/architecture/MASTER_CONTRACT.md b/docs/architecture/MASTER_CONTRACT.md new file mode 100644 index 00000000..4aa776b7 --- /dev/null +++ b/docs/architecture/MASTER_CONTRACT.md @@ -0,0 +1,38 @@ +# MASTER CONTRACT + +## 1. Unified Response Envelope +- Success: `{ "code": 0, "message": "success", "data": {...} }` +- Failure: `{ "code": , "message": "", "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 diff --git a/docs/architecture/TASK_BRIEF_TEMPLATE.md b/docs/architecture/TASK_BRIEF_TEMPLATE.md new file mode 100644 index 00000000..70ec210b --- /dev/null +++ b/docs/architecture/TASK_BRIEF_TEMPLATE.md @@ -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 diff --git a/docs/task_briefs/model_routing_override_effective_20260312.png b/docs/task_briefs/model_routing_override_effective_20260312.png new file mode 100644 index 00000000..74964f6e Binary files /dev/null and b/docs/task_briefs/model_routing_override_effective_20260312.png differ diff --git a/docs/task_briefs/sidebar_new_entries_20260312.png b/docs/task_briefs/sidebar_new_entries_20260312.png new file mode 100644 index 00000000..68096dae Binary files /dev/null and b/docs/task_briefs/sidebar_new_entries_20260312.png differ diff --git a/docs/task_briefs/task_brief_20260312_182557.md b/docs/task_briefs/task_brief_20260312_182557.md new file mode 100644 index 00000000..8f56fbec --- /dev/null +++ b/docs/task_briefs/task_brief_20260312_182557.md @@ -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 diff --git a/frontend/src/api/modelRouting.ts b/frontend/src/api/modelRouting.ts new file mode 100644 index 00000000..1a938448 --- /dev/null +++ b/frontend/src/api/modelRouting.ts @@ -0,0 +1,45 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).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 { + const { data } = await api.get>("/model-routing/functions"); + return unwrap(data).items || []; + }, + + async updateFunction(functionName: string, payload: ModelRoutingUpdateRequest): Promise { + const { data } = await api.put>( + `/model-routing/functions/${functionName}`, + payload + ); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/sstc.ts b/frontend/src/api/sstc.ts index 50739d9e..3c9f9a75 100644 --- a/frontend/src/api/sstc.ts +++ b/frontend/src/api/sstc.ts @@ -65,6 +65,10 @@ export interface SSTCSignal { created_at?: string | null; charter_name?: string | null; attachments?: SignalAttachment[]; + strategic_analysis?: Record; + planner_status?: "pending" | "completed" | "failed"; + planner_analyzed_at?: string | null; + planner_assignee_user_id?: string | null; } export interface SSTCSignalListPayload { diff --git a/frontend/src/api/taskBriefs.ts b/frontend/src/api/taskBriefs.ts new file mode 100644 index 00000000..97085963 --- /dev/null +++ b/frontend/src/api/taskBriefs.ts @@ -0,0 +1,120 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).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 { + const { data } = await api.get>("/task-briefs/center"); + return unwrap(data).items || []; + }, + + async listTemplates(): Promise { + const { data } = await api.get>("/task-briefs/templates"); + return unwrap(data).items || []; + }, + + async createTemplate(payload: CreateTemplateRequest): Promise { + const { data } = await api.post>("/task-briefs/templates", payload); + return unwrap(data); + }, + + async updateTemplate(templateId: string, payload: UpdateTemplateRequest): Promise { + const { data } = await api.put>( + `/task-briefs/templates/${templateId}`, + payload + ); + return unwrap(data); + }, + + async listRuns(): Promise { + const { data } = await api.get>("/task-briefs/runs"); + return unwrap(data).items || []; + }, + + async generate(payload: GenerateTaskBriefRequest): Promise<{ run: TaskBriefRun; brief: TaskBriefCard }> { + const { data } = await api.post>( + "/task-briefs/generate", + payload + ); + return unwrap(data); + }, + + async getContractSyncStatus(): Promise { + const { data } = await api.get>("/task-briefs/contract-sync"); + return unwrap(data); + }, +}; diff --git a/frontend/src/components/ai-config/ConfigSubpageHeader.tsx b/frontend/src/components/ai-config/ConfigSubpageHeader.tsx new file mode 100644 index 00000000..ca256a33 --- /dev/null +++ b/frontend/src/components/ai-config/ConfigSubpageHeader.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type Tone = "violet" | "blue" | "emerald" | "amber" | "slate"; + +const toneMap: Record = { + 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 ( +
+ + + +
+
+ + {icon} + +
+
+ 智能配置 · 治理工具 +
+

{title}

+

{description}

+
+
+ {meta ? ( +
+ {meta} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 9050897f..4b7d9755 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -13,7 +13,7 @@ export default function Layout() { }, [location.pathname, location.search]); return ( -
+
setMobileSidebarOpen(false)} /> {mobileSidebarOpen && (
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index efeb29f3..4f7e04ec 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { NavLink, useLocation } from "react-router-dom"; import { LayoutDashboard, FolderKanban, @@ -9,6 +9,11 @@ import { Users, Sparkles, Sliders, + Files, + LayoutTemplate, + History, + ShieldCheck, + Settings2, X, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -26,33 +31,43 @@ const baseNavItems = [ { to: "/skills", label: "能力管理", icon: BarChart3 }, ]; +const configToolItems = [ + { to: "/ai-config/task-brief-center", label: "增量任务包中心", icon: Files }, + { to: "/ai-config/task-brief-templates", label: "任务包模板管理", icon: LayoutTemplate }, + { to: "/ai-config/task-brief-runs", label: "任务包生成记录", icon: History }, + { to: "/ai-config/contract-sync-board", label: "契约同步看板", icon: ShieldCheck }, + { to: "/ai-config/model-routing-config", label: "模型路由配置", icon: Settings2 }, +]; + interface SidebarProps { open: boolean; onClose: () => void; } export function Sidebar({ open, onClose }: SidebarProps) { + const location = useLocation(); const { theme, toggleTheme } = useTheme(); const user = useAuthStore((s) => s.user); const isManager = isManagerRole(user?.role); const navItems = isManager ? [...baseNavItems, { to: "/users", label: "用户管理", icon: Users }] : baseNavItems; + const showConfigTools = location.pathname.startsWith("/ai-config"); return (