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