PMFlow/backend/app/services/sstc/charter_data.py

431 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = {}
derived_artifacts = content.get("derived_artifacts")
if not isinstance(derived_artifacts, dict):
derived_artifacts = {}
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,
),
"task_seeds": [
{
"title": _compact_task_breakdown_text(item.get("title"), max_len=100),
"description": _compact_task_breakdown_text(item.get("description"), max_len=160),
"evidence_refs": _compact_task_breakdown_items(item.get("evidence_refs") or [], max_items=5, max_len=80),
}
for item in (derived_artifacts.get("task_seeds") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("title"), max_len=100)
],
"verification_metrics": [
_compact_task_breakdown_text(item.get("name") or item.get("title"), max_len=100)
for item in (derived_artifacts.get("verification_metrics") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("name") or item.get("title"), max_len=100)
],
"risk_gates": [
{
"name": _compact_task_breakdown_text(item.get("name"), max_len=100),
"action": _compact_task_breakdown_text(item.get("action"), max_len=140),
}
for item in (derived_artifacts.get("risk_gates") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("name"), 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,
)