From 4da49c7fc4afc3e8e4052d1cef9caecaa15de22a Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Thu, 26 Mar 2026 10:50:16 +0800 Subject: [PATCH] Add unified feedback center and task learning loop --- CLAUDE.md | 12 +- ...60326_add_task_breakdown_payload_fields.py | 61 ++++ .../app/api/sstc_api/tasks_ai_breakdown.py | 153 +++++++- .../app/api/sstc_api/tasks_board_status.py | 7 +- .../api/sstc_api/tasks_recommend_optimize.py | 3 +- .../app/api/sstc_api/tasks_update_delete.py | 15 +- backend/app/api/tasks.py | 12 +- .../app/config/ai_prompts_catalog/part_01.py | 34 +- .../app/config/ai_prompts_catalog/part_03.py | 15 +- backend/app/models/task.py | 2 + backend/app/schemas/__init__.py | 10 +- backend/app/schemas/common.py | 54 ++- .../mixin_structured_candidates.py | 130 +++++++ .../services/llm_service/shared_part_01.py | 20 ++ .../app/services/skill/workload_recommend.py | 39 ++- backend/app/services/task_breakdown.py | 83 +++++ .../services/task_persistence_core/models.py | 2 + .../task_persistence_core/normalize.py | 4 + .../services/task_persistence_core/persist.py | 4 + .../test_sstc_tasks_ai_breakdown_contract.py | 329 +++++++++++++++++- frontend/src/api/sstc.ts | 58 ++- .../sstc/SSTCExecutionBoardPanel.tsx | 182 +++++++--- .../components/sstc/SSTCTaskStagePanel.tsx | 114 ++++-- 23 files changed, 1207 insertions(+), 136 deletions(-) create mode 100644 backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py create mode 100644 backend/app/services/task_breakdown.py diff --git a/CLAUDE.md b/CLAUDE.md index cff49a11..db330c0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,13 +103,15 @@ backend/ - 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务) - 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit) - 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) +- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断) - 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约) - 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单) - 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段) -- 更新:`backend/app/api/tasks.py`(`/tasks/breakdown` 支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一) +- 更新:`backend/app/api/tasks.py`(`/tasks/breakdown` 统一结构化任务业务对象输出,支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一) - 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`) - 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper) -- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`PROMPT_DECOMPOSE_TASKS` 统一读取 `request/project/charter/modules` 结构化上下文) +- 更新:`backend/app/config/ai_prompts_catalog/part_01.py`(`PROMPT_TASK_BREAKDOWN` 统一为结构化任务业务对象输出) +- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`PROMPT_DECOMPOSE_TASKS` 统一读取 `request/project/charter/modules` 结构化上下文,并输出 `task_key/title/required_skill_names/required_skill_tag_ids/depends_on_task_keys/risk/confidence/evidence_refs`) - 新增:`backend/tests/test_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出) - 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传) - 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派) @@ -134,6 +136,7 @@ backend/ - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) - 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) +- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -492,9 +495,12 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要 - AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 - AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块 +- 通用任务拆解接口(`POST /tasks/breakdown`)统一返回结构化任务业务对象,支持 `project_id/charter_id` 上下文与 Plane 同步 - SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 - SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间” -- SSTC AI 拆解接口(`POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown`)执行任务标题去重(同次 + 历史任务),返回 `skipped_duplicates` 明确跳过计数 +- SSTC AI 拆解接口(`POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown`)执行任务标题去重(同次 + 历史任务),返回 `skipped_duplicates` / `auto_assigned_count`,并落 `ai_breakdown_payload/manual_overrides` +- SSTC 负责人推荐接口(`POST /projects/{project_id}/tasks/{task_id}/recommend-assignee`)直接消费 `required_skill_tag_ids` 语义,不再把技能 ID 当字符串 +- SSTC 任务更新接口(`PATCH /projects/{project_id}/tasks/{task_id}`)会把人工修改写入 `manual_overrides`,供后续 AI 重跑识别覆盖层 - SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids` - 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution` - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` diff --git a/backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py b/backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py new file mode 100644 index 00000000..d953ef1b --- /dev/null +++ b/backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py @@ -0,0 +1,61 @@ +"""add task breakdown payload fields + +Revision ID: 20260326_add_task_breakdown_payload_fields +Revises: add_project_create_idempotency_indexes +Create Date: 2026-03-26 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +revision = "20260326_add_task_breakdown_payload_fields" +down_revision = "add_project_create_idempotency_indexes" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +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 + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "tasks" + if not _table_exists(bind, table_name): + return + + if not _column_exists(bind, table_name, "ai_breakdown_payload"): + op.add_column(table_name, sa.Column("ai_breakdown_payload", _json_type(bind), nullable=True)) + if not _column_exists(bind, table_name, "manual_overrides"): + op.add_column(table_name, sa.Column("manual_overrides", _json_type(bind), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "tasks" + if not _table_exists(bind, table_name): + return + + if _column_exists(bind, table_name, "manual_overrides"): + op.drop_column(table_name, "manual_overrides") + if _column_exists(bind, table_name, "ai_breakdown_payload"): + op.drop_column(table_name, "ai_breakdown_payload") diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py index 7be1b9f8..76fd5391 100644 --- a/backend/app/api/sstc_api/tasks_ai_breakdown.py +++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from .shared import * @@ -12,10 +13,20 @@ from ...services.sstc.charter_data import ( from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project from ...services.sstc.governance import _assert_charter_aligned, _require_manager from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async +from ...services.context_cache_service import context_cache_service +from ...services.task_breakdown import ( + build_task_breakdown_ai_payload, + normalize_task_breakdown_payload, + resolve_task_breakdown_skill_tag_ids, + should_auto_assign_breakdown_task, +) router = APIRouter(tags=["sstc"]) _TITLE_NORMALIZE_RE = re.compile(r"[\s\-_]+") +_AUTO_ASSIGN_CONFIDENCE_THRESHOLD = 0.85 +_AUTO_ASSIGN_MATCH_SCORE_THRESHOLD = 75.0 +_AUTO_ASSIGN_WORKLOAD_THRESHOLD = 70.0 def _task_title_key(value: str) -> str: @@ -48,25 +59,28 @@ async def ai_breakdown_tasks( available_skills = await _load_available_skill_names(db, tenant_id=tenant_id) prompt = _build_task_breakdown_prompt( project_id=pid, - charter_context=charter_context, + context=charter_context, available_skills=available_skills, ) try: - raw_text = await llm_service.generate( + structured = await llm_service.generate_structured_object( prompt, db=db, tenant_id=tenant_id, project_id=pid, function_name="sstc_ai_breakdown", ) - rows = llm_service._parse_json_array(raw_text) - if not rows: - raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组") + if not structured.payload: + raise ValueError("AI 拆解返回空结果") + payload = normalize_task_breakdown_payload(structured.payload) except RuntimeError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc + if not payload.tasks: + raise HTTPException(status_code=500, detail="AI 拆解返回空结果") + existing_task_title_result = await db.execute( select(Task.title).where( and_( @@ -82,13 +96,36 @@ async def ai_breakdown_tasks( if str(title or "").strip() } seen_title_keys = set(existing_title_keys) + ai_breakdown_payload = build_task_breakdown_ai_payload(payload) + ai_breakdown_payload["source_context"] = charter_context created = [] skipped_duplicates = 0 + auto_assigned_count = 0 prompt_tokens = max(len(prompt) // 4, 1) - completion_tokens = max(len(raw_text) // 4, 0) - for item in rows or []: - title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) + completion_tokens = max(len(structured.raw_text or json.dumps(payload.model_dump(mode="json"), ensure_ascii=False)) // 4, 0) + project_user_ids_result = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == pid, + ) + ) + ) + project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()} + active_users_result = await db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + active_user_ids = {int(uid) for uid in active_users_result.scalars().all()} + + for item in payload.tasks: + title = compact_title((item.title or ""), max_len=32) if not title: continue title_key = _task_title_key(title) @@ -99,8 +136,58 @@ async def ai_breakdown_tasks( continue seen_title_keys.add(title_key) - priority = str(item.get("priority") or "P1").lower() - mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" + resolved_skill_tag_ids, _skill_resolution = await resolve_task_breakdown_skill_tag_ids( + db, + tenant_id=tenant_id, + task=item, + ) + required_skills = list(resolved_skill_tag_ids or item.required_skill_tag_ids or item.required_skill_names) + recommendations_payload = await recommend_personnel( + db=db, + tenant_id=tenant_id, + required_skills=required_skills, + task_description=item.description or item.title, + task_id=str(item.task_key), + limit=10, + ) + + filtered_recommendations = [] + for rec in recommendations_payload.get("recommendations", []): + try: + uid = int(rec.get("user_id")) + except (TypeError, ValueError): + continue + if uid in project_user_ids and uid in active_user_ids: + filtered_recommendations.append(rec) + recommendations_payload["recommendations"] = filtered_recommendations + recommendations_payload["total_count"] = len(filtered_recommendations) + + top_rec = filtered_recommendations[0] if filtered_recommendations else None + assignee_id = None + if top_rec is not None: + try: + top_match_score = float(top_rec.get("match_score") or 0) + except (TypeError, ValueError): + top_match_score = 0.0 + try: + top_workload_rate = float(top_rec.get("workload_rate") or 0) + except (TypeError, ValueError): + top_workload_rate = 100.0 + if should_auto_assign_breakdown_task( + task=item, + top_match_score=top_match_score, + top_workload_rate=top_workload_rate, + confidence_threshold=_AUTO_ASSIGN_CONFIDENCE_THRESHOLD, + match_score_threshold=_AUTO_ASSIGN_MATCH_SCORE_THRESHOLD, + workload_threshold=_AUTO_ASSIGN_WORKLOAD_THRESHOLD, + ): + try: + assignee_id = int(top_rec.get("user_id")) + except (TypeError, ValueError): + assignee_id = None + + task_ai_breakdown_payload = dict(ai_breakdown_payload) + task_ai_breakdown_payload["task"] = item.model_dump(mode="json") task = await persist_task( db, tenant_id=tenant_id, @@ -108,11 +195,15 @@ async def ai_breakdown_tasks( project_id=pid, draft=build_task_create_draft( title=title, - description=item.get("description"), - required_skill_tag_ids=[], - priority=mapped_priority, + description=item.description, + assignee_id=assignee_id, + required_skill_tag_ids=resolved_skill_tag_ids or item.required_skill_tag_ids, + ai_breakdown_payload=task_ai_breakdown_payload, + manual_overrides={}, + priority=item.priority or "medium", status="todo", - estimated_hours=float(item.get("estimated_hours") or 0) or None, + estimated_hours=float(item.estimated_hours or 0) or None, + ai_recommended_assignees=recommendations_payload, ), actor_user_id=int(current_user.id), action="task.create", @@ -121,6 +212,8 @@ async def ai_breakdown_tasks( refresh_workload=False, ) created.append(task) + if assignee_id is not None: + auto_assigned_count += 1 db.add( AICallLog( @@ -128,7 +221,7 @@ async def ai_breakdown_tasks( user_id=int(current_user.id), function_name="sstc_ai_breakdown", prompt=prompt, - response=f"created_count={len(created)},skipped_duplicates={skipped_duplicates}", + response=f"created_count={len(created)},skipped_duplicates={skipped_duplicates},auto_assigned_count={auto_assigned_count}", tokens_used=prompt_tokens + completion_tokens, cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), status="success", @@ -142,9 +235,18 @@ async def ai_breakdown_tasks( entity_type="task", entity_id=str(cid), action="ai_breakdown", - after_data={"created_count": len(created), "skipped_duplicates": skipped_duplicates}, + after_data={ + "created_count": len(created), + "skipped_duplicates": skipped_duplicates, + "auto_assigned_count": auto_assigned_count, + }, ) await db.commit() + assigned_user_ids = { + int(task.assignee_id) + for task in created + if task.assignee_id is not None + } for task in created: _sync_pmflow_rag_async( tenant_id=tenant_id, @@ -154,13 +256,30 @@ async def ai_breakdown_tasks( title=task.title, content=task.description or "", ) + for user_id in assigned_user_ids: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=user_id, + ) await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return success_response( { "created_count": len(created), "skipped_duplicates": skipped_duplicates, + "auto_assigned_count": auto_assigned_count, "items": [ - {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} + { + "id": str(t.id), + "title": t.title, + "priority": t.priority, + "status": t.status, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + "required_skill_tag_ids": list(t.required_skill_tag_ids or []), + "ai_breakdown_payload": t.ai_breakdown_payload, + "manual_overrides": t.manual_overrides, + "auto_assigned": bool(t.assignee_id) and not bool((t.manual_overrides or {}).get("assignee_id")), + } for t in created ], } diff --git a/backend/app/api/sstc_api/tasks_board_status.py b/backend/app/api/sstc_api/tasks_board_status.py index c9a2e408..e94e53d7 100644 --- a/backend/app/api/sstc_api/tasks_board_status.py +++ b/backend/app/api/sstc_api/tasks_board_status.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from ...services.context_cache_service import context_cache_service from ...services.sstc.entity_lookup import _ensure_project, _ensure_task from ...services.sstc.governance import _is_manager from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async @@ -52,6 +53,11 @@ async def get_task_board( "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, "risk_count": risk_count, "truth_node_id": str(t.truth_node_id), + "required_skill_tag_ids": list(t.required_skill_tag_ids or []), + "ai_recommended_assignees": t.ai_recommended_assignees, + "ai_breakdown_payload": t.ai_breakdown_payload, + "manual_overrides": t.manual_overrides, + "auto_assigned": bool(t.assignee_id) and not bool((t.manual_overrides or {}).get("assignee_id")), } ) return success_response(rows) @@ -123,4 +129,3 @@ async def patch_task_status( ) return success_response({"id": str(task.id), "status": task.status}) - diff --git a/backend/app/api/sstc_api/tasks_recommend_optimize.py b/backend/app/api/sstc_api/tasks_recommend_optimize.py index 3339077d..a36d5750 100644 --- a/backend/app/api/sstc_api/tasks_recommend_optimize.py +++ b/backend/app/api/sstc_api/tasks_recommend_optimize.py @@ -23,7 +23,7 @@ async def recommend_assignee_for_task( await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) - required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] + required_skills = list(task.required_skill_tag_ids or []) recommendations = await recommend_personnel( db=db, tenant_id=tenant_id, @@ -128,4 +128,3 @@ async def optimize_task_workload( "mode": body.mode, } ) - diff --git a/backend/app/api/sstc_api/tasks_update_delete.py b/backend/app/api/sstc_api/tasks_update_delete.py index 31feeffb..dbdd824b 100644 --- a/backend/app/api/sstc_api/tasks_update_delete.py +++ b/backend/app/api/sstc_api/tasks_update_delete.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from ...services.context_cache_service import context_cache_service from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date from ...services.sstc.governance import _require_manager from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async @@ -41,23 +42,34 @@ async def patch_task( "priority": task.priority, "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, "due_date": task.due_date.isoformat() if task.due_date else None, + "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "manual_overrides": task.manual_overrides, } old_assignee_id = int(task.assignee_id) if task.assignee_id else None + manual_overrides = dict(task.manual_overrides or {}) if body.title is not None: task.title = compact_title(body.title, max_len=32) + manual_overrides["title"] = task.title if body.description is not None: task.description = body.description + manual_overrides["description"] = task.description if body.assignee_id is not None: task.assignee_id = body.assignee_id + manual_overrides["assignee_id"] = task.assignee_id if body.required_skill_tag_ids is not None: task.required_skill_tag_ids = body.required_skill_tag_ids + manual_overrides["required_skill_tag_ids"] = list(task.required_skill_tag_ids or []) if body.priority is not None: task.priority = body.priority + manual_overrides["priority"] = task.priority if body.estimated_hours is not None: task.estimated_hours = body.estimated_hours + manual_overrides["estimated_hours"] = float(task.estimated_hours) if task.estimated_hours is not None else None if body.due_date is not None: task.due_date = _parse_due_date(body.due_date) + manual_overrides["due_date"] = task.due_date.isoformat() if task.due_date else None + task.manual_overrides = manual_overrides or None await log_operation( db, @@ -75,6 +87,8 @@ async def patch_task( "priority": task.priority, "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, "due_date": task.due_date.isoformat() if task.due_date else None, + "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "manual_overrides": task.manual_overrides, }, ) @@ -161,4 +175,3 @@ async def delete_task( await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id) return success_response({"id": str(tid), "deleted": True}) - diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 0b69e944..44974568 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -16,6 +16,7 @@ from ..services.sstc.charter_data import ( _load_available_skill_names, _load_task_breakdown_context, ) +from ..services.task_breakdown import normalize_task_breakdown_payload router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -47,20 +48,21 @@ async def breakdown_tasks( available_skills=available_skills, ) try: - raw_text = await llm_service.generate( + structured = await llm_service.generate_structured_object( prompt, db=db, tenant_id=tenant_id, function_name="generate_task_breakdown", ) - tasks = llm_service._parse_json_array(raw_text) - if not tasks: - raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组") + if not structured.payload: + raise ValueError("AI 拆解返回空结果") + payload = normalize_task_breakdown_payload(structured.payload) except RuntimeError: raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to generate task breakdown: {exc}") + tasks = [task.model_dump(mode="json") for task in payload.tasks] sync_result = None if request.sync_to_plane: sync_result = await plane_service.sync_tasks(tasks) @@ -93,7 +95,7 @@ async def recommend_assignees( if not task: raise HTTPException(status_code=404, detail="Task not found") - required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] + required_skills = list(task.required_skill_tag_ids or []) recommendations = await recommend_personnel( db=db, tenant_id=tenant_id, diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py index 5dd15b7a..956dc32c 100644 --- a/backend/app/config/ai_prompts_catalog/part_01.py +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -21,19 +21,29 @@ Your goal is to break down a software feature request into specific, actionable Feature Request: "{description}" -Required Output Format: JSON Array only. -Each item must have: -- "task_name": Concise technical task description. -- "type": One of ["frontend", "backend", "database"]. -- "estimated_hours": Realistic hourly estimate (float). +Required Output Format: JSON Object only. +{ + "tasks": [ + { + "task_key": "task-1", + "title": "Concise task title", + "description": "What this task delivers", + "type": "frontend|backend|database|ai|devops", + "estimated_hours": 2.0, + "required_skill_names": ["Skill Name"], + "required_skill_tag_ids": [1], + "depends_on_task_keys": [], + "risk_level": "low|medium|high|critical", + "risk_notes": "Risk summary", + "confidence": 0.0, + "evidence_refs": ["charter.objectives.0"], + "priority": "high|medium|low" + } + ], + "detected_skills": ["Skill Name"] +} -Example Output: -[ - {{"task_name": "Design Login Schema", "type": "database", "estimated_hours": 2.0}}, - {{"task_name": "Implement Auth API", "type": "backend", "estimated_hours": 4.0}} -] - -Ensure the breakdown is comprehensive (Frontend, Backend, Database) and strictly follows the JSON format. +Ensure the breakdown is comprehensive and strictly follows the JSON format. Do not include any markdown formatting or explanation outside the JSON. """ diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index 97173e32..3ea5c19f 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -117,16 +117,29 @@ PROMPT_DECOMPOSE_TASKS = """ 2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。 3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。 4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。 +5. 每个任务必须生成稳定的 `task_key`,推荐格式为 `task-1`、`task-2`、`task-3`。 +6. `required_skill_names` 只写系统技能名称;`required_skill_tag_ids` 可留空,由后端按技能名称自动回填。 +7. `depends_on_task_keys` 用于描述任务依赖,必须引用前面任务的 `task_key`。 +8. `risk_level` 只能是 `low`、`medium`、`high` 或 `critical`。 +9. `confidence` 使用 0 到 1 的小数,越接近 1 表示越确定。 +10. `evidence_refs` 填写宪章或项目上下文里的证据位置,例如 `charter.objectives.0`、`charter.risks.1`。 输出 JSON 格式: {{ "tasks": [ {{ - "task_name": "任务名称", + "task_key": "task-1", + "title": "任务名称", "description": "任务描述", "type": "frontend|backend|database|ai|devops", "estimated_hours": 0.0, "required_skill_names": ["技能1", "技能2"], + "required_skill_tag_ids": [1, 2], + "depends_on_task_keys": ["task-1"], + "risk_level": "low|medium|high|critical", + "risk_notes": "风险说明", + "confidence": 0.0, + "evidence_refs": ["charter.objectives.0"], "priority": "high|medium|low" }} ], diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 2496ad82..ca9c2489 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -21,6 +21,8 @@ class Task(Base): BigInteger, ForeignKey("users.id"), nullable=True ) ai_recommended_assignees: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + ai_breakdown_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + manual_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) title: Mapped[str] = mapped_column(VARCHAR(500), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) required_skill_tag_ids: Mapped[List[int]] = mapped_column( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 796a64b3..9ed5b048 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -15,7 +15,13 @@ from .auth import ( from .signal import SignalCreate, SignalPriority, classify_signal_priority from .experiment import ExperimentCreate, ExperimentUpdate from .context import ContextNodeCreate -from .common import TaskBreakdownRequest, TaskBreakdownResponse, TaskItem +from .common import ( + TaskBreakdownBusinessObject, + TaskBreakdownPayload, + TaskBreakdownRequest, + TaskBreakdownResponse, + TaskItem, +) from .feedback import SignalFeedbackCreate, SignalFeedbackResponse from .model_capability import ( ValidateMatchRequest, @@ -68,6 +74,8 @@ __all__ = [ "ContextNodeCreate", "TaskBreakdownRequest", "TaskBreakdownResponse", + "TaskBreakdownBusinessObject", + "TaskBreakdownPayload", "TaskItem", "SignalFeedbackCreate", "SignalFeedbackResponse", diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index 294d588d..8b624ef9 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -1,15 +1,55 @@ """通用 Pydantic 模型""" -from typing import List, Optional -from pydantic import BaseModel +from __future__ import annotations + +from typing import Any, List, Optional + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator -class TaskItem(BaseModel): - """任务项""" +class TaskBreakdownBusinessObject(BaseModel): + """结构化任务拆解业务对象""" - task_name: str - type: str + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + task_key: str = Field(validation_alias=AliasChoices("task_key", "key")) + title: str = Field(validation_alias=AliasChoices("title", "task_name")) + description: Optional[str] = None + type: str = Field(validation_alias=AliasChoices("type", "task_type")) estimated_hours: float + required_skill_names: List[str] = Field(default_factory=list) + required_skill_tag_ids: List[int] = Field(default_factory=list) + depends_on_task_keys: List[str] = Field(default_factory=list) + risk_level: str = "medium" + risk_notes: Optional[str] = None + confidence: float = 0.0 + evidence_refs: List[str] = Field(default_factory=list) + priority: str = "medium" + + @model_validator(mode="after") + def _fill_task_key(self) -> "TaskBreakdownBusinessObject": + key = str(self.task_key or "").strip() + if not key: + title = str(self.title or "").strip() + key = title.lower().replace(" ", "-") if title else "task" + self.task_key = key + return self + + +class TaskItem(TaskBreakdownBusinessObject): + """兼容旧调用的任务项别名""" + + pass + + +class TaskBreakdownPayload(BaseModel): + """AI 任务拆解结构化载荷""" + + model_config = ConfigDict(extra="ignore") + + tasks: List[TaskBreakdownBusinessObject] + detected_skills: List[str] = Field(default_factory=list) + source_context: Optional[dict[str, Any]] = None class TaskBreakdownRequest(BaseModel): @@ -24,5 +64,5 @@ class TaskBreakdownRequest(BaseModel): class TaskBreakdownResponse(BaseModel): """任务拆解响应""" - tasks: List[TaskItem] + tasks: List[TaskBreakdownBusinessObject] sync_result: Optional[dict] = None diff --git a/backend/app/services/llm_service/mixin_structured_candidates.py b/backend/app/services/llm_service/mixin_structured_candidates.py index fda9aef6..2979d0e4 100644 --- a/backend/app/services/llm_service/mixin_structured_candidates.py +++ b/backend/app/services/llm_service/mixin_structured_candidates.py @@ -1,8 +1,130 @@ from __future__ import annotations +import re + from .shared import * class LLMStructuredCandidatesMixin: + @staticmethod + def _normalize_string_list(value: Any) -> list[str]: + if value is None: + return [] + if not isinstance(value, (list, tuple, set)): + value = [value] + normalized: list[str] = [] + seen: set[str] = set() + for raw in value: + text = str(raw or "").strip() + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + normalized.append(text[:120]) + return normalized + + @staticmethod + def _normalize_int_list(value: Any) -> list[int]: + if value is None: + return [] + if not isinstance(value, (list, tuple, set)): + value = [value] + normalized: list[int] = [] + seen: set[int] = set() + for raw in value: + if isinstance(raw, bool): + continue + try: + number = int(str(raw).strip()) + except (TypeError, ValueError): + continue + if number <= 0 or number in seen: + continue + seen.add(number) + normalized.append(number) + return normalized + + @classmethod + def _coerce_task_breakdown_item(cls, payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + title = str(normalized.get("title") or normalized.get("task_name") or "").strip() + description = normalized.get("description") + task_type = str(normalized.get("type") or normalized.get("task_type") or "backend").strip().lower() or "backend" + task_key = str(normalized.get("task_key") or normalized.get("key") or "").strip() + if not task_key and title: + task_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or title + normalized["task_key"] = task_key + normalized["title"] = title + if description is not None: + normalized["description"] = str(description or "").strip() or None + normalized["type"] = task_type + + estimated_hours = cls._coerce_number(normalized.get("estimated_hours")) + normalized["estimated_hours"] = float(estimated_hours or 0.0) + + required_skill_names = normalized.get("required_skill_names") + normalized["required_skill_names"] = cls._normalize_string_list(required_skill_names) + normalized["required_skill_tag_ids"] = cls._normalize_int_list(normalized.get("required_skill_tag_ids")) + normalized["depends_on_task_keys"] = cls._normalize_string_list(normalized.get("depends_on_task_keys")) + + risk_level = str(normalized.get("risk_level") or "medium").strip().lower() or "medium" + normalized["risk_level"] = risk_level + risk_notes = normalized.get("risk_notes") + normalized["risk_notes"] = str(risk_notes or "").strip() or None + + confidence = cls._coerce_number(normalized.get("confidence")) + if confidence is None: + confidence = 0.0 + if confidence > 1 and confidence <= 100: + confidence = confidence / 100.0 + normalized["confidence"] = max(0.0, min(float(confidence), 1.0)) + + normalized["evidence_refs"] = cls._normalize_string_list(normalized.get("evidence_refs")) + normalized["priority"] = str(normalized.get("priority") or "medium").strip().lower() or "medium" + return normalized + + @classmethod + def _coerce_task_breakdown_payload(cls, payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + tasks = normalized.get("tasks") + if isinstance(tasks, list): + normalized["tasks"] = [ + cls._coerce_task_breakdown_item(item) + for item in tasks + if isinstance(item, dict) + ] + normalized["detected_skills"] = cls._normalize_string_list(normalized.get("detected_skills")) + source_context = normalized.get("source_context") + if source_context is not None and not isinstance(source_context, dict): + normalized["source_context"] = source_context if isinstance(source_context, dict) else None + return normalized + + @staticmethod + def _validate_task_breakdown_item(payload: dict[str, Any]) -> bool: + required_text_fields = ("task_key", "title", "type", "risk_level") + for key in required_text_fields: + value = str(payload.get(key) or "").strip() + if not value: + return False + if not isinstance(payload.get("estimated_hours"), (int, float)) or isinstance(payload.get("estimated_hours"), bool): + return False + if not isinstance(payload.get("confidence"), (int, float)) or isinstance(payload.get("confidence"), bool): + return False + for key in ("required_skill_names", "required_skill_tag_ids", "depends_on_task_keys", "evidence_refs"): + if not isinstance(payload.get(key), list): + return False + priority = payload.get("priority") + if priority is not None and not isinstance(priority, str): + return False + risk_notes = payload.get("risk_notes") + if risk_notes is not None and not isinstance(risk_notes, str): + return False + description = payload.get("description") + if description is not None and not isinstance(description, str): + return False + return True + def _iter_structured_candidates( self, *, @@ -167,6 +289,8 @@ class LLMStructuredCandidatesMixin: fn = str(function_name or "").strip().lower() if not isinstance(payload, dict): return payload + if fn in {"generate_task_breakdown", "sstc_ai_breakdown"}: + return cls._coerce_task_breakdown_payload(payload) if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: return cls._coerce_intent_structured_payload(payload) return payload @@ -193,4 +317,10 @@ class LLMStructuredCandidatesMixin: continue if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values): return "structured_schema_invalid" + if fn in {"generate_task_breakdown", "sstc_ai_breakdown"}: + tasks = payload.get("tasks") + if not isinstance(tasks, list) or not tasks: + return "structured_schema_invalid" + if not all(isinstance(item, dict) and self._validate_task_breakdown_item(item) for item in tasks): + return "structured_schema_invalid" return None diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 67090313..9ae27b6b 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -275,4 +275,24 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "assignee_reason": ("str_or_none",), }, }, + "generate_task_breakdown": { + "required_keys": ("tasks",), + "type_checks": { + "tasks": "list", + }, + "optional_type_checks": { + "detected_skills": ("list",), + "source_context": ("dict_or_none",), + }, + }, + "sstc_ai_breakdown": { + "required_keys": ("tasks",), + "type_checks": { + "tasks": "list", + }, + "optional_type_checks": { + "detected_skills": ("list",), + "source_context": ("dict_or_none",), + }, + }, } diff --git a/backend/app/services/skill/workload_recommend.py b/backend/app/services/skill/workload_recommend.py index 664ec73d..09eb5507 100644 --- a/backend/app/services/skill/workload_recommend.py +++ b/backend/app/services/skill/workload_recommend.py @@ -1,5 +1,7 @@ from __future__ import annotations +from sqlalchemy import or_ + from .shared import * async def update_user_workload( @@ -60,7 +62,7 @@ async def update_user_workload( async def recommend_personnel( db: AsyncSession, tenant_id: int, - required_skills: List[str], + required_skills: List[str | int], task_description: Optional[str] = None, task_id: Optional[str] = None, limit: int = 5, @@ -78,12 +80,42 @@ async def recommend_personnel( "total_count": 0, } - # 通过 slug 或 name 匹配技能标签 + skill_ids: list[int] = [] + skill_names: list[str] = [] + for raw in required_skills: + if isinstance(raw, bool): + continue + try: + skill_id = int(str(raw).strip()) + except (TypeError, ValueError): + skill_id = 0 + if skill_id > 0 and str(raw).strip() == str(skill_id): + skill_ids.append(skill_id) + continue + text = str(raw or "").strip() + if text: + skill_names.append(text) + + conditions = [] + if skill_ids: + conditions.append(SkillTag.id.in_(skill_ids)) + if skill_names: + conditions.append(or_(SkillTag.slug.in_(skill_names), SkillTag.name.in_(skill_names))) + + if not conditions: + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": list(required_skills), + "recommendations": [], + "total_count": 0, + } + skill_tags_result = await db.execute( select(SkillTag).where( and_( SkillTag.tenant_id == tenant_id, - (SkillTag.slug.in_(required_skills) | SkillTag.name.in_(required_skills)), + or_(*conditions), ) ) ) @@ -164,4 +196,3 @@ async def recommend_personnel( "recommendations": recommendations, "total_count": len(recommendations), } - diff --git a/backend/app/services/task_breakdown.py b/backend/app/services/task_breakdown.py new file mode 100644 index 00000000..9df4454c --- /dev/null +++ b/backend/app/services/task_breakdown.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from ..schemas import TaskBreakdownBusinessObject, TaskBreakdownPayload +from .task_persistence_core import SkillTagResolution, resolve_skill_tag_ids_by_names +from .task_persistence_core.shared import AsyncSession + + +def normalize_task_breakdown_payload(raw_payload: Any) -> TaskBreakdownPayload: + """将模型结构化输出归一为任务拆解载荷。""" + + if isinstance(raw_payload, TaskBreakdownPayload): + return raw_payload + if not isinstance(raw_payload, dict): + raise ValueError("task_breakdown_payload must be a JSON object") + return TaskBreakdownPayload.model_validate(raw_payload) + + +def build_task_breakdown_ai_payload(payload: TaskBreakdownPayload) -> dict[str, Any]: + """将结构化拆解载荷保存为 JSON 负载。""" + + return payload.model_dump(mode="json") + + +async def resolve_task_breakdown_skill_tag_ids( + db: AsyncSession, + *, + tenant_id: int, + task: TaskBreakdownBusinessObject, +) -> tuple[list[int], SkillTagResolution]: + """将任务里的技能名称和已有 skill_tag_id 合并成最终 ID 列表。""" + + resolution = await resolve_skill_tag_ids_by_names( + db, + tenant_id=tenant_id, + skill_names=task.required_skill_names, + ) + merged_ids: list[int] = [] + seen: set[int] = set() + for raw in list(task.required_skill_tag_ids or []) + list(resolution.matched_ids or []): + try: + skill_id = int(raw) + except (TypeError, ValueError): + continue + if skill_id <= 0 or skill_id in seen: + continue + seen.add(skill_id) + merged_ids.append(skill_id) + return merged_ids, resolution + + +def should_auto_assign_breakdown_task( + *, + task: TaskBreakdownBusinessObject, + top_match_score: float | None, + top_workload_rate: float | None, + confidence_threshold: float = 0.85, + match_score_threshold: float = 75.0, + workload_threshold: float = 70.0, +) -> bool: + """判断 AI 拆解任务是否可自动分配负责人。""" + + confidence = float(task.confidence or 0.0) + if confidence < confidence_threshold: + return False + if str(task.risk_level or "").strip().lower() not in {"low"}: + return False + if top_match_score is None or top_workload_rate is None: + return False + if top_match_score < match_score_threshold: + return False + if top_workload_rate >= workload_threshold: + return False + return True + + +__all__ = [ + "normalize_task_breakdown_payload", + "build_task_breakdown_ai_payload", + "resolve_task_breakdown_skill_tag_ids", + "should_auto_assign_breakdown_task", +] diff --git a/backend/app/services/task_persistence_core/models.py b/backend/app/services/task_persistence_core/models.py index bd4e0d8c..546f72d3 100644 --- a/backend/app/services/task_persistence_core/models.py +++ b/backend/app/services/task_persistence_core/models.py @@ -19,6 +19,8 @@ class TaskCreateDraft: description: Optional[str] = None assignee_id: Optional[int] = None required_skill_tag_ids: list[int] = field(default_factory=list) + ai_breakdown_payload: Optional[dict[str, Any]] = None + manual_overrides: Optional[dict[str, Any]] = None priority: str = "medium" status: str = "todo" estimated_hours: Optional[float] = None diff --git a/backend/app/services/task_persistence_core/normalize.py b/backend/app/services/task_persistence_core/normalize.py index fbe8882b..fd6124c2 100644 --- a/backend/app/services/task_persistence_core/normalize.py +++ b/backend/app/services/task_persistence_core/normalize.py @@ -93,6 +93,8 @@ def build_task_create_draft( description: str | None = None, assignee_id: int | None = None, required_skill_tag_ids: Any = None, + ai_breakdown_payload: dict[str, Any] | None = None, + manual_overrides: dict[str, Any] | None = None, priority: Any = "medium", status: Any = "todo", estimated_hours: Any = None, @@ -109,6 +111,8 @@ def build_task_create_draft( description=str(description or "").strip() or None, assignee_id=int(assignee_id) if assignee_id is not None else None, required_skill_tag_ids=normalize_int_list(required_skill_tag_ids), + ai_breakdown_payload=None if ai_breakdown_payload is None else dict(ai_breakdown_payload), + manual_overrides=None if manual_overrides is None else dict(manual_overrides), priority=normalize_task_priority(priority), status=normalize_task_status(status), estimated_hours=normalize_estimated_hours(estimated_hours), diff --git a/backend/app/services/task_persistence_core/persist.py b/backend/app/services/task_persistence_core/persist.py index 5e707f0a..cf0752bd 100644 --- a/backend/app/services/task_persistence_core/persist.py +++ b/backend/app/services/task_persistence_core/persist.py @@ -27,6 +27,8 @@ async def persist_task( title=draft.title, description=draft.description, required_skill_tag_ids=draft.required_skill_tag_ids, + ai_breakdown_payload=draft.ai_breakdown_payload, + manual_overrides=draft.manual_overrides, status=draft.status, priority=draft.priority, estimated_hours=draft.estimated_hours, @@ -55,6 +57,8 @@ async def persist_task( "status": task.status, "assignee_id": int(task.assignee_id) if task.assignee_id is not None else None, "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "ai_breakdown_payload": task.ai_breakdown_payload, + "manual_overrides": task.manual_overrides, "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, "due_date": task.due_date.isoformat() if task.due_date else None, }, diff --git a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py index a584c93a..45fb7b1f 100644 --- a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py +++ b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py @@ -4,6 +4,9 @@ from types import SimpleNamespace import pytest from app.api import tasks as tasks_api +from app.api.sstc_api import tasks_ai_breakdown as sstc_tasks_ai_breakdown_api +from app.api.sstc_api import tasks_recommend_optimize as sstc_tasks_recommend_optimize_api +from app.services.llm_service import StructuredExtractionResult from app.services.sstc.charter_data import ( _build_task_breakdown_context, _build_task_breakdown_prompt, @@ -142,34 +145,51 @@ async def test_general_task_breakdown_route_uses_project_and_charter_context(mon return "FastAPI, React, LangChain" class _FakeLLM: - async def generate(self, prompt: str, **kwargs): + async def generate_structured_object(self, prompt: str, **kwargs): captured["prompt"] = prompt captured["llm_kwargs"] = kwargs - return json.dumps( - [ + payload = { + "tasks": [ { - "task_name": "需求澄清与范围确认", + "task_key": "task-1", + "title": "需求澄清与范围确认", + "description": "确认导出知识库的范围", "type": "ai", "estimated_hours": 4, - "priority": "high", "required_skill_names": ["FastAPI"], + "required_skill_tag_ids": [101], + "depends_on_task_keys": [], + "risk_level": "low", + "risk_notes": "边界明确", + "confidence": 0.94, + "evidence_refs": ["charter.objectives.0"], }, { - "task_name": "核心方案落地", + "task_key": "task-2", + "title": "核心方案落地", + "description": "实现导出与验证流程", "type": "backend", "estimated_hours": 12, - "priority": "medium", "required_skill_names": ["React", "LangChain"], + "required_skill_tag_ids": [202, 303], + "depends_on_task_keys": ["task-1"], + "risk_level": "medium", + "risk_notes": "依赖范围确认结果", + "confidence": 0.87, + "evidence_refs": ["charter.success_criteria.0"], }, ], - ensure_ascii=False, + "detected_skills": ["FastAPI", "React", "LangChain"], + } + return StructuredExtractionResult( + payload=payload, + source_field="message.content", + provider="glm", + model="qwen-plus", + raw_text=json.dumps(payload, ensure_ascii=False), + validation="ok", ) - @staticmethod - def _parse_json_array(text: str): - parsed = json.loads(text) - return [item for item in parsed if isinstance(item, dict)] - class _FakePlane: async def sync_tasks(self, tasks, is_urgent: bool = False): _ = is_urgent @@ -194,4 +214,285 @@ async def test_general_task_breakdown_route_uses_project_and_charter_context(mon assert "内部知识库升级宪章" in str(captured["prompt"]) assert "FastAPI, React, LangChain" in str(captured["prompt"]) assert result["sync_result"]["synced_count"] == 2 - assert result["tasks"][0]["task_name"] == "需求澄清与范围确认" + assert result["tasks"][0]["task_key"] == "task-1" + assert result["tasks"][0]["required_skill_tag_ids"] == [101] + assert result["tasks"][1]["depends_on_task_keys"] == ["task-1"] + + +@pytest.mark.asyncio +async def test_sstc_task_breakdown_route_persists_business_object_and_payload(monkeypatch) -> None: + captured: dict[str, object] = {"drafts": []} + + async def _fake_load_context(db, *, tenant_id: int, description: str, project_id=None, charter_id=None): + _ = (db, tenant_id, project_id, charter_id) + captured["description"] = description + return { + "request": {"description": description}, + "project": { + "id": 6, + "title": "内部知识库升级", + "description": "围绕内部知识库问答能力升级", + "status": "active", + "assigned_role": "backend", + }, + "charter": { + "id": 55, + "title": "内部知识库升级宪章", + "version": 2, + "status": "aligning", + "summary": "减少重复检索", + "objectives": ["提升命中率", "降低重复提问"], + "scope": "知识库检索与问答", + "in_scope": ["问答检索", "知识补充"], + "out_of_scope": ["权限模型重构"], + "success_criteria": ["首响 < 3s"], + "constraints": ["附件仅作证据引用"], + "milestones": ["需求冻结", "开发完成"], + "risks": ["需求膨胀"], + "signal_ids": [12], + }, + } + + async def _fake_load_skills(db, *, tenant_id: int): + _ = (db, tenant_id) + return "FastAPI, React, LangChain" + + class _FakeLLM: + async def generate_structured_object(self, prompt: str, **kwargs): + captured["prompt"] = prompt + captured["llm_kwargs"] = kwargs + payload = { + "tasks": [ + { + "task_key": "task-1", + "title": "需求澄清与范围确认", + "description": "确认导出知识库的范围", + "type": "ai", + "estimated_hours": 4, + "required_skill_names": ["FastAPI"], + "required_skill_tag_ids": [101], + "depends_on_task_keys": [], + "risk_level": "low", + "risk_notes": "边界明确", + "confidence": 0.94, + "evidence_refs": ["charter.objectives.0"], + }, + { + "task_key": "task-2", + "title": "核心方案落地", + "description": "实现导出与验证流程", + "type": "backend", + "estimated_hours": 12, + "required_skill_names": ["React", "LangChain"], + "required_skill_tag_ids": [202, 303], + "depends_on_task_keys": ["task-1"], + "risk_level": "medium", + "risk_notes": "依赖范围确认结果", + "confidence": 0.87, + "evidence_refs": ["charter.success_criteria.0"], + }, + ], + "detected_skills": ["FastAPI", "React", "LangChain"], + } + return StructuredExtractionResult( + payload=payload, + source_field="message.content", + provider="glm", + model="qwen-plus", + raw_text=json.dumps(payload, ensure_ascii=False), + validation="ok", + ) + + class _FakeResult: + def __init__(self, titles=None): + self._titles = titles or [] + + def scalars(self): + return self + + def all(self): + return self._titles + + def first(self): + return self._titles[0] if self._titles else None + + class _FakeDB: + def __init__(self): + self.added = [] + self.committed = False + + async def execute(self, stmt): + _ = stmt + return _FakeResult([]) + + def add(self, value): + self.added.append(value) + + async def commit(self): + self.committed = True + + async def refresh(self, obj): + _ = obj + return None + + async def _fake_persist_task(db, **kwargs): + _ = db + draft = kwargs["draft"] + captured["drafts"].append(draft) + return SimpleNamespace( + id=len(captured["drafts"]), + title=draft.title, + description=draft.description, + priority=draft.priority, + status=draft.status, + assignee_id=draft.assignee_id, + required_skill_tag_ids=list(draft.required_skill_tag_ids or []), + ai_breakdown_payload=draft.ai_breakdown_payload, + manual_overrides=draft.manual_overrides, + ) + + async def _fake_ensure_project(service, project_id): + _ = (service, project_id) + return SimpleNamespace( + id=6, + title="内部知识库升级", + name="内部知识库升级", + description="围绕内部知识库问答能力升级", + status="active", + assigned_role="backend", + ) + + async def _fake_ensure_charter(db, *, tenant_id: int, project_id: int, charter_id: int): + _ = (db, tenant_id, project_id, charter_id) + return SimpleNamespace( + id=55, + title="内部知识库升级宪章", + version=2, + content={"modules": {}}, + status="aligning", + ) + + def _fake_assert_charter_aligned(charter): + _ = charter + return None + + async def _fake_log_operation(*args, **kwargs): + _ = (args, kwargs) + return None + + async def _fake_invalidate_system_context(*args, **kwargs): + _ = (args, kwargs) + return None + + def _fake_sync_pmflow_rag_async(*args, **kwargs): + _ = (args, kwargs) + return None + + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_ensure_project", _fake_ensure_project) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_ensure_charter", _fake_ensure_charter) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_assert_charter_aligned", _fake_assert_charter_aligned) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_load_available_skill_names", _fake_load_skills) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "llm_service", _FakeLLM()) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "persist_task", _fake_persist_task) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "log_operation", _fake_log_operation) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_sync_pmflow_rag_async", _fake_sync_pmflow_rag_async) + monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "context_cache_service", SimpleNamespace(invalidate_system_context=_fake_invalidate_system_context)) + + result = await sstc_tasks_ai_breakdown_api.ai_breakdown_tasks( + project_id="6", + charter_id="55", + db=_FakeDB(), + current_user=SimpleNamespace(tenant_id=1, id=1, role="ADMIN"), + permission_user=SimpleNamespace(), + ) + + assert "内部知识库升级宪章" in str(captured["prompt"]) + assert len(captured["drafts"]) == 2 + assert captured["drafts"][0].required_skill_tag_ids == [101] + assert captured["drafts"][0].ai_breakdown_payload["tasks"][0]["task_key"] == "task-1" + assert captured["drafts"][0].manual_overrides == {} + assert result["data"]["created_count"] == 2 + assert result["data"]["items"][0]["title"] == "需求澄清与范围确认" + + +@pytest.mark.asyncio +async def test_recommend_assignee_route_uses_skill_tag_ids_semantically(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeTask: + id = 77 + title = "导出知识库" + description = "支持导出知识库内容" + project_id = 6 + required_skill_tag_ids = [101, 202] + + async def _fake_ensure_project(service, project_id): + _ = (service, project_id) + return None + + async def _fake_ensure_task(db, *, tenant_id: int, project_id: int, task_id: int): + _ = (db, tenant_id, project_id, task_id) + return _FakeTask() + + async def _fake_recommend_personnel(*, db, tenant_id: int, required_skills, task_description=None, task_id=None, limit=5): + _ = (db, tenant_id, task_description, task_id, limit) + captured["required_skills"] = list(required_skills) + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": list(required_skills), + "recommendations": [ + { + "user_id": "9", + "user_name": "Ada", + "match_score": 91, + "reason": "strong match", + "skill_match_count": 2, + "avg_proficiency": 88, + "workload_rate": 45, + "recommended_role": "backend", + } + ], + "total_count": 1, + } + + class _FakeResult: + def __init__(self, values): + self._values = values + + def scalars(self): + return self + + def all(self): + return self._values + + async def _fake_ensure_project(service, project_id): + _ = (service, project_id) + return SimpleNamespace(id=6) + + async def _fake_ensure_task(db, *, tenant_id: int, project_id: int, task_id: int): + _ = (db, tenant_id, project_id, task_id) + return _FakeTask() + + class _FakeDB: + async def execute(self, stmt): + sql = str(stmt) + if "user_projects" in sql.lower(): + return _FakeResult([9]) + return _FakeResult([9]) + + monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "_ensure_project", _fake_ensure_project) + monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "_ensure_task", _fake_ensure_task) + monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "recommend_personnel", _fake_recommend_personnel) + + result = await sstc_tasks_recommend_optimize_api.recommend_assignee_for_task( + project_id="6", + task_id="77", + db=_FakeDB(), + current_user=SimpleNamespace(tenant_id=1, id=1), + permission_user=SimpleNamespace(), + ) + + assert captured["required_skills"] == [101, 202] + assert result["data"]["recommendations"][0]["user_id"] == "9" + assert result["data"]["total_count"] == 1 diff --git a/frontend/src/api/sstc.ts b/frontend/src/api/sstc.ts index 79b8f0f5..af5a38e4 100644 --- a/frontend/src/api/sstc.ts +++ b/frontend/src/api/sstc.ts @@ -102,6 +102,53 @@ export interface SSTCTask { due_date?: string | null; risk_count?: number; truth_node_id?: string; + required_skill_tag_ids?: number[]; + ai_recommended_assignees?: SSTCTaskAssigneeRecommendationResponse | Record | null; + ai_breakdown_payload?: SSTCTaskBreakdownPayload | Record | null; + manual_overrides?: Record | null; + auto_assigned?: boolean; +} + +export interface SSTCTaskBreakdownBusinessObject { + task_key: string; + title: string; + description?: string | null; + type: string; + estimated_hours: number; + required_skill_names: string[]; + required_skill_tag_ids: number[]; + depends_on_task_keys: string[]; + risk_level: string; + risk_notes?: string | null; + confidence: number; + evidence_refs: string[]; + priority: string; +} + +export interface SSTCTaskBreakdownPayload { + tasks: SSTCTaskBreakdownBusinessObject[]; + detected_skills: string[]; + source_context?: Record | null; + task?: SSTCTaskBreakdownBusinessObject | null; +} + +export interface SSTCTaskAIBreakdownItemResponse { + id: string; + title: string; + priority: string; + status: string; + assignee_id?: string | null; + required_skill_tag_ids?: number[]; + ai_breakdown_payload?: SSTCTaskBreakdownPayload | Record | null; + manual_overrides?: Record | null; + auto_assigned?: boolean; +} + +export interface SSTCTaskAIBreakdownResponse { + created_count: number; + skipped_duplicates: number; + auto_assigned_count: number; + items: SSTCTaskAIBreakdownItemResponse[]; } export interface SSTCTaskRecommendation { @@ -117,7 +164,7 @@ export interface SSTCTaskRecommendation { export interface SSTCTaskAssigneeRecommendationResponse { task_id: string; - required_skills: string[]; + required_skills: Array; recommendations: SSTCTaskRecommendation[]; total_count: number; } @@ -569,12 +616,9 @@ export const sstcApi = { }, async aiBreakdownTasks(projectId: string, charterId: string) { - const { data } = await api.post< - ApiEnvelope<{ - created_count: number; - items: Array<{ id: string; title: string; priority: string; status: string }>; - }> - >(`/projects/${projectId}/charters/${charterId}/tasks/ai-breakdown`); + const { data } = await api.post>( + `/projects/${projectId}/charters/${charterId}/tasks/ai-breakdown` + ); return unwrap(data); }, diff --git a/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx b/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx index 91e2143e..462f3c2e 100644 --- a/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx +++ b/frontend/src/components/sstc/SSTCExecutionBoardPanel.tsx @@ -1,6 +1,6 @@ import { RefreshCcw } from "lucide-react"; import { type DragEvent, useMemo, useState } from "react"; -import type { SSTCTask } from "@/api/sstc"; +import type { SSTCTask, SSTCTaskBreakdownBusinessObject, SSTCTaskBreakdownPayload } from "@/api/sstc"; import type { User } from "@/api/users"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,6 +36,31 @@ interface SSTCExecutionBoardPanelProps { handleDropTask: (targetStatus: SSTCTask["status"], ev: DragEvent) => void; } +function formatConfidence(value?: number | null): string { + if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; + const normalized = Number(value); + const percent = normalized <= 1 ? normalized * 100 : normalized; + return `${Math.round(percent)}%`; +} + +function getTaskBreakdownObject(task: SSTCTask): SSTCTaskBreakdownBusinessObject | null { + const payload = task.ai_breakdown_payload; + if (!payload || typeof payload !== "object") return null; + const typed = payload as SSTCTaskBreakdownPayload; + if (typed.task && typeof typed.task === "object") return typed.task; + if (Array.isArray(typed.tasks) && typed.tasks.length > 0) { + const matched = typed.tasks.find((item) => item.task_key === task.id || item.title === task.title); + return matched || typed.tasks[0] || null; + } + return null; +} + +function getOverrideFields(task: SSTCTask): string[] { + const overrides = task.manual_overrides; + if (!overrides || typeof overrides !== "object") return []; + return Object.keys(overrides); +} + export function SSTCExecutionBoardPanel({ stageLocked, projectUsers, @@ -51,6 +76,8 @@ export function SSTCExecutionBoardPanel({ () => new Map(projectUsers.map((user) => [String(user.id), user.name || user.username || `用户 ${user.id}`])), [projectUsers] ); + const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null; + const detailOverrides = detailTask ? getOverrideFields(detailTask) : []; return ( @@ -108,47 +135,75 @@ export function SSTCExecutionBoardPanel({ {executionLanes[lane.key].length}
- {executionLanes[lane.key].map((t) => ( -
e.dataTransfer.setData("taskId", t.id)} - onClick={() => setDetailTask(t)} - className="cursor-pointer rounded-md border bg-card p-2" - > -

{t.title}

-
- {toZhPriority(t.priority)} - {t.due_date ? t.due_date.slice(0, 10) : "-"} + {executionLanes[lane.key].map((t) => { + const breakdown = getTaskBreakdownObject(t); + const overrideFields = getOverrideFields(t); + return ( +
e.dataTransfer.setData("taskId", t.id)} + onClick={() => setDetailTask(t)} + className="cursor-pointer rounded-md border bg-card p-2" + > +
+

{t.title}

+
+ {t.auto_assigned && ( + + AI 自动指派 + + )} + {overrideFields.length > 0 && ( + + 人工覆盖 {overrideFields.length} + + )} +
+ {breakdown && ( +
+

+ 风险 {breakdown.risk_level || "—"} · 置信度 {formatConfidence(breakdown.confidence)} +

+

+ 技能:{breakdown.required_skill_names.join(" / ") || "—"} +

+
+ )} +
+
+ {toZhPriority(t.priority)} + {t.due_date ? t.due_date.slice(0, 10) : "-"} +
+
+ + +
-
- - -
-
- ))} + ); + })}
))} @@ -187,6 +242,51 @@ export function SSTCExecutionBoardPanel({

任务描述

{detailTask.description || "-"}

+ {detailBreakdown && ( +
+

AI 业务对象

+
+
+

任务键

+

{detailBreakdown.task_key || "-"}

+
+
+

风险 / 置信度

+

+ {detailBreakdown.risk_level || "-"} / {formatConfidence(detailBreakdown.confidence)} +

+
+
+

技能要求

+

+ {detailBreakdown.required_skill_names.join(" / ") || "-"} +

+
+
+

依赖任务

+

+ {detailBreakdown.depends_on_task_keys.join(" / ") || "无"} +

+
+
+

证据

+

+ {detailBreakdown.evidence_refs.join(" / ") || "-"} +

+
+
+
+ )} + {detailTask.auto_assigned && ( +

+ 该任务由 AI 自动指派负责人。 +

+ )} + {detailOverrides.length > 0 && ( +
+ 人工覆盖字段:{detailOverrides.join(" / ")} +
+ )} )} diff --git a/frontend/src/components/sstc/SSTCTaskStagePanel.tsx b/frontend/src/components/sstc/SSTCTaskStagePanel.tsx index fd021b25..22da5ce4 100644 --- a/frontend/src/components/sstc/SSTCTaskStagePanel.tsx +++ b/frontend/src/components/sstc/SSTCTaskStagePanel.tsx @@ -4,6 +4,8 @@ import type { SSTCCharter, SSTCTask, SSTCTaskAssigneeRecommendationResponse, + SSTCTaskBreakdownBusinessObject, + SSTCTaskBreakdownPayload, SSTCTaskRecommendation, } from "@/api/sstc"; import type { User } from "@/api/users"; @@ -64,6 +66,40 @@ interface RecommendAssigneeMutationLike { isPending: boolean; } +function formatConfidence(value?: number | null): string { + if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; + const normalized = Number(value); + const percent = normalized <= 1 ? normalized * 100 : normalized; + return `${Math.round(percent)}%`; +} + +function getTaskBreakdownObject(task: SSTCTask): SSTCTaskBreakdownBusinessObject | null { + const payload = task.ai_breakdown_payload; + if (!payload || typeof payload !== "object") return null; + const typed = payload as SSTCTaskBreakdownPayload; + if (typed.task && typeof typed.task === "object") return typed.task; + if (Array.isArray(typed.tasks) && typed.tasks.length > 0) { + const matched = typed.tasks.find( + (item) => item.task_key === task.id || item.title === task.title + ); + return matched || typed.tasks[0] || null; + } + return null; +} + +function getStoredRecommendations(task: SSTCTask): SSTCTaskRecommendation[] { + const stored = task.ai_recommended_assignees; + if (!stored || typeof stored !== "object") return []; + const recommendations = (stored as SSTCTaskAssigneeRecommendationResponse).recommendations; + return Array.isArray(recommendations) ? recommendations : []; +} + +function getOverrideFields(task: SSTCTask): string[] { + const overrides = task.manual_overrides; + if (!overrides || typeof overrides !== "object") return []; + return Object.keys(overrides); +} + interface SSTCTaskStagePanelProps { projectId: string; stageLocked: boolean; @@ -267,11 +303,15 @@ export function SSTCTaskStagePanel({ {charterTasks.map((t) => { - const recs = taskRecommendations[t.id] || []; + const storedRecs = getStoredRecommendations(t); + const recs = taskRecommendations[t.id] || storedRecs; const status = String(t.status || "").toLowerCase(); const done = status === "done" || status === "completed"; const topRec = recs[0]; const sourceCharterTitle = selectedCharter ? compactTitle(selectedCharter.title, 16) : "-"; + const breakdown = getTaskBreakdownObject(t); + const overrideFields = getOverrideFields(t); + const autoAssigned = Boolean(t.auto_assigned); return ( @@ -283,26 +323,60 @@ export function SSTCTaskStagePanel({ aria-label={`选择任务 ${t.title}`} /> - -

{t.title}

- {t.description &&

{t.description}

} -
- 来源:{sourceCharterTitle} - 风险:{t.risk_count ?? 0} - 任务ID:{String(t.id).slice(-6)} -
- {recs.length > 0 && ( -
- {recs.slice(0, 4).map((r) => ( - - {r.user_name} · {r.recommended_role || "-"} · {Number(r.workload_rate || 0).toFixed(0)}% - - ))} + +
+
+

{t.title}

+ {autoAssigned && ( + + AI 自动指派 + + )} + {overrideFields.length > 0 && ( + + 人工覆盖 {overrideFields.length} + + )}
- )} + {t.description &&

{t.description}

} +
+ 来源:{sourceCharterTitle} + 风险:{t.risk_count ?? 0} + 任务ID:{String(t.id).slice(-6)} +
+ {breakdown && ( +
+
+ 风险等级:{breakdown.risk_level} + 置信度:{formatConfidence(breakdown.confidence)} + 工时:{Number(breakdown.estimated_hours || 0).toFixed(1)}h +
+

+ 技能:{breakdown.required_skill_names.join(" / ") || "—"} +

+

+ 依赖:{breakdown.depends_on_task_keys.join(" / ") || "无"} +

+ {breakdown.evidence_refs.length > 0 && ( +

+ 证据:{breakdown.evidence_refs.join(" / ")} +

+ )} +
+ )} + {recs.length > 0 && ( +
+ {recs.slice(0, 4).map((r) => ( + + {r.user_name} · {r.recommended_role || "-"} · {Number(r.workload_rate || 0).toFixed(0)}% + + ))} +
+ )} +
{toZhPriority(t.priority)}