"""PMFlow Project Launch draft and confirm service.""" from __future__ import annotations import asyncio import hashlib import json from datetime import timedelta from typing import Any, Optional from uuid import uuid4 from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import PROMPT_PMFLOW_LAUNCH_DRAFT from ..models import ( PMFlowLaunchDraft, PMFlowLaunchDraftJob, PMFlowThread, PMFlowThreadMessage, Project, SemanticFact, TruthNode, UserProject, ) from ..schemas.pmflow_launch import ( PMFlowLaunchAttachment, PMFlowLaunchConfirmResponse, PMFlowLaunchDraftJobRequest, PMFlowLaunchDraftJobResponse, PMFlowLaunchDraftPayload, PMFlowLaunchDraftRequest, PMFlowLaunchDraftResponse, ) from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfile, AIFunctionRuntimeProfileService from ..services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ..services.ai_scene_runtime_policy import AISceneRuntimePolicyService from ..services.context_cache_service import context_cache_service from ..services.llm_service import llm_service from ..services.operation_log_service import log_operation from ..services.ai_routing_governance_service import PLATFORM_RUNTIME_TENANT_ID from ..services.prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService from ..services.task_persistence_core import build_task_create_draft, persist_task from ..utils.sequence import ensure_project_id_sequence from ..utils.timezone import now_cn_naive from ..utils.title import compact_title class PMFlowLaunchError(RuntimeError): def __init__(self, code: str, message: str, *, status_code: int = 400): super().__init__(message) self.code = code self.message = message self.status_code = int(status_code) class PMFlowLaunchService: """Owns the two-step Project Launch write chain.""" DRAFT_TTL_HOURS = 24 JOB_TTL_HOURS = 24 DRAFT_GENERATION_MAX_TOKENS = 7200 DRAFT_PROMPT_KEY = "PROMPT_PMFLOW_LAUNCH_DRAFT" DRAFT_SCENE_KEY = "project_launch" DRAFT_FUNCTION_NAME = "pmflow_launch_draft_v1" def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db self.tenant_id = int(tenant_id) async def _generate_draft_structured( self, *, body: PMFlowLaunchDraftRequest, user_id: int | None = None, profile_stage: str = "active", routing_meta_extra: dict[str, Any] | None = None, ): timeout_policy_meta = self._draft_timeout_policy_meta(body=body) runtime_profile = await self._resolve_draft_runtime_profile(stage=profile_stage) effective_runtime_meta = { **timeout_policy_meta, **runtime_profile.routing_meta(), } prompt_body, preprocess_meta = self._apply_large_input_policy( body=body, routing_meta=effective_runtime_meta, ) prompt_result = await self._render_draft_prompt(body=prompt_body) routing_meta = { "scene": "pmflow_project_launch", "tier_alias": "low", "response_contract": "structured", "prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID, **(routing_meta_extra if isinstance(routing_meta_extra, dict) else {}), **effective_runtime_meta, **preprocess_meta, **prompt_result.routing_meta(), } job_timeout_seconds = self._bounded_seconds( routing_meta.get("job_timeout_seconds"), default=runtime_profile.job_timeout_seconds, minimum=10, maximum=900, ) try: return await asyncio.wait_for( llm_service.generate_structured_object( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name=self.DRAFT_FUNCTION_NAME, temperature=runtime_profile.temperature, max_tokens=runtime_profile.max_tokens, response_mode=runtime_profile.response_mode, routing_meta=routing_meta, user_id=user_id, ), timeout=float(job_timeout_seconds), ) except asyncio.TimeoutError as exc: raise PMFlowLaunchError( "draft_job_timeout", f"项目草案生成超过 Job 超时 {job_timeout_seconds} 秒,已停止本次生成。", status_code=504, ) from exc async def create_draft( self, *, body: PMFlowLaunchDraftRequest, user_id: int, ) -> PMFlowLaunchDraftResponse: self._assert_input_ready(body) try: structured = await self._generate_draft_structured(body=body, user_id=int(user_id)) except PMFlowLaunchError: raise except RuntimeError as exc: raise PMFlowLaunchError("draft_generation_failed", str(exc), status_code=502) from exc except Exception as exc: raise PMFlowLaunchError("draft_generation_failed", f"项目草案生成失败: {exc}", status_code=502) from exc payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} draft_payload = self._normalize_draft_payload(payload) self._validate_draft_payload(draft_payload, require_confirmable=False) warnings = self._draft_count_warnings(draft_payload) merged_warnings = self._merge_warnings(draft_payload.warnings, warnings) draft_payload.warnings = merged_warnings expires_at = now_cn_naive() + timedelta(hours=self.DRAFT_TTL_HOURS) draft_id = f"draft_{uuid4().hex[:16]}" row = PMFlowLaunchDraft( tenant_id=self.tenant_id, user_id=int(user_id), draft_id=draft_id, input_text=str(body.input or "").strip(), input_type=str(body.input_type or "text"), source=str(body.source or "web")[:32], attachments=[item.model_dump(mode="json") for item in body.attachments], draft_payload=draft_payload.model_dump(mode="json"), edited_payload=None, status="draft", confidence=float(draft_payload.confidence), warnings=merged_warnings, expires_at=expires_at, ) self.db.add(row) await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(user_id), project_id=None, entity_type="pmflow_launch_draft", entity_id=draft_id, action="pmflow_launch.draft.create", before_data=None, after_data={ "draft_id": draft_id, "title": draft_payload.title, "warnings": merged_warnings, }, interface_path="/pmflow-launch/drafts", caller="user", ) try: await self.db.commit() except Exception: await self.db.rollback() raise return PMFlowLaunchDraftResponse( **draft_payload.model_dump(mode="json"), draft_id=draft_id, status="draft", expires_at=expires_at.isoformat(), ) async def generate_draft_payload_for_readiness_check(self, *, user_id: int, profile_stage: str = "active") -> PMFlowLaunchDraftPayload: """Run the protected Project Launch structured chain without writing a draft.""" body = PMFlowLaunchDraftRequest( input=( "我想做一个 AI 会议纪要助手,帮助个人开发者和小团队把会议文本整理成目标、" "需求、风险和第一批任务。" ), input_type="text", attachments=[], source="system_smoke", ) try: structured = await self._generate_draft_structured( body=body, user_id=int(user_id), profile_stage=profile_stage, routing_meta_extra={ "tier_alias": "structured", "readiness_check": True, "source": "system_smoke", }, ) except PMFlowLaunchError: raise except RuntimeError as exc: raise PMFlowLaunchError("draft_smoke_failed", str(exc), status_code=502) from exc except Exception as exc: raise PMFlowLaunchError("draft_smoke_failed", f"项目草案 smoke test 失败: {exc}", status_code=502) from exc payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} draft_payload = self._normalize_draft_payload(payload) self._validate_draft_payload(draft_payload, require_confirmable=False) draft_payload.warnings = self._merge_warnings(draft_payload.warnings, self._draft_count_warnings(draft_payload)) return draft_payload async def create_draft_job( self, *, body: PMFlowLaunchDraftJobRequest, user_id: int, ) -> PMFlowLaunchDraftJobResponse: self._assert_input_ready(body) parent_draft_id = str(body.parent_draft_id or "").strip() or None if parent_draft_id: await self._load_draft_for_update(draft_id=parent_draft_id, user_id=user_id) job_id = f"job_{uuid4().hex[:16]}" revision_id = f"rev_{uuid4().hex[:16]}" expires_at = now_cn_naive() + timedelta(hours=self.JOB_TTL_HOURS) await self._supersede_active_jobs(user_id=user_id, superseded_by_job_id=job_id) row = PMFlowLaunchDraftJob( tenant_id=self.tenant_id, user_id=int(user_id), job_id=job_id, revision_id=revision_id, parent_draft_id=parent_draft_id, superseded_by_job_id=None, result_draft_id=None, input_text=str(body.input or "").strip(), input_type=str(body.input_type or "text"), source=str(body.source or "web")[:32], attachments=[item.model_dump(mode="json") for item in body.attachments], mode=str(body.mode or "create"), revision_note=str(body.revision_note or "").strip() or None, status="queued", result_payload=None, changed_fields=[], error_code=None, error_message=None, expires_at=expires_at, ) self.db.add(row) await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(user_id), project_id=None, entity_type="pmflow_launch_draft_job", entity_id=job_id, action="pmflow_launch.draft_job.create", before_data=None, after_data={ "job_id": job_id, "revision_id": revision_id, "mode": row.mode, "parent_draft_id": parent_draft_id, }, interface_path="/pmflow-launch/draft-jobs", caller="user", ) try: await self.db.commit() except Exception: await self.db.rollback() raise return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) async def get_draft_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJobResponse: row = await self._load_job(job_id=job_id, user_id=user_id) return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) async def cancel_draft_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJobResponse: row = await self._load_job_for_update(job_id=job_id, user_id=user_id) if row.status in {"queued", "running", "user_paused"}: row.status = "cancelled" row.completed_at = now_cn_naive() row.error_code = "cancelled" row.error_message = "用户已取消本次生成。" await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(user_id), project_id=None, entity_type="pmflow_launch_draft_job", entity_id=row.job_id, action="pmflow_launch.draft_job.cancel", before_data={"status": "running"}, after_data={"status": "cancelled"}, interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}/cancel", caller="user", ) await self.db.commit() return self._job_response(row, runtime_policy=await self._draft_job_runtime_policy()) async def run_draft_job(self, *, job_id: str) -> None: row = await self._load_job_for_worker(job_id=job_id) if row.status != "queued": return row.status = "running" row.started_at = now_cn_naive() await self.db.commit() try: body = await self._job_to_draft_request(row) structured = await self._generate_draft_structured( body=body, user_id=int(row.user_id), routing_meta_extra={ "launch_job_id": row.job_id, "launch_revision_id": row.revision_id, "launch_job_mode": row.mode, }, ) payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} draft_payload = self._normalize_draft_payload(payload) self._validate_draft_payload(draft_payload, require_confirmable=False) draft_payload.warnings = self._merge_warnings(draft_payload.warnings, self._draft_count_warnings(draft_payload)) changed_fields = await self._changed_fields(parent_draft_id=row.parent_draft_id, payload=draft_payload, user_id=int(row.user_id)) await self._complete_draft_job(row=row, payload=draft_payload, changed_fields=changed_fields) except PMFlowLaunchError as exc: await self._fail_draft_job(row=row, code=exc.code, message=exc.message) except RuntimeError as exc: await self._fail_draft_job(row=row, code="draft_generation_failed", message=str(exc)) except Exception as exc: await self._fail_draft_job(row=row, code="draft_generation_failed", message=f"项目草案生成失败: {exc}") async def _render_draft_prompt(self, *, body: PMFlowLaunchDraftRequest) -> PromptRuntimeRenderResult: return await PromptRuntimeService(self.db).render( tenant_id=PLATFORM_RUNTIME_TENANT_ID, prompt_key=self.DRAFT_PROMPT_KEY, fallback_template=PROMPT_PMFLOW_LAUNCH_DRAFT, input_text=str(body.input or "")[:4000], attachments_json=json.dumps([item.model_dump(mode="json") for item in body.attachments], ensure_ascii=False), ) async def _resolve_draft_runtime_profile(self, *, stage: str = "active") -> AIFunctionRuntimeProfile: try: return await AIFunctionRuntimeProfileService(self.db).resolve_profile( function_name=self.DRAFT_FUNCTION_NAME, scene_key=self.DRAFT_SCENE_KEY, prompt_key=self.DRAFT_PROMPT_KEY, stage=stage, ) except Exception: return AIFunctionRuntimeProfile( function_name=self.DRAFT_FUNCTION_NAME, scene_key=self.DRAFT_SCENE_KEY, prompt_key=self.DRAFT_PROMPT_KEY, required_slot="structured", response_contract="structured", max_tokens=self.DRAFT_GENERATION_MAX_TOKENS, repair_max_tokens=3600, temperature=0.0, foreground_wait_seconds=10, provider_timeout_seconds=90, job_timeout_seconds=180, max_retries=1, large_input_policy="preprocess_then_generate", response_mode="fast", response_format="json_object", profile_version="code-default", profile_source="code_default", ) def _draft_timeout_policy_meta(self, *, body: PMFlowLaunchDraftRequest) -> dict[str, Any]: attachments = list(body.attachments or []) attachment_text = "\n".join( str(item.summary or item.name or "").strip() for item in attachments if str(item.summary or item.name or "").strip() ) policy, input_tokens = AISceneRuntimePolicyService.resolve( scene_key=self.DRAFT_SCENE_KEY, function_name=self.DRAFT_FUNCTION_NAME, input_text="\n".join([str(body.input or ""), attachment_text]).strip(), attachment_count=len(attachments), ) return policy.routing_meta(input_tokens_estimate=input_tokens) @staticmethod def _bounded_seconds(value: Any, *, default: int, minimum: int, maximum: int) -> int: try: parsed = int(float(value)) except Exception: parsed = int(default) return max(int(minimum), min(parsed, int(maximum))) @staticmethod def _compact_material(value: Any, *, limit: int) -> str: text = " ".join(str(value or "").split()) if len(text) <= limit: return text head = max(0, int(limit * 0.72)) tail = max(0, limit - head - 24) return f"{text[:head]} ... [中间材料已压缩] ... {text[-tail:]}" if tail > 0 else text[:limit] def _apply_large_input_policy( self, *, body: PMFlowLaunchDraftRequest, routing_meta: dict[str, Any], ) -> tuple[PMFlowLaunchDraftRequest, dict[str, Any]]: policy = str(routing_meta.get("large_input_policy") or "").strip() input_bucket = str(routing_meta.get("input_bucket") or "").strip() if policy != "preprocess_then_generate" or input_bucket != "large": return body, {"large_input_preprocessed": False} original_input = str(body.input or "") compact_input = self._compact_material(original_input, limit=2800) compact_attachments = [] original_attachment_chars = 0 for item in list(body.attachments or [])[:8]: item_payload = item.model_dump(mode="json") raw_material = item_payload.get("summary") or item_payload.get("name") or item_payload.get("payload") or "" if isinstance(raw_material, dict): raw_material = json.dumps(raw_material, ensure_ascii=False, default=str) raw_text = str(raw_material or "") original_attachment_chars += len(raw_text) item_payload["summary"] = self._compact_material(raw_text, limit=520) item_payload["payload"] = None compact_attachments.append(PMFlowLaunchAttachment(**item_payload)) preprocessed_input = ( f"{compact_input}\n\n" "[大型输入预处理]\n" "已按运行合同压缩原始材料,只保留项目目标、目标用户、问题、方案、范围、风险、约束和待确认线索。" ).strip() preprocessed_input = self._compact_material(preprocessed_input, limit=3900) return ( body.model_copy( update={ "input": preprocessed_input, "attachments": compact_attachments, } ), { "large_input_preprocessed": True, "large_input_preprocess_strategy": "bounded_material_compaction", "large_input_original_chars": len(original_input) + original_attachment_chars, "large_input_preprocessed_chars": len(preprocessed_input) + sum(len(str(item.summary or "")) for item in compact_attachments), }, ) def _assert_input_ready(self, body: PMFlowLaunchDraftRequest) -> None: input_text = str(body.input or "").strip() attachment_summaries = [ str(item.summary or item.name or "").strip() for item in body.attachments if str(item.summary or item.name or "").strip() ] evidence_text = " ".join([input_text, *attachment_summaries]).strip() meaningful_chars = [char for char in evidence_text if char.isalnum() or "\u4e00" <= char <= "\u9fff"] if len(meaningful_chars) >= 12: return raise PMFlowLaunchError( "input_needs_more_info", ( "当前输入还不足以生成稳定的项目草案。请至少补充:想做什么产品、" "面向谁、解决什么问题,或导入一份包含这些信息的材料。" ), status_code=422, ) async def _supersede_active_jobs(self, *, user_id: int, superseded_by_job_id: str) -> None: result = await self.db.execute( select(PMFlowLaunchDraftJob).where( and_( PMFlowLaunchDraftJob.tenant_id == self.tenant_id, PMFlowLaunchDraftJob.user_id == int(user_id), PMFlowLaunchDraftJob.status.in_(["queued", "running", "user_paused"]), ) ) ) now = now_cn_naive() for row in result.scalars().all(): row.status = "superseded" row.superseded_by_job_id = superseded_by_job_id row.completed_at = now row.error_code = "superseded" row.error_message = "已有新的补充或重新生成请求,本次生成结果不再采用。" async def _load_job(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJob: result = await self.db.execute( select(PMFlowLaunchDraftJob).where( and_( PMFlowLaunchDraftJob.tenant_id == self.tenant_id, PMFlowLaunchDraftJob.user_id == int(user_id), PMFlowLaunchDraftJob.job_id == str(job_id), ) ) ) row = result.scalar_one_or_none() if row is None: raise PMFlowLaunchError("job_not_found", "草案生成任务不存在或无权访问", status_code=404) return row async def _load_job_for_update(self, *, job_id: str, user_id: int) -> PMFlowLaunchDraftJob: result = await self.db.execute( select(PMFlowLaunchDraftJob) .where( and_( PMFlowLaunchDraftJob.tenant_id == self.tenant_id, PMFlowLaunchDraftJob.user_id == int(user_id), PMFlowLaunchDraftJob.job_id == str(job_id), ) ) .with_for_update() ) row = result.scalar_one_or_none() if row is None: raise PMFlowLaunchError("job_not_found", "草案生成任务不存在或无权访问", status_code=404) return row async def _load_job_for_worker(self, *, job_id: str) -> PMFlowLaunchDraftJob: result = await self.db.execute( select(PMFlowLaunchDraftJob) .where(and_(PMFlowLaunchDraftJob.tenant_id == self.tenant_id, PMFlowLaunchDraftJob.job_id == str(job_id))) .with_for_update() ) row = result.scalar_one_or_none() if row is None: raise PMFlowLaunchError("job_not_found", "草案生成任务不存在", status_code=404) return row async def _job_to_draft_request(self, row: PMFlowLaunchDraftJob) -> PMFlowLaunchDraftRequest: input_text = str(row.input_text or "").strip() if row.parent_draft_id: parent = await self._load_draft_for_update(draft_id=row.parent_draft_id, user_id=int(row.user_id)) input_text = ( "请基于当前项目草案做增量修订,优先保留未被补充信息推翻的已有结构,不要无依据重写。\n\n" f"原始输入:\n{parent.input_text}\n\n" f"当前草案 JSON:\n{json.dumps(parent.draft_payload or {}, ensure_ascii=False)}\n\n" f"用户补充:\n{input_text}\n" ) if row.revision_note: input_text = f"{input_text}\n修订要求:\n{row.revision_note}" return PMFlowLaunchDraftRequest( input=input_text[:4000], input_type=row.input_type or "text", attachments=list(row.attachments or []), source=row.source or "web", ) async def _changed_fields( self, *, parent_draft_id: Optional[str], payload: PMFlowLaunchDraftPayload, user_id: int, ) -> list[str]: if not parent_draft_id: return ["title", "one_liner", "target_user", "requirements", "risks", "decisions", "tasks"] parent = await self._load_draft_for_update(draft_id=parent_draft_id, user_id=user_id) previous = parent.draft_payload or {} current = payload.model_dump(mode="json") fields = ["title", "one_liner", "target_user", "problem", "solution", "requirements", "risks", "decisions", "tasks", "warnings"] return [field for field in fields if previous.get(field) != current.get(field)] async def _complete_draft_job( self, *, row: PMFlowLaunchDraftJob, payload: PMFlowLaunchDraftPayload, changed_fields: list[str], ) -> None: await self.db.refresh(row) if row.status != "running": return draft_id = f"draft_{uuid4().hex[:16]}" expires_at = now_cn_naive() + timedelta(hours=self.DRAFT_TTL_HOURS) draft = PMFlowLaunchDraft( tenant_id=self.tenant_id, user_id=int(row.user_id), draft_id=draft_id, input_text=str(row.input_text or "").strip(), input_type=str(row.input_type or "text"), source=str(row.source or "web")[:32], attachments=list(row.attachments or []), draft_payload=payload.model_dump(mode="json"), edited_payload=None, status="draft", confidence=float(payload.confidence), warnings=list(payload.warnings or []), expires_at=expires_at, ) self.db.add(draft) row.status = "completed" row.result_draft_id = draft_id row.result_payload = payload.model_dump(mode="json") row.changed_fields = list(changed_fields or []) row.error_code = None row.error_message = None row.completed_at = now_cn_naive() await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(row.user_id), project_id=None, entity_type="pmflow_launch_draft_job", entity_id=row.job_id, action="pmflow_launch.draft_job.complete", before_data={"status": "running"}, after_data={ "status": "completed", "draft_id": draft_id, "changed_fields": list(changed_fields or []), }, interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}", caller="system", ) await self.db.commit() async def _fail_draft_job(self, *, row: PMFlowLaunchDraftJob, code: str, message: str) -> None: await self.db.refresh(row) if row.status not in {"queued", "running", "user_paused"}: return row.status = "failed" row.error_code = str(code or "draft_generation_failed")[:64] row.error_message = str(message or "项目草案生成失败")[:1000] row.completed_at = now_cn_naive() await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(row.user_id), project_id=None, entity_type="pmflow_launch_draft_job", entity_id=row.job_id, action="pmflow_launch.draft_job.fail", before_data={"status": "running"}, after_data={"status": "failed", "error_code": row.error_code}, interface_path=f"/pmflow-launch/draft-jobs/{row.job_id}", caller="system", ) await self.db.commit() async def _draft_job_runtime_policy(self) -> dict[str, Any]: profile = await self._resolve_draft_runtime_profile(stage="active") return { "foreground_wait_seconds": int(profile.foreground_wait_seconds), "provider_timeout_seconds": int(profile.provider_timeout_seconds), "job_timeout_seconds": int(profile.job_timeout_seconds), "max_retries": int(profile.max_retries), "large_input_policy": str(profile.large_input_policy or "preprocess_then_generate"), } def _job_response( self, row: PMFlowLaunchDraftJob, *, runtime_policy: dict[str, Any] | None = None, ) -> PMFlowLaunchDraftJobResponse: draft = None if row.result_draft_id and isinstance(row.result_payload, dict): draft = PMFlowLaunchDraftResponse( **row.result_payload, draft_id=row.result_draft_id, status="draft", expires_at=row.expires_at.isoformat(), ) return PMFlowLaunchDraftJobResponse( job_id=row.job_id, revision_id=row.revision_id, status=row.status, mode=row.mode, parent_draft_id=row.parent_draft_id, superseded_by_job_id=row.superseded_by_job_id, draft_id=row.result_draft_id, draft=draft, changed_fields=self._safe_str_list(row.changed_fields), error_code=row.error_code, error_message=row.error_message, started_at=row.started_at.isoformat() if row.started_at else None, completed_at=row.completed_at.isoformat() if row.completed_at else None, expires_at=row.expires_at.isoformat(), runtime_policy=runtime_policy, ) async def confirm_draft( self, *, draft_id: str, edited_draft: dict[str, Any], user_id: int, ) -> PMFlowLaunchConfirmResponse: row = await self._load_draft_for_update(draft_id=draft_id, user_id=user_id) if row.status == "confirmed" and row.confirmed_project_id is not None: return PMFlowLaunchConfirmResponse( project_id=int(row.confirmed_project_id), redirect_url=f"/pmflow-os?projectId={int(row.confirmed_project_id)}", warnings=self._safe_str_list(row.warnings), ) self._assert_draft_confirmable(row) payload = self._normalize_draft_payload(self._merge_draft(row.draft_payload, edited_draft)) self._validate_draft_payload(payload, require_confirmable=True) warnings = self._merge_warnings(payload.warnings, self._draft_count_warnings(payload)) request_hash = self._confirm_hash(payload.model_dump(mode="json")) created_truth_node_ids: list[int] = [] created_task_ids: list[int] = [] thread_id: Optional[int] = None thread_message_id: Optional[int] = None try: await ensure_project_id_sequence(self.db) title = compact_title(payload.title, max_len=24) or "新项目" project = Project( tenant_id=self.tenant_id, name=title, title=title, description=self._project_description(payload), owner_id=int(user_id), status="active", assigned_role="BUILDER", signal_count=0, ) self.db.add(project) await self.db.flush() self.db.add(UserProject(tenant_id=self.tenant_id, user_id=int(user_id), project_id=int(project.id))) await self.db.flush() thread = PMFlowThread( tenant_id=self.tenant_id, project_id=int(project.id), launch_draft_id=int(row.id), title=f"{title} 项目线程", objective=payload.one_liner, status="active", source="project_launch", ) self.db.add(thread) await self.db.flush() thread_id = int(thread.id) message = PMFlowThreadMessage( tenant_id=self.tenant_id, thread_id=int(thread.id), project_id=int(project.id), launch_draft_id=int(row.id), role="user", content=row.input_text, source=row.source, attachments=row.attachments or [], message_metadata={ "launch_draft_id": row.draft_id, "input_type": row.input_type, "source": row.source, }, status="sent", ) self.db.add(message) await self.db.flush() thread_message_id = int(message.id) metadata = self._source_metadata( launch_draft=row, thread_id=thread_id, thread_message_id=thread_message_id, payload=payload, ) requirement_nodes = await self._create_truth_nodes( project_id=int(project.id), user_id=int(user_id), payload=payload, metadata=metadata, created_truth_node_ids=created_truth_node_ids, ) await self._create_tasks( project_id=int(project.id), user_id=int(user_id), payload=payload, metadata=metadata, requirement_nodes=requirement_nodes, created_task_ids=created_task_ids, ) self._create_semantic_facts(project_id=int(project.id), payload=payload, metadata=metadata) row.status = "confirmed" row.edited_payload = payload.model_dump(mode="json") row.warnings = warnings row.confidence = float(payload.confidence) row.confirmed_project_id = int(project.id) row.confirmed_at = now_cn_naive() row.confirm_request_hash = request_hash await log_operation( self.db, tenant_id=self.tenant_id, user_id=int(user_id), project_id=int(project.id), entity_type="pmflow_launch_draft", entity_id=row.draft_id, action="pmflow_launch.confirm", before_data={"status": "draft"}, after_data={ "status": "confirmed", "project_id": int(project.id), "thread_id": thread_id, "thread_message_id": thread_message_id, "created_truth_node_ids": created_truth_node_ids, "created_task_ids": created_task_ids, "confirm_request_hash": request_hash, }, interface_path=f"/pmflow-launch/drafts/{row.draft_id}/confirm", caller="user", ) await self.db.commit() except Exception: await self.db.rollback() raise await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) await AIPMOKernelService.invalidate_world_model_cache(tenant_id=self.tenant_id, project_id=int(row.confirmed_project_id)) return PMFlowLaunchConfirmResponse( project_id=int(row.confirmed_project_id), redirect_url=f"/pmflow-os?projectId={int(row.confirmed_project_id)}", created_truth_node_ids=created_truth_node_ids, created_task_ids=created_task_ids, thread_id=thread_id, thread_message_id=thread_message_id, warnings=warnings, ) async def _load_draft_for_update(self, *, draft_id: str, user_id: int) -> PMFlowLaunchDraft: result = await self.db.execute( select(PMFlowLaunchDraft) .where( and_( PMFlowLaunchDraft.tenant_id == self.tenant_id, PMFlowLaunchDraft.user_id == int(user_id), PMFlowLaunchDraft.draft_id == str(draft_id), ) ) .with_for_update() ) row = result.scalar_one_or_none() if row is None: raise PMFlowLaunchError("draft_not_found", "项目草案不存在或不属于当前用户", status_code=404) return row def _assert_draft_confirmable(self, row: PMFlowLaunchDraft) -> None: if row.status in {"expired", "cancelled", "failed"}: raise PMFlowLaunchError("draft_not_confirmable", f"项目草案状态不可确认: {row.status}", status_code=409) if row.status != "draft": raise PMFlowLaunchError("draft_not_confirmable", f"项目草案状态不可确认: {row.status}", status_code=409) expires_at = row.expires_at if expires_at is not None and expires_at <= now_cn_naive(): row.status = "expired" raise PMFlowLaunchError("draft_expired", "项目草案已过期,请重新生成", status_code=409) def _normalize_draft_payload(self, raw: dict[str, Any]) -> PMFlowLaunchDraftPayload: payload = dict(raw or {}) payload["requirements"] = [ item for item in (self._normalize_requirement(row) for row in self._safe_dict_list(payload.get("requirements"))) if item["title"] ] payload["tasks"] = [ item for item in (self._normalize_task(row) for row in self._safe_dict_list(payload.get("tasks"))) if item["title"] ] payload["risks"] = [ item for item in (self._normalize_risk(row) for row in self._safe_dict_list(payload.get("risks"))) if item["title"] ] payload["decisions"] = [ item for item in (self._normalize_decision(row) for row in self._safe_dict_list(payload.get("decisions"))) if item["title"] ] payload["evidence"] = [ item for item in (self._normalize_evidence(row) for row in self._safe_dict_list(payload.get("evidence"))) if item["excerpt"] ] payload["warnings"] = self._safe_str_list(payload.get("warnings")) try: payload["confidence"] = max(0.0, min(float(payload.get("confidence") or 0), 1.0)) except (TypeError, ValueError): payload["confidence"] = 0.0 return PMFlowLaunchDraftPayload.model_validate(payload) def _validate_draft_payload(self, payload: PMFlowLaunchDraftPayload, *, require_confirmable: bool) -> None: missing = [] for field in ("title", "one_liner", "target_user", "problem", "solution"): if not str(getattr(payload, field) or "").strip(): missing.append(field) if not payload.evidence: missing.append("evidence") if require_confirmable: if not payload.requirements: missing.append("requirements") if not payload.tasks: missing.append("tasks") if not payload.risks: missing.append("risks") if not payload.decisions: missing.append("decisions") if missing: raise PMFlowLaunchError( "draft_structured_failed", f"项目草案结构化字段缺失: {', '.join(missing)}", status_code=422, ) def _draft_count_warnings(self, payload: PMFlowLaunchDraftPayload) -> list[str]: warnings: list[str] = [] if not 7 <= len(payload.requirements) <= 10: warnings.append(f"requirements_count_out_of_range: expected 7-10, got {len(payload.requirements)}") if not 10 <= len(payload.tasks) <= 16: warnings.append(f"tasks_count_out_of_range: expected 10-16, got {len(payload.tasks)}") if not 4 <= len(payload.risks) <= 7: warnings.append(f"risks_count_out_of_range: expected 4-7, got {len(payload.risks)}") if not 3 <= len(payload.decisions) <= 6: warnings.append(f"decisions_count_out_of_range: expected 3-6, got {len(payload.decisions)}") return warnings async def _create_truth_nodes( self, *, project_id: int, user_id: int, payload: PMFlowLaunchDraftPayload, metadata: dict[str, Any], created_truth_node_ids: list[int], ) -> list[TruthNode]: requirements: list[TruthNode] = [] all_nodes: list[TruthNode] = [] for item in payload.requirements: node = TruthNode( tenant_id=self.tenant_id, node_type="requirement", project_id=int(project_id), created_by=int(user_id), title=compact_title(item.title, max_len=120) or "初始需求", content={ "summary": item.summary, "acceptance_criteria": item.acceptance_criteria, "stage": "planning", "metadata": metadata, }, version=1, status="confirmed", ) self.db.add(node) requirements.append(node) all_nodes.append(node) for item in payload.risks: node = TruthNode( tenant_id=self.tenant_id, node_type="risk", project_id=int(project_id), created_by=int(user_id), title=compact_title(item.title, max_len=120) or "初始风险", content={ "summary": item.summary, "risk_level": item.level, "mitigation": item.mitigation, "stage": "planning", "metadata": metadata, }, version=1, status="confirmed", ) self.db.add(node) all_nodes.append(node) for item in payload.decisions: node = TruthNode( tenant_id=self.tenant_id, node_type="decision", project_id=int(project_id), created_by=int(user_id), title=compact_title(item.title, max_len=120) or "初始决策", content={ "summary": item.reason, "owner": item.owner or "项目负责人", "stage": "planning", "metadata": metadata, }, version=1, status="confirmed", ) self.db.add(node) all_nodes.append(node) await self.db.flush() created_truth_node_ids.extend([int(node.id) for node in all_nodes]) return requirements async def _create_tasks( self, *, project_id: int, user_id: int, payload: PMFlowLaunchDraftPayload, metadata: dict[str, Any], requirement_nodes: list[TruthNode], created_task_ids: list[int], ) -> None: if not requirement_nodes: raise PMFlowLaunchError("draft_structured_failed", "项目草案缺少可绑定任务的需求", status_code=422) for index, item in enumerate(payload.tasks): req_index = item.requirement_index if item.requirement_index is not None else index % len(requirement_nodes) if req_index is None or req_index < 0 or req_index >= len(requirement_nodes): req_index = 0 task_payload = { "source": "pmflow_launch_confirmed_draft", "launch_metadata": metadata, "task": item.model_dump(mode="json"), "acceptance_criteria": item.acceptance_criteria, } task = await persist_task( self.db, tenant_id=self.tenant_id, truth_node_id=int(requirement_nodes[int(req_index)].id), project_id=int(project_id), draft=build_task_create_draft( title=item.title, description=item.summary, required_skill_tag_ids=[], ai_breakdown_payload=task_payload, manual_overrides={"launch_metadata": metadata, "acceptance_criteria": item.acceptance_criteria}, priority=item.priority, status="todo", ), actor_user_id=int(user_id), action="pmflow_launch.task.create", remark="pmflow_launch.confirm", commit=False, refresh_workload=False, ) created_task_ids.append(int(task.id)) def _create_semantic_facts( self, *, project_id: int, payload: PMFlowLaunchDraftPayload, metadata: dict[str, Any], ) -> None: facts = { "one_liner": payload.one_liner, "target_user": payload.target_user, "problem": payload.problem, "solution": payload.solution, } for key, value in facts.items(): text = str(value or "").strip() if not text: continue self.db.add( SemanticFact( tenant_id=self.tenant_id, entity_type="project", entity_id=int(project_id), fact_namespace="pmflow_launch_seed", fact_key=key, fact_value_text=text, fact_value_norm=text[:255], value_type="text", confidence=float(payload.confidence), evidence_source_type="pmflow_thread_message", evidence_source_id=metadata.get("thread_message_id"), evidence_excerpt=self._evidence_excerpt(payload), extract_version="pmflow_launch_v1", freshness_at=now_cn_naive().isoformat(), is_active=True, ) ) @staticmethod def _merge_draft(base: Any, edited: dict[str, Any]) -> dict[str, Any]: merged = dict(base or {}) for key, value in dict(edited or {}).items(): if value is not None: merged[key] = value return merged @staticmethod def _safe_dict_list(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] return [dict(item) for item in value if isinstance(item, dict)] @staticmethod def _safe_str_list(value: Any) -> list[str]: if not isinstance(value, list): return [] return [str(item).strip() for item in value if str(item or "").strip()] @staticmethod def _merge_warnings(*groups: list[str]) -> list[str]: seen: set[str] = set() merged: list[str] = [] for group in groups: for item in group or []: text = str(item or "").strip() if text and text not in seen: seen.add(text) merged.append(text) return merged @staticmethod def _normalize_requirement(item: dict[str, Any]) -> dict[str, Any]: return { "title": str(item.get("title") or "").strip(), "summary": str(item.get("summary") or item.get("description") or "").strip(), "acceptance_criteria": PMFlowLaunchService._safe_str_list( item.get("acceptance_criteria") or item.get("acceptanceCriteria") or item.get("criteria") ), } @staticmethod def _normalize_task(item: dict[str, Any]) -> dict[str, Any]: group = str(item.get("group") or "product").strip().lower() group_map = {"产品任务": "product", "开发任务": "engineering", "测试任务": "qa"} group = group_map.get(group, group) if group not in {"product", "engineering", "qa"}: group = "product" priority = str(item.get("priority") or "medium").strip().lower() if priority not in {"low", "medium", "high", "urgent"}: priority = "medium" req_index = item.get("requirement_index") try: req_index = int(req_index) if req_index is not None else None except (TypeError, ValueError): req_index = None return { "title": str(item.get("title") or "").strip(), "summary": str(item.get("summary") or item.get("description") or "").strip(), "group": group, "priority": priority, "acceptance_criteria": PMFlowLaunchService._safe_str_list( item.get("acceptance_criteria") or item.get("acceptanceCriteria") or item.get("criteria") ), "requirement_index": req_index, } @staticmethod def _normalize_risk(item: dict[str, Any]) -> dict[str, Any]: level = str(item.get("level") or item.get("risk_level") or "medium").strip().lower() level_map = {"高": "high", "中": "medium", "低": "low"} level = level_map.get(level, level) if level not in {"low", "medium", "high"}: level = "medium" return { "title": str(item.get("title") or "").strip(), "summary": str(item.get("summary") or item.get("description") or "").strip(), "level": level, "mitigation": str(item.get("mitigation") or item.get("mitigation_plan") or "").strip(), } @staticmethod def _normalize_decision(item: dict[str, Any]) -> dict[str, Any]: return { "title": str(item.get("title") or "").strip(), "reason": str(item.get("reason") or item.get("summary") or item.get("rationale") or "").strip(), "owner": str(item.get("owner") or "").strip(), } @staticmethod def _normalize_evidence(item: dict[str, Any]) -> dict[str, Any]: return { "source": str(item.get("source") or "user_input").strip() or "user_input", "excerpt": str(item.get("excerpt") or item.get("summary") or "").strip(), } @staticmethod def _project_description(payload: PMFlowLaunchDraftPayload) -> str: return "\n".join( [ payload.one_liner, f"目标用户:{payload.target_user}", f"核心问题:{payload.problem}", f"解决方案:{payload.solution}", ] ) @staticmethod def _evidence_excerpt(payload: PMFlowLaunchDraftPayload) -> str: if payload.evidence: return payload.evidence[0].excerpt[:500] return payload.one_liner[:500] def _source_metadata( self, *, launch_draft: PMFlowLaunchDraft, thread_id: int, thread_message_id: int, payload: PMFlowLaunchDraftPayload, ) -> dict[str, Any]: return { "source": "pmflow_launch_confirmed_draft", "launch_draft_id": launch_draft.draft_id, "launch_draft_row_id": int(launch_draft.id), "thread_id": int(thread_id), "thread_message_id": int(thread_message_id), "confidence": float(payload.confidence), "evidence": [item.model_dump(mode="json") for item in payload.evidence], } @staticmethod def _confirm_hash(payload: dict[str, Any]) -> str: raw = json.dumps(payload, ensure_ascii=False, sort_keys=True) return hashlib.sha256(raw.encode("utf-8")).hexdigest() __all__ = ["PMFlowLaunchError", "PMFlowLaunchService"]