PMFlow/backend/app/services/pmflow_launch_service.py

1224 lines
51 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.

"""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,
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,
),
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)
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."""
_ = user_id
body = PMFlowLaunchDraftRequest(
input=(
"我想做一个 AI 会议纪要助手,帮助个人开发者和小团队把会议文本整理成目标、"
"需求、风险和第一批任务。"
),
input_type="text",
attachments=[],
source="system_smoke",
)
try:
structured = await self._generate_draft_structured(
body=body,
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,
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"]