Add unified feedback center and task learning loop
This commit is contained in:
parent
4268d791ed
commit
4da49c7fc4
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -103,13 +103,15 @@ backend/
|
||||||
- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务)
|
- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务)
|
||||||
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
|
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
|
||||||
- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
|
- 新增:`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_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约)
|
||||||
- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单)
|
- 更新:`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/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/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`)
|
||||||
- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper)
|
- 更新:`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_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_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 契约回归:意图识别、路由归一与执行器分派)
|
- 新增:`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_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
|
||||||
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
|
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
|
||||||
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
|
- 新增:`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/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
||||||
- 新增:`backend/app/services/task_brief_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 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要
|
||||||
- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流
|
- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流
|
||||||
- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块
|
- 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}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本
|
||||||
- SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间”
|
- 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`
|
- SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids`
|
||||||
- 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution`
|
- 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution`
|
||||||
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .shared import *
|
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.entity_lookup import _ensure_charter, _ensure_project
|
||||||
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
|
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.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"])
|
router = APIRouter(tags=["sstc"])
|
||||||
|
|
||||||
_TITLE_NORMALIZE_RE = re.compile(r"[\s\-_]+")
|
_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:
|
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)
|
available_skills = await _load_available_skill_names(db, tenant_id=tenant_id)
|
||||||
prompt = _build_task_breakdown_prompt(
|
prompt = _build_task_breakdown_prompt(
|
||||||
project_id=pid,
|
project_id=pid,
|
||||||
charter_context=charter_context,
|
context=charter_context,
|
||||||
available_skills=available_skills,
|
available_skills=available_skills,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw_text = await llm_service.generate(
|
structured = await llm_service.generate_structured_object(
|
||||||
prompt,
|
prompt,
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
project_id=pid,
|
project_id=pid,
|
||||||
function_name="sstc_ai_breakdown",
|
function_name="sstc_ai_breakdown",
|
||||||
)
|
)
|
||||||
rows = llm_service._parse_json_array(raw_text)
|
if not structured.payload:
|
||||||
if not rows:
|
raise ValueError("AI 拆解返回空结果")
|
||||||
raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组")
|
payload = normalize_task_breakdown_payload(structured.payload)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from 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(
|
existing_task_title_result = await db.execute(
|
||||||
select(Task.title).where(
|
select(Task.title).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -82,13 +96,36 @@ async def ai_breakdown_tasks(
|
||||||
if str(title or "").strip()
|
if str(title or "").strip()
|
||||||
}
|
}
|
||||||
seen_title_keys = set(existing_title_keys)
|
seen_title_keys = set(existing_title_keys)
|
||||||
|
ai_breakdown_payload = build_task_breakdown_ai_payload(payload)
|
||||||
|
ai_breakdown_payload["source_context"] = charter_context
|
||||||
|
|
||||||
created = []
|
created = []
|
||||||
skipped_duplicates = 0
|
skipped_duplicates = 0
|
||||||
|
auto_assigned_count = 0
|
||||||
prompt_tokens = max(len(prompt) // 4, 1)
|
prompt_tokens = max(len(prompt) // 4, 1)
|
||||||
completion_tokens = max(len(raw_text) // 4, 0)
|
completion_tokens = max(len(structured.raw_text or json.dumps(payload.model_dump(mode="json"), ensure_ascii=False)) // 4, 0)
|
||||||
for item in rows or []:
|
project_user_ids_result = await db.execute(
|
||||||
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
|
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:
|
if not title:
|
||||||
continue
|
continue
|
||||||
title_key = _task_title_key(title)
|
title_key = _task_title_key(title)
|
||||||
|
|
@ -99,8 +136,58 @@ async def ai_breakdown_tasks(
|
||||||
continue
|
continue
|
||||||
seen_title_keys.add(title_key)
|
seen_title_keys.add(title_key)
|
||||||
|
|
||||||
priority = str(item.get("priority") or "P1").lower()
|
resolved_skill_tag_ids, _skill_resolution = await resolve_task_breakdown_skill_tag_ids(
|
||||||
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
|
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(
|
task = await persist_task(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -108,11 +195,15 @@ async def ai_breakdown_tasks(
|
||||||
project_id=pid,
|
project_id=pid,
|
||||||
draft=build_task_create_draft(
|
draft=build_task_create_draft(
|
||||||
title=title,
|
title=title,
|
||||||
description=item.get("description"),
|
description=item.description,
|
||||||
required_skill_tag_ids=[],
|
assignee_id=assignee_id,
|
||||||
priority=mapped_priority,
|
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",
|
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),
|
actor_user_id=int(current_user.id),
|
||||||
action="task.create",
|
action="task.create",
|
||||||
|
|
@ -121,6 +212,8 @@ async def ai_breakdown_tasks(
|
||||||
refresh_workload=False,
|
refresh_workload=False,
|
||||||
)
|
)
|
||||||
created.append(task)
|
created.append(task)
|
||||||
|
if assignee_id is not None:
|
||||||
|
auto_assigned_count += 1
|
||||||
|
|
||||||
db.add(
|
db.add(
|
||||||
AICallLog(
|
AICallLog(
|
||||||
|
|
@ -128,7 +221,7 @@ async def ai_breakdown_tasks(
|
||||||
user_id=int(current_user.id),
|
user_id=int(current_user.id),
|
||||||
function_name="sstc_ai_breakdown",
|
function_name="sstc_ai_breakdown",
|
||||||
prompt=prompt,
|
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,
|
tokens_used=prompt_tokens + completion_tokens,
|
||||||
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
|
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
|
||||||
status="success",
|
status="success",
|
||||||
|
|
@ -142,9 +235,18 @@ async def ai_breakdown_tasks(
|
||||||
entity_type="task",
|
entity_type="task",
|
||||||
entity_id=str(cid),
|
entity_id=str(cid),
|
||||||
action="ai_breakdown",
|
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()
|
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:
|
for task in created:
|
||||||
_sync_pmflow_rag_async(
|
_sync_pmflow_rag_async(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -154,13 +256,30 @@ async def ai_breakdown_tasks(
|
||||||
title=task.title,
|
title=task.title,
|
||||||
content=task.description or "",
|
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)
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
return success_response(
|
return success_response(
|
||||||
{
|
{
|
||||||
"created_count": len(created),
|
"created_count": len(created),
|
||||||
"skipped_duplicates": skipped_duplicates,
|
"skipped_duplicates": skipped_duplicates,
|
||||||
|
"auto_assigned_count": auto_assigned_count,
|
||||||
"items": [
|
"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
|
for t in created
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
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.entity_lookup import _ensure_project, _ensure_task
|
||||||
from ...services.sstc.governance import _is_manager
|
from ...services.sstc.governance import _is_manager
|
||||||
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
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,
|
"estimated_hours": float(t.estimated_hours) if t.estimated_hours else None,
|
||||||
"risk_count": risk_count,
|
"risk_count": risk_count,
|
||||||
"truth_node_id": str(t.truth_node_id),
|
"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)
|
return success_response(rows)
|
||||||
|
|
@ -123,4 +129,3 @@ async def patch_task_status(
|
||||||
)
|
)
|
||||||
|
|
||||||
return success_response({"id": str(task.id), "status": task.status})
|
return success_response({"id": str(task.id), "status": task.status})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async def recommend_assignee_for_task(
|
||||||
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
|
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)
|
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(
|
recommendations = await recommend_personnel(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -128,4 +128,3 @@ async def optimize_task_workload(
|
||||||
"mode": body.mode,
|
"mode": body.mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
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.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
|
||||||
from ...services.sstc.governance import _require_manager
|
from ...services.sstc.governance import _require_manager
|
||||||
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
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,
|
"priority": task.priority,
|
||||||
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
|
"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,
|
"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
|
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:
|
if body.title is not None:
|
||||||
task.title = compact_title(body.title, max_len=32)
|
task.title = compact_title(body.title, max_len=32)
|
||||||
|
manual_overrides["title"] = task.title
|
||||||
if body.description is not None:
|
if body.description is not None:
|
||||||
task.description = body.description
|
task.description = body.description
|
||||||
|
manual_overrides["description"] = task.description
|
||||||
if body.assignee_id is not None:
|
if body.assignee_id is not None:
|
||||||
task.assignee_id = body.assignee_id
|
task.assignee_id = body.assignee_id
|
||||||
|
manual_overrides["assignee_id"] = task.assignee_id
|
||||||
if body.required_skill_tag_ids is not None:
|
if body.required_skill_tag_ids is not None:
|
||||||
task.required_skill_tag_ids = body.required_skill_tag_ids
|
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:
|
if body.priority is not None:
|
||||||
task.priority = body.priority
|
task.priority = body.priority
|
||||||
|
manual_overrides["priority"] = task.priority
|
||||||
if body.estimated_hours is not None:
|
if body.estimated_hours is not None:
|
||||||
task.estimated_hours = body.estimated_hours
|
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:
|
if body.due_date is not None:
|
||||||
task.due_date = _parse_due_date(body.due_date)
|
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(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
|
|
@ -75,6 +87,8 @@ async def patch_task(
|
||||||
"priority": task.priority,
|
"priority": task.priority,
|
||||||
"estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None,
|
"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,
|
"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)
|
await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id)
|
||||||
|
|
||||||
return success_response({"id": str(tid), "deleted": True})
|
return success_response({"id": str(tid), "deleted": True})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from ..services.sstc.charter_data import (
|
||||||
_load_available_skill_names,
|
_load_available_skill_names,
|
||||||
_load_task_breakdown_context,
|
_load_task_breakdown_context,
|
||||||
)
|
)
|
||||||
|
from ..services.task_breakdown import normalize_task_breakdown_payload
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
|
@ -47,20 +48,21 @@ async def breakdown_tasks(
|
||||||
available_skills=available_skills,
|
available_skills=available_skills,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw_text = await llm_service.generate(
|
structured = await llm_service.generate_structured_object(
|
||||||
prompt,
|
prompt,
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="generate_task_breakdown",
|
function_name="generate_task_breakdown",
|
||||||
)
|
)
|
||||||
tasks = llm_service._parse_json_array(raw_text)
|
if not structured.payload:
|
||||||
if not tasks:
|
raise ValueError("AI 拆解返回空结果")
|
||||||
raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组")
|
payload = normalize_task_breakdown_payload(structured.payload)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to generate task breakdown: {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
|
sync_result = None
|
||||||
if request.sync_to_plane:
|
if request.sync_to_plane:
|
||||||
sync_result = await plane_service.sync_tasks(tasks)
|
sync_result = await plane_service.sync_tasks(tasks)
|
||||||
|
|
@ -93,7 +95,7 @@ async def recommend_assignees(
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
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(
|
recommendations = await recommend_personnel(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,29 @@ Your goal is to break down a software feature request into specific, actionable
|
||||||
|
|
||||||
Feature Request: "{description}"
|
Feature Request: "{description}"
|
||||||
|
|
||||||
Required Output Format: JSON Array only.
|
Required Output Format: JSON Object only.
|
||||||
Each item must have:
|
{
|
||||||
- "task_name": Concise technical task description.
|
"tasks": [
|
||||||
- "type": One of ["frontend", "backend", "database"].
|
{
|
||||||
- "estimated_hours": Realistic hourly estimate (float).
|
"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:
|
Ensure the breakdown is comprehensive and strictly follows the JSON format.
|
||||||
[
|
|
||||||
{{"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.
|
|
||||||
Do not include any markdown formatting or explanation outside the JSON.
|
Do not include any markdown formatting or explanation outside the JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,16 +117,29 @@ PROMPT_DECOMPOSE_TASKS = """
|
||||||
2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
|
2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
|
||||||
3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
|
3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
|
||||||
4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。
|
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 格式:
|
输出 JSON 格式:
|
||||||
{{
|
{{
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{{
|
{{
|
||||||
"task_name": "任务名称",
|
"task_key": "task-1",
|
||||||
|
"title": "任务名称",
|
||||||
"description": "任务描述",
|
"description": "任务描述",
|
||||||
"type": "frontend|backend|database|ai|devops",
|
"type": "frontend|backend|database|ai|devops",
|
||||||
"estimated_hours": 0.0,
|
"estimated_hours": 0.0,
|
||||||
"required_skill_names": ["技能1", "技能2"],
|
"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"
|
"priority": "high|medium|low"
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ class Task(Base):
|
||||||
BigInteger, ForeignKey("users.id"), nullable=True
|
BigInteger, ForeignKey("users.id"), nullable=True
|
||||||
)
|
)
|
||||||
ai_recommended_assignees: Mapped[Optional[dict]] = mapped_column(JSONB, 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)
|
title: Mapped[str] = mapped_column(VARCHAR(500), nullable=False)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
required_skill_tag_ids: Mapped[List[int]] = mapped_column(
|
required_skill_tag_ids: Mapped[List[int]] = mapped_column(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ from .auth import (
|
||||||
from .signal import SignalCreate, SignalPriority, classify_signal_priority
|
from .signal import SignalCreate, SignalPriority, classify_signal_priority
|
||||||
from .experiment import ExperimentCreate, ExperimentUpdate
|
from .experiment import ExperimentCreate, ExperimentUpdate
|
||||||
from .context import ContextNodeCreate
|
from .context import ContextNodeCreate
|
||||||
from .common import TaskBreakdownRequest, TaskBreakdownResponse, TaskItem
|
from .common import (
|
||||||
|
TaskBreakdownBusinessObject,
|
||||||
|
TaskBreakdownPayload,
|
||||||
|
TaskBreakdownRequest,
|
||||||
|
TaskBreakdownResponse,
|
||||||
|
TaskItem,
|
||||||
|
)
|
||||||
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
|
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
|
||||||
from .model_capability import (
|
from .model_capability import (
|
||||||
ValidateMatchRequest,
|
ValidateMatchRequest,
|
||||||
|
|
@ -68,6 +74,8 @@ __all__ = [
|
||||||
"ContextNodeCreate",
|
"ContextNodeCreate",
|
||||||
"TaskBreakdownRequest",
|
"TaskBreakdownRequest",
|
||||||
"TaskBreakdownResponse",
|
"TaskBreakdownResponse",
|
||||||
|
"TaskBreakdownBusinessObject",
|
||||||
|
"TaskBreakdownPayload",
|
||||||
"TaskItem",
|
"TaskItem",
|
||||||
"SignalFeedbackCreate",
|
"SignalFeedbackCreate",
|
||||||
"SignalFeedbackResponse",
|
"SignalFeedbackResponse",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,55 @@
|
||||||
"""通用 Pydantic 模型"""
|
"""通用 Pydantic 模型"""
|
||||||
|
|
||||||
from typing import List, Optional
|
from __future__ import annotations
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class TaskItem(BaseModel):
|
class TaskBreakdownBusinessObject(BaseModel):
|
||||||
"""任务项"""
|
"""结构化任务拆解业务对象"""
|
||||||
|
|
||||||
task_name: str
|
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||||
type: str
|
|
||||||
|
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
|
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):
|
class TaskBreakdownRequest(BaseModel):
|
||||||
|
|
@ -24,5 +64,5 @@ class TaskBreakdownRequest(BaseModel):
|
||||||
class TaskBreakdownResponse(BaseModel):
|
class TaskBreakdownResponse(BaseModel):
|
||||||
"""任务拆解响应"""
|
"""任务拆解响应"""
|
||||||
|
|
||||||
tasks: List[TaskItem]
|
tasks: List[TaskBreakdownBusinessObject]
|
||||||
sync_result: Optional[dict] = None
|
sync_result: Optional[dict] = None
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,130 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
class LLMStructuredCandidatesMixin:
|
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(
|
def _iter_structured_candidates(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -167,6 +289,8 @@ class LLMStructuredCandidatesMixin:
|
||||||
fn = str(function_name or "").strip().lower()
|
fn = str(function_name or "").strip().lower()
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return payload
|
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"}:
|
if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}:
|
||||||
return cls._coerce_intent_structured_payload(payload)
|
return cls._coerce_intent_structured_payload(payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
@ -193,4 +317,10 @@ class LLMStructuredCandidatesMixin:
|
||||||
continue
|
continue
|
||||||
if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values):
|
if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values):
|
||||||
return "structured_schema_invalid"
|
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
|
return None
|
||||||
|
|
|
||||||
|
|
@ -275,4 +275,24 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||||
"assignee_reason": ("str_or_none",),
|
"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",),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
async def update_user_workload(
|
async def update_user_workload(
|
||||||
|
|
@ -60,7 +62,7 @@ async def update_user_workload(
|
||||||
async def recommend_personnel(
|
async def recommend_personnel(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
required_skills: List[str],
|
required_skills: List[str | int],
|
||||||
task_description: Optional[str] = None,
|
task_description: Optional[str] = None,
|
||||||
task_id: Optional[str] = None,
|
task_id: Optional[str] = None,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
|
|
@ -78,12 +80,42 @@ async def recommend_personnel(
|
||||||
"total_count": 0,
|
"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(
|
skill_tags_result = await db.execute(
|
||||||
select(SkillTag).where(
|
select(SkillTag).where(
|
||||||
and_(
|
and_(
|
||||||
SkillTag.tenant_id == tenant_id,
|
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,
|
"recommendations": recommendations,
|
||||||
"total_count": len(recommendations),
|
"total_count": len(recommendations),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -19,6 +19,8 @@ class TaskCreateDraft:
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
required_skill_tag_ids: list[int] = field(default_factory=list)
|
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"
|
priority: str = "medium"
|
||||||
status: str = "todo"
|
status: str = "todo"
|
||||||
estimated_hours: Optional[float] = None
|
estimated_hours: Optional[float] = None
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ def build_task_create_draft(
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
assignee_id: int | None = None,
|
assignee_id: int | None = None,
|
||||||
required_skill_tag_ids: Any = 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",
|
priority: Any = "medium",
|
||||||
status: Any = "todo",
|
status: Any = "todo",
|
||||||
estimated_hours: Any = None,
|
estimated_hours: Any = None,
|
||||||
|
|
@ -109,6 +111,8 @@ def build_task_create_draft(
|
||||||
description=str(description or "").strip() or None,
|
description=str(description or "").strip() or None,
|
||||||
assignee_id=int(assignee_id) if assignee_id is not None else 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),
|
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),
|
priority=normalize_task_priority(priority),
|
||||||
status=normalize_task_status(status),
|
status=normalize_task_status(status),
|
||||||
estimated_hours=normalize_estimated_hours(estimated_hours),
|
estimated_hours=normalize_estimated_hours(estimated_hours),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ async def persist_task(
|
||||||
title=draft.title,
|
title=draft.title,
|
||||||
description=draft.description,
|
description=draft.description,
|
||||||
required_skill_tag_ids=draft.required_skill_tag_ids,
|
required_skill_tag_ids=draft.required_skill_tag_ids,
|
||||||
|
ai_breakdown_payload=draft.ai_breakdown_payload,
|
||||||
|
manual_overrides=draft.manual_overrides,
|
||||||
status=draft.status,
|
status=draft.status,
|
||||||
priority=draft.priority,
|
priority=draft.priority,
|
||||||
estimated_hours=draft.estimated_hours,
|
estimated_hours=draft.estimated_hours,
|
||||||
|
|
@ -55,6 +57,8 @@ async def persist_task(
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
"assignee_id": int(task.assignee_id) if task.assignee_id is not None else None,
|
"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 []),
|
"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,
|
"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,
|
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ from types import SimpleNamespace
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.api import tasks as tasks_api
|
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 (
|
from app.services.sstc.charter_data import (
|
||||||
_build_task_breakdown_context,
|
_build_task_breakdown_context,
|
||||||
_build_task_breakdown_prompt,
|
_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"
|
return "FastAPI, React, LangChain"
|
||||||
|
|
||||||
class _FakeLLM:
|
class _FakeLLM:
|
||||||
async def generate(self, prompt: str, **kwargs):
|
async def generate_structured_object(self, prompt: str, **kwargs):
|
||||||
captured["prompt"] = prompt
|
captured["prompt"] = prompt
|
||||||
captured["llm_kwargs"] = kwargs
|
captured["llm_kwargs"] = kwargs
|
||||||
return json.dumps(
|
payload = {
|
||||||
[
|
"tasks": [
|
||||||
{
|
{
|
||||||
"task_name": "需求澄清与范围确认",
|
"task_key": "task-1",
|
||||||
|
"title": "需求澄清与范围确认",
|
||||||
|
"description": "确认导出知识库的范围",
|
||||||
"type": "ai",
|
"type": "ai",
|
||||||
"estimated_hours": 4,
|
"estimated_hours": 4,
|
||||||
"priority": "high",
|
|
||||||
"required_skill_names": ["FastAPI"],
|
"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",
|
"type": "backend",
|
||||||
"estimated_hours": 12,
|
"estimated_hours": 12,
|
||||||
"priority": "medium",
|
|
||||||
"required_skill_names": ["React", "LangChain"],
|
"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:
|
class _FakePlane:
|
||||||
async def sync_tasks(self, tasks, is_urgent: bool = False):
|
async def sync_tasks(self, tasks, is_urgent: bool = False):
|
||||||
_ = is_urgent
|
_ = 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 "内部知识库升级宪章" in str(captured["prompt"])
|
||||||
assert "FastAPI, React, LangChain" in str(captured["prompt"])
|
assert "FastAPI, React, LangChain" in str(captured["prompt"])
|
||||||
assert result["sync_result"]["synced_count"] == 2
|
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
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,53 @@ export interface SSTCTask {
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
risk_count?: number;
|
risk_count?: number;
|
||||||
truth_node_id?: string;
|
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 {
|
export interface SSTCTaskRecommendation {
|
||||||
|
|
@ -117,7 +164,7 @@ export interface SSTCTaskRecommendation {
|
||||||
|
|
||||||
export interface SSTCTaskAssigneeRecommendationResponse {
|
export interface SSTCTaskAssigneeRecommendationResponse {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
required_skills: string[];
|
required_skills: Array<string | number>;
|
||||||
recommendations: SSTCTaskRecommendation[];
|
recommendations: SSTCTaskRecommendation[];
|
||||||
total_count: number;
|
total_count: number;
|
||||||
}
|
}
|
||||||
|
|
@ -569,12 +616,9 @@ export const sstcApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async aiBreakdownTasks(projectId: string, charterId: string) {
|
async aiBreakdownTasks(projectId: string, charterId: string) {
|
||||||
const { data } = await api.post<
|
const { data } = await api.post<ApiEnvelope<SSTCTaskAIBreakdownResponse>>(
|
||||||
ApiEnvelope<{
|
`/projects/${projectId}/charters/${charterId}/tasks/ai-breakdown`
|
||||||
created_count: number;
|
);
|
||||||
items: Array<{ id: string; title: string; priority: string; status: string }>;
|
|
||||||
}>
|
|
||||||
>(`/projects/${projectId}/charters/${charterId}/tasks/ai-breakdown`);
|
|
||||||
return unwrap(data);
|
return unwrap(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { RefreshCcw } from "lucide-react";
|
import { RefreshCcw } from "lucide-react";
|
||||||
import { type DragEvent, useMemo, useState } from "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 type { User } from "@/api/users";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -36,6 +36,31 @@ interface SSTCExecutionBoardPanelProps {
|
||||||
handleDropTask: (targetStatus: SSTCTask["status"], ev: DragEvent<HTMLDivElement>) => void;
|
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({
|
export function SSTCExecutionBoardPanel({
|
||||||
stageLocked,
|
stageLocked,
|
||||||
projectUsers,
|
projectUsers,
|
||||||
|
|
@ -51,6 +76,8 @@ export function SSTCExecutionBoardPanel({
|
||||||
() => new Map(projectUsers.map((user) => [String(user.id), user.name || user.username || `用户 ${user.id}`])),
|
() => new Map(projectUsers.map((user) => [String(user.id), user.name || user.username || `用户 ${user.id}`])),
|
||||||
[projectUsers]
|
[projectUsers]
|
||||||
);
|
);
|
||||||
|
const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null;
|
||||||
|
const detailOverrides = detailTask ? getOverrideFields(detailTask) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -108,47 +135,75 @@ export function SSTCExecutionBoardPanel({
|
||||||
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
|
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{executionLanes[lane.key].map((t) => (
|
{executionLanes[lane.key].map((t) => {
|
||||||
<div
|
const breakdown = getTaskBreakdownObject(t);
|
||||||
key={t.id}
|
const overrideFields = getOverrideFields(t);
|
||||||
draggable={!stageLocked}
|
return (
|
||||||
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
|
<div
|
||||||
onClick={() => setDetailTask(t)}
|
key={t.id}
|
||||||
className="cursor-pointer rounded-md border bg-card p-2"
|
draggable={!stageLocked}
|
||||||
>
|
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
|
||||||
<p className="line-clamp-2 text-xs font-medium sm:text-sm">{t.title}</p>
|
onClick={() => setDetailTask(t)}
|
||||||
<div className="mt-1 flex items-center justify-between">
|
className="cursor-pointer rounded-md border bg-card p-2"
|
||||||
<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 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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -187,6 +242,51 @@ export function SSTCExecutionBoardPanel({
|
||||||
<p className="text-xs text-slate-500">任务描述</p>
|
<p className="text-xs text-slate-500">任务描述</p>
|
||||||
<p className="mt-1 whitespace-pre-wrap text-slate-700">{detailTask.description || "-"}</p>
|
<p className="mt-1 whitespace-pre-wrap text-slate-700">{detailTask.description || "-"}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type {
|
||||||
SSTCCharter,
|
SSTCCharter,
|
||||||
SSTCTask,
|
SSTCTask,
|
||||||
SSTCTaskAssigneeRecommendationResponse,
|
SSTCTaskAssigneeRecommendationResponse,
|
||||||
|
SSTCTaskBreakdownBusinessObject,
|
||||||
|
SSTCTaskBreakdownPayload,
|
||||||
SSTCTaskRecommendation,
|
SSTCTaskRecommendation,
|
||||||
} from "@/api/sstc";
|
} from "@/api/sstc";
|
||||||
import type { User } from "@/api/users";
|
import type { User } from "@/api/users";
|
||||||
|
|
@ -64,6 +66,40 @@ interface RecommendAssigneeMutationLike {
|
||||||
isPending: boolean;
|
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 {
|
interface SSTCTaskStagePanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
stageLocked: boolean;
|
stageLocked: boolean;
|
||||||
|
|
@ -267,11 +303,15 @@ export function SSTCTaskStagePanel({
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{charterTasks.map((t) => {
|
{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 status = String(t.status || "").toLowerCase();
|
||||||
const done = status === "done" || status === "completed";
|
const done = status === "done" || status === "completed";
|
||||||
const topRec = recs[0];
|
const topRec = recs[0];
|
||||||
const sourceCharterTitle = selectedCharter ? compactTitle(selectedCharter.title, 16) : "-";
|
const sourceCharterTitle = selectedCharter ? compactTitle(selectedCharter.title, 16) : "-";
|
||||||
|
const breakdown = getTaskBreakdownObject(t);
|
||||||
|
const overrideFields = getOverrideFields(t);
|
||||||
|
const autoAssigned = Boolean(t.auto_assigned);
|
||||||
return (
|
return (
|
||||||
<TableRow key={t.id}>
|
<TableRow key={t.id}>
|
||||||
<TableCell className="text-center align-top">
|
<TableCell className="text-center align-top">
|
||||||
|
|
@ -283,26 +323,60 @@ export function SSTCTaskStagePanel({
|
||||||
aria-label={`选择任务 ${t.title}`}
|
aria-label={`选择任务 ${t.title}`}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<p className="font-medium">{t.title}</p>
|
<div className="space-y-2">
|
||||||
{t.description && <p className="text-xs text-muted-foreground">{t.description}</p>}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
<p className="font-medium text-slate-900">{t.title}</p>
|
||||||
<span>来源:{sourceCharterTitle}</span>
|
{autoAssigned && (
|
||||||
<span>风险:{t.risk_count ?? 0}</span>
|
<Badge className="border border-emerald-200 bg-emerald-50 text-[10px] text-emerald-700">
|
||||||
<span>任务ID:{String(t.id).slice(-6)}</span>
|
AI 自动指派
|
||||||
</div>
|
</Badge>
|
||||||
{recs.length > 0 && (
|
)}
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
{overrideFields.length > 0 && (
|
||||||
{recs.slice(0, 4).map((r) => (
|
<Badge className="border border-amber-200 bg-amber-50 text-[10px] text-amber-700">
|
||||||
<span
|
人工覆盖 {overrideFields.length}
|
||||||
key={`${t.id}-${r.user_id}`}
|
</Badge>
|
||||||
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>
|
||||||
)}
|
{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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={`border whitespace-nowrap ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
<Badge className={`border whitespace-nowrap ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue