406 lines
13 KiB
Python
406 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
from ...config.ai_prompts import PROMPT_DECOMPOSE_TASKS
|
||
from .signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _signal_display_type, _signal_project_scope, _signal_source_context
|
||
from .signal_filters import _signal_proposer_name, _signal_title_text
|
||
|
||
_DEFAULT_TASK_BREAKDOWN_SKILLS = (
|
||
"Python",
|
||
"TypeScript",
|
||
"FastAPI",
|
||
"React",
|
||
"SQLAlchemy",
|
||
"LangChain",
|
||
"Docker",
|
||
"LLM调优",
|
||
)
|
||
|
||
async def _fetch_valid_project_signal_ids(
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
signal_ids: list[int],
|
||
) -> set[int]:
|
||
link_result = await db.execute(
|
||
select(ProjectSignalLink.signal_id).where(
|
||
and_(
|
||
ProjectSignalLink.project_id == project_id,
|
||
ProjectSignalLink.signal_id.in_(signal_ids),
|
||
ProjectSignalLink.tenant_id == tenant_id,
|
||
)
|
||
)
|
||
)
|
||
return {int(v) for v in link_result.scalars().all()}
|
||
|
||
async def _load_signal_rows(
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
signal_ids: list[int],
|
||
) -> list[Signal]:
|
||
if not signal_ids:
|
||
return []
|
||
return (
|
||
await db.execute(
|
||
select(Signal).where(
|
||
and_(
|
||
Signal.tenant_id == tenant_id,
|
||
Signal.id.in_(signal_ids),
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
|
||
def _build_charter_summary_payload(charter: TruthNode) -> dict[str, Any]:
|
||
content = _as_dict(charter.content)
|
||
return {
|
||
"id": str(charter.id),
|
||
"title": charter.title,
|
||
"content": content,
|
||
"signal_ids": content.get("signal_ids", []),
|
||
"status": charter.status,
|
||
"version": charter.version,
|
||
}
|
||
|
||
|
||
def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]:
|
||
content = _as_dict(charter.content)
|
||
modules = content.get("modules")
|
||
if not isinstance(modules, dict):
|
||
modules = {}
|
||
module_1 = modules.get("module_1") if isinstance(modules.get("module_1"), dict) else {}
|
||
module_2 = modules.get("module_2") if isinstance(modules.get("module_2"), dict) else {}
|
||
module_4 = modules.get("module_4") if isinstance(modules.get("module_4"), dict) else {}
|
||
module_5 = modules.get("module_5") if isinstance(modules.get("module_5"), dict) else {}
|
||
module_6 = modules.get("module_6") if isinstance(modules.get("module_6"), dict) else {}
|
||
|
||
signal_ids = content.get("signal_ids")
|
||
normalized_signal_ids: list[int] = []
|
||
if isinstance(signal_ids, list):
|
||
for sid in signal_ids:
|
||
try:
|
||
normalized_signal_ids.append(int(sid))
|
||
except Exception:
|
||
continue
|
||
|
||
return {
|
||
"id": int(charter.id),
|
||
"title": _compact_task_breakdown_text(charter.title or "", max_len=120) or "未命名宪章",
|
||
"version": int(charter.version or 1),
|
||
"status": _compact_task_breakdown_text(charter.status or "", max_len=32) or "unknown",
|
||
"summary": _compact_task_breakdown_text(
|
||
content.get("summary")
|
||
or content.get("rationale")
|
||
or content.get("background")
|
||
or module_1.get("problem_statement")
|
||
or "",
|
||
max_len=180,
|
||
),
|
||
"objectives": _compact_task_breakdown_items(
|
||
content.get("objectives")
|
||
or module_1.get("goal_statement")
|
||
or [],
|
||
max_items=5,
|
||
max_len=100,
|
||
),
|
||
"scope": _compact_task_breakdown_text(
|
||
content.get("scope")
|
||
or module_2.get("alignment_qa")
|
||
or "",
|
||
max_len=240,
|
||
),
|
||
"in_scope": _compact_task_breakdown_items(
|
||
content.get("in_scope")
|
||
or module_2.get("in_scope")
|
||
or [],
|
||
max_items=5,
|
||
max_len=100,
|
||
),
|
||
"out_of_scope": _compact_task_breakdown_items(
|
||
content.get("out_of_scope")
|
||
or module_2.get("out_of_scope")
|
||
or [],
|
||
max_items=5,
|
||
max_len=100,
|
||
),
|
||
"success_criteria": _compact_task_breakdown_items(
|
||
content.get("success_criteria")
|
||
or content.get("success_metrics")
|
||
or content.get("core_metrics")
|
||
or module_1.get("kpis")
|
||
or [],
|
||
max_items=6,
|
||
max_len=100,
|
||
),
|
||
"constraints": _compact_task_breakdown_items(
|
||
content.get("constraints")
|
||
or module_5.get("execution_rule")
|
||
or module_6.get("governance_rule")
|
||
or module_2.get("alignment_qa")
|
||
or [],
|
||
max_items=6,
|
||
max_len=100,
|
||
),
|
||
"milestones": _compact_task_breakdown_items(
|
||
content.get("milestones")
|
||
or module_4.get("milestones")
|
||
or [],
|
||
max_items=5,
|
||
max_len=100,
|
||
),
|
||
"risks": _compact_task_breakdown_items(
|
||
content.get("risks")
|
||
or module_5.get("risk_register")
|
||
or [],
|
||
max_items=5,
|
||
max_len=100,
|
||
),
|
||
"signal_ids": normalized_signal_ids,
|
||
}
|
||
|
||
def _serialize_charter_signal(signal: Signal) -> dict[str, Any]:
|
||
return {
|
||
"id": str(signal.id),
|
||
"source": signal.source,
|
||
"signal_type": _signal_display_type(signal),
|
||
"project_scope": _signal_project_scope(signal),
|
||
"proposer_name": _signal_proposer_name(signal),
|
||
"title": _signal_title_text(signal)[:80] or "未命名信号",
|
||
"content": signal.content,
|
||
"priority": signal.priority,
|
||
"impact_score": signal.impact_score,
|
||
"attachments": _extract_signal_attachments(signal),
|
||
"strategic_analysis": _as_dict(signal.strategic_analysis),
|
||
"planner_status": _normalize_planner_status(getattr(signal, "planner_status", None)),
|
||
"planner_analyzed_at": (
|
||
signal.planner_analyzed_at.isoformat() if getattr(signal, "planner_analyzed_at", None) else None
|
||
),
|
||
"planner_assignee_user_id": (
|
||
str(signal.planner_assignee_user_id) if getattr(signal, "planner_assignee_user_id", None) is not None else None
|
||
),
|
||
}
|
||
|
||
def _serialize_source_signal(signal: Signal) -> dict[str, Any]:
|
||
return {
|
||
"id": str(signal.id),
|
||
"title": _signal_title_text(signal)[:80] or "未命名信号",
|
||
"signal_type": _signal_display_type(signal),
|
||
"priority": signal.priority,
|
||
"content": signal.content,
|
||
"attachments": _extract_signal_attachments(signal),
|
||
"strategic_analysis": _as_dict(signal.strategic_analysis),
|
||
"planner_status": _normalize_planner_status(getattr(signal, "planner_status", None)),
|
||
"planner_analyzed_at": (
|
||
signal.planner_analyzed_at.isoformat()
|
||
if getattr(signal, "planner_analyzed_at", None)
|
||
else None
|
||
),
|
||
"planner_assignee_user_id": (
|
||
str(signal.planner_assignee_user_id)
|
||
if getattr(signal, "planner_assignee_user_id", None) is not None
|
||
else None
|
||
),
|
||
}
|
||
|
||
def _select_source_signal(charter: TruthNode, signal_rows: list[Signal]) -> Optional[Signal]:
|
||
if charter.signal_id:
|
||
for signal in signal_rows:
|
||
if int(signal.id) == int(charter.signal_id):
|
||
return signal
|
||
if signal_rows:
|
||
return signal_rows[0]
|
||
return None
|
||
|
||
def _serialize_charter_task(task: Task) -> dict[str, Any]:
|
||
return {
|
||
"id": str(task.id),
|
||
"title": task.title,
|
||
"description": task.description,
|
||
"status": task.status,
|
||
"priority": task.priority,
|
||
"estimated_hours": float(task.estimated_hours) if task.estimated_hours else None,
|
||
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
|
||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||
}
|
||
|
||
def _serialize_alignment_record(
|
||
record: AlignmentRecord,
|
||
user_name: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
confirmed_at = None
|
||
if bool(record.is_confirmed):
|
||
ts = record.updated_at or record.created_at
|
||
confirmed_at = ts.isoformat() if ts else None
|
||
|
||
return {
|
||
"user_id": str(record.user_id),
|
||
"user_name": user_name or f"用户 {record.user_id}",
|
||
"score": record.alignment_score,
|
||
"confirmed": bool(record.is_confirmed),
|
||
"comments": record.comments,
|
||
"confirmed_at": confirmed_at,
|
||
}
|
||
|
||
|
||
def _compact_task_breakdown_text(value: object, *, max_len: int = 120) -> str:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return ""
|
||
text = re.sub(r"\s+", " ", text)
|
||
return text[:max_len]
|
||
|
||
|
||
def _compact_task_breakdown_items(
|
||
value: object,
|
||
*,
|
||
max_items: int = 6,
|
||
max_len: int = 120,
|
||
) -> list[str]:
|
||
if isinstance(value, list):
|
||
raw_items = value
|
||
elif isinstance(value, str):
|
||
raw_items = re.split(r"[\n\r,,;;、/|]+", value)
|
||
else:
|
||
raw_items = []
|
||
|
||
items: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in raw_items:
|
||
text = _compact_task_breakdown_text(raw, max_len=max_len)
|
||
if not text:
|
||
continue
|
||
key = text.lower()
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
items.append(text)
|
||
if len(items) >= max_items:
|
||
break
|
||
return items
|
||
|
||
|
||
def _build_task_breakdown_context(
|
||
*,
|
||
description: str,
|
||
project: Optional[Project] = None,
|
||
charter: Optional[TruthNode] = None,
|
||
) -> dict[str, Any]:
|
||
project_payload: dict[str, Any] = {}
|
||
if project is not None:
|
||
project_payload = {
|
||
"id": int(project.id),
|
||
"title": _compact_task_breakdown_text(project.title or project.name or "", max_len=120) or "未命名项目",
|
||
"description": _compact_task_breakdown_text(project.description or "", max_len=240),
|
||
"status": _compact_task_breakdown_text(project.status or "", max_len=32),
|
||
"assigned_role": _compact_task_breakdown_text(project.assigned_role or "", max_len=32),
|
||
}
|
||
|
||
charter_payload: dict[str, Any] = {}
|
||
if charter is not None:
|
||
charter_payload = _build_charter_context_payload(charter)
|
||
|
||
return {
|
||
"request": {
|
||
"description": _compact_task_breakdown_text(description, max_len=1200),
|
||
},
|
||
"project": project_payload,
|
||
"charter": charter_payload,
|
||
}
|
||
|
||
|
||
async def _load_available_skill_names(
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
) -> str:
|
||
rows = (
|
||
await db.execute(
|
||
select(SkillTag.name)
|
||
.where(SkillTag.tenant_id == tenant_id)
|
||
.order_by(SkillTag.sort_order.asc(), SkillTag.name.asc())
|
||
)
|
||
).scalars().all()
|
||
names: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in rows:
|
||
name = str(raw or "").strip()
|
||
if not name:
|
||
continue
|
||
key = name.lower()
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
names.append(name)
|
||
if not names:
|
||
names = list(_DEFAULT_TASK_BREAKDOWN_SKILLS)
|
||
return ", ".join(names)
|
||
|
||
|
||
async def _load_task_breakdown_context(
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
description: str,
|
||
project_id: Optional[int] = None,
|
||
charter_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
project = None
|
||
if project_id is not None:
|
||
project = (
|
||
await db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == int(tenant_id),
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
charter = None
|
||
if charter_id is not None:
|
||
charter_query = select(TruthNode).where(
|
||
and_(
|
||
TruthNode.tenant_id == int(tenant_id),
|
||
TruthNode.id == int(charter_id),
|
||
TruthNode.node_type == "charter",
|
||
)
|
||
)
|
||
if project_id is not None:
|
||
charter_query = charter_query.where(TruthNode.project_id == int(project_id))
|
||
charter = (await db.execute(charter_query)).scalar_one_or_none()
|
||
elif project_id is not None:
|
||
charter = (
|
||
await db.execute(
|
||
select(TruthNode).where(
|
||
and_(
|
||
TruthNode.tenant_id == int(tenant_id),
|
||
TruthNode.project_id == int(project_id),
|
||
TruthNode.node_type == "charter",
|
||
)
|
||
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
|
||
)
|
||
).scalars().first()
|
||
|
||
return _build_task_breakdown_context(
|
||
description=description,
|
||
project=project,
|
||
charter=charter,
|
||
)
|
||
|
||
|
||
def _build_task_breakdown_prompt(
|
||
*,
|
||
project_id: Optional[int],
|
||
context: dict[str, Any],
|
||
available_skills: str,
|
||
) -> str:
|
||
return PROMPT_DECOMPOSE_TASKS.format(
|
||
project_id=str(project_id or "未指定"),
|
||
charter_content=json.dumps(context, ensure_ascii=False),
|
||
available_skills=available_skills,
|
||
)
|