Add unified feedback center and task learning loop

This commit is contained in:
Yuuuu 2026-03-26 10:50:16 +08:00
parent 4268d791ed
commit 4da49c7fc4
23 changed files with 1207 additions and 136 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown> | null;
ai_breakdown_payload?: SSTCTaskBreakdownPayload | Record<string, unknown> | null;
manual_overrides?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null;
manual_overrides?: Record<string, unknown> | 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<string | number>;
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<ApiEnvelope<SSTCTaskAIBreakdownResponse>>(
`/projects/${projectId}/charters/${charterId}/tasks/ai-breakdown`
);
return unwrap(data);
},

View File

@ -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<HTMLDivElement>) => 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 (
<Card>
@ -108,47 +135,75 @@ export function SSTCExecutionBoardPanel({
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
</div>
<div className="space-y-2">
{executionLanes[lane.key].map((t) => (
<div
key={t.id}
draggable={!stageLocked}
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
onClick={() => setDetailTask(t)}
className="cursor-pointer rounded-md border bg-card p-2"
>
<p className="line-clamp-2 text-xs font-medium sm:text-sm">{t.title}</p>
<div className="mt-1 flex items-center justify-between">
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span>
{executionLanes[lane.key].map((t) => {
const breakdown = getTaskBreakdownObject(t);
const overrideFields = getOverrideFields(t);
return (
<div
key={t.id}
draggable={!stageLocked}
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
onClick={() => setDetailTask(t)}
className="cursor-pointer rounded-md border bg-card p-2"
>
<div className="space-y-1">
<p className="line-clamp-2 text-xs font-medium sm:text-sm">{t.title}</p>
<div className="flex flex-wrap gap-1">
{t.auto_assigned && (
<Badge className="border border-emerald-200 bg-emerald-50 text-[10px] text-emerald-700">
AI 自动指派
</Badge>
)}
{overrideFields.length > 0 && (
<Badge className="border border-amber-200 bg-amber-50 text-[10px] text-amber-700">
人工覆盖 {overrideFields.length}
</Badge>
)}
</div>
{breakdown && (
<div className="space-y-0.5 text-[10px] text-muted-foreground">
<p>
风险 {breakdown.risk_level || "—"} · 置信度 {formatConfidence(breakdown.confidence)}
</p>
<p className="line-clamp-1">
技能:{breakdown.required_skill_names.join(" / ") || "—"}
</p>
</div>
)}
</div>
<div className="mt-1 flex items-center justify-between">
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span>
</div>
<div className="mt-2 flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={stageLocked}
onClick={(event) => {
event.stopPropagation();
patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" });
}}
>
阻塞
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={stageLocked}
onClick={(event) => {
event.stopPropagation();
patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" });
}}
>
完成
</Button>
</div>
</div>
<div className="mt-2 flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={stageLocked}
onClick={(event) => {
event.stopPropagation();
patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" });
}}
>
阻塞
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={stageLocked}
onClick={(event) => {
event.stopPropagation();
patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" });
}}
>
完成
</Button>
</div>
</div>
))}
);
})}
</div>
</div>
))}
@ -187,6 +242,51 @@ export function SSTCExecutionBoardPanel({
<p className="text-xs text-slate-500">任务描述</p>
<p className="mt-1 whitespace-pre-wrap text-slate-700">{detailTask.description || "-"}</p>
</div>
{detailBreakdown && (
<div className="rounded-xl border border-slate-200 bg-white p-3">
<p className="text-xs font-medium text-slate-500">AI 业务对象</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-slate-500">任务键</p>
<p className="font-medium text-slate-700">{detailBreakdown.task_key || "-"}</p>
</div>
<div>
<p className="text-xs text-slate-500">风险 / 置信度</p>
<p className="font-medium text-slate-700">
{detailBreakdown.risk_level || "-"} / {formatConfidence(detailBreakdown.confidence)}
</p>
</div>
<div>
<p className="text-xs text-slate-500">技能要求</p>
<p className="font-medium text-slate-700">
{detailBreakdown.required_skill_names.join(" / ") || "-"}
</p>
</div>
<div>
<p className="text-xs text-slate-500">依赖任务</p>
<p className="font-medium text-slate-700">
{detailBreakdown.depends_on_task_keys.join(" / ") || "无"}
</p>
</div>
<div className="sm:col-span-2">
<p className="text-xs text-slate-500">证据</p>
<p className="font-medium text-slate-700">
{detailBreakdown.evidence_refs.join(" / ") || "-"}
</p>
</div>
</div>
</div>
)}
{detailTask.auto_assigned && (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
该任务由 AI 自动指派负责人。
</p>
)}
{detailOverrides.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
人工覆盖字段:{detailOverrides.join(" / ")}
</div>
)}
</div>
)}
<DialogFooter className="gap-2">

View File

@ -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({
</TableHeader>
<TableBody>
{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 (
<TableRow key={t.id}>
<TableCell className="text-center align-top">
@ -283,26 +323,60 @@ export function SSTCTaskStagePanel({
aria-label={`选择任务 ${t.title}`}
/>
</TableCell>
<TableCell>
<p className="font-medium">{t.title}</p>
{t.description && <p className="text-xs text-muted-foreground">{t.description}</p>}
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>来源:{sourceCharterTitle}</span>
<span>风险:{t.risk_count ?? 0}</span>
<span>任务ID:{String(t.id).slice(-6)}</span>
</div>
{recs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{recs.slice(0, 4).map((r) => (
<span
key={`${t.id}-${r.user_id}`}
className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
{r.user_name} · {r.recommended_role || "-"} · {Number(r.workload_rate || 0).toFixed(0)}%
</span>
))}
<TableCell className="align-top">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-slate-900">{t.title}</p>
{autoAssigned && (
<Badge className="border border-emerald-200 bg-emerald-50 text-[10px] text-emerald-700">
AI 自动指派
</Badge>
)}
{overrideFields.length > 0 && (
<Badge className="border border-amber-200 bg-amber-50 text-[10px] text-amber-700">
人工覆盖 {overrideFields.length}
</Badge>
)}
</div>
)}
{t.description && <p className="text-xs text-muted-foreground">{t.description}</p>}
<div className="flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>来源:{sourceCharterTitle}</span>
<span>风险:{t.risk_count ?? 0}</span>
<span>任务ID:{String(t.id).slice(-6)}</span>
</div>
{breakdown && (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-[11px] text-slate-600">
<div className="flex flex-wrap gap-2">
<span>风险等级:{breakdown.risk_level}</span>
<span>置信度:{formatConfidence(breakdown.confidence)}</span>
<span>工时:{Number(breakdown.estimated_hours || 0).toFixed(1)}h</span>
</div>
<p className="mt-1 line-clamp-1">
技能:{breakdown.required_skill_names.join(" / ") || "—"}
</p>
<p className="line-clamp-1">
依赖:{breakdown.depends_on_task_keys.join(" / ") || "无"}
</p>
{breakdown.evidence_refs.length > 0 && (
<p className="line-clamp-1">
证据:{breakdown.evidence_refs.join(" / ")}
</p>
)}
</div>
)}
{recs.length > 0 && (
<div className="flex flex-wrap gap-1">
{recs.slice(0, 4).map((r) => (
<span
key={`${t.id}-${r.user_id}`}
className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
{r.user_name} · {r.recommended_role || "-"} · {Number(r.workload_rate || 0).toFixed(0)}%
</span>
))}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge className={`border whitespace-nowrap ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>