1376 lines
58 KiB
Python
1376 lines
58 KiB
Python
"""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,
|
||
generation_context: str = "",
|
||
):
|
||
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(),
|
||
}
|
||
seed_generation_context = self._launch_seed_generation_context(body.attachments)
|
||
generation_context = self._merge_generation_context(
|
||
generation_context,
|
||
seed_generation_context,
|
||
)
|
||
generation_body = body
|
||
if seed_generation_context:
|
||
generation_body = body.model_copy(
|
||
update={
|
||
"input": self._compact_material(
|
||
f"{str(body.input or '').strip()}\n\n{seed_generation_context}",
|
||
limit=3900,
|
||
)
|
||
}
|
||
)
|
||
prompt_body, preprocess_meta = self._apply_large_input_policy(
|
||
body=generation_body,
|
||
routing_meta=effective_runtime_meta,
|
||
)
|
||
prompt_result = await self._render_draft_prompt(body=prompt_body, generation_context=generation_context)
|
||
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(),
|
||
}
|
||
if generation_context:
|
||
routing_meta["launch_generation_context"] = "seed_first" if self._launch_seed_from_attachments(body.attachments) else "variant"
|
||
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)
|
||
generation_context = await self._job_generation_context(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,
|
||
},
|
||
generation_context=generation_context,
|
||
)
|
||
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,
|
||
generation_context: str = "",
|
||
) -> 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),
|
||
generation_context=str(generation_context or "无额外上下文。").strip(),
|
||
)
|
||
|
||
@staticmethod
|
||
def _merge_generation_context(*blocks: str) -> str:
|
||
rows = [str(item or "").strip() for item in blocks if str(item or "").strip()]
|
||
return "\n\n".join(rows) if rows else ""
|
||
|
||
@staticmethod
|
||
def _launch_seed_from_attachments(attachments: list[PMFlowLaunchAttachment]) -> dict[str, Any]:
|
||
for item in list(attachments or []):
|
||
if str(item.type or "").strip() != "pmflow_launch_seed":
|
||
continue
|
||
payload = item.payload if isinstance(item.payload, dict) else None
|
||
if payload:
|
||
return payload
|
||
return {}
|
||
|
||
def _launch_seed_generation_context(self, attachments: list[PMFlowLaunchAttachment]) -> str:
|
||
seed = self._launch_seed_from_attachments(attachments)
|
||
if not seed:
|
||
return ""
|
||
compact_seed = self._compact_material(json.dumps(seed, ensure_ascii=False, default=str), limit=3200)
|
||
return (
|
||
"[Space AI 同轮产品方案种子]\n"
|
||
"本轮 Project Launch 必须基于这个 seed materialize 项目草案,而不是重新解释用户创意。\n"
|
||
"必须保持 seed 中的产品定位、目标用户、核心问题、价值主张、MVP 边界、风险、验证路径、"
|
||
"变现假设和证据边界;只能补齐缺失字段或把表达结构化。若确需调整,必须在 warnings 说明调整原因。\n"
|
||
"seed JSON:\n"
|
||
f"{compact_seed}"
|
||
)
|
||
|
||
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",
|
||
runtime_mode="active",
|
||
quality_gate_mode="strict",
|
||
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")
|
||
if str(item_payload.get("type") or "").strip() == "pmflow_launch_seed":
|
||
compact_attachments.append(PMFlowLaunchAttachment(**item_payload))
|
||
original_attachment_chars += len(json.dumps(item_payload.get("payload") or {}, ensure_ascii=False, default=str))
|
||
continue
|
||
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",
|
||
)
|
||
|
||
@staticmethod
|
||
def _normalized_launch_input(text: str) -> str:
|
||
return " ".join(str(text or "").split()).strip()
|
||
|
||
def _variant_summary(self, payload: Any) -> str:
|
||
if not isinstance(payload, dict):
|
||
return ""
|
||
title = compact_title(payload.get("title"), max_len=40)
|
||
one_liner = self._compact_material(payload.get("one_liner"), limit=120)
|
||
requirements = [
|
||
compact_title(item.get("title"), max_len=28)
|
||
for item in self._safe_dict_list(payload.get("requirements"))[:4]
|
||
if compact_title(item.get("title"), max_len=28)
|
||
]
|
||
tasks = [
|
||
compact_title(item.get("title"), max_len=28)
|
||
for item in self._safe_dict_list(payload.get("tasks"))[:4]
|
||
if compact_title(item.get("title"), max_len=28)
|
||
]
|
||
parts = []
|
||
if title:
|
||
parts.append(f"标题:{title}")
|
||
if one_liner:
|
||
parts.append(f"一句话:{one_liner}")
|
||
if requirements:
|
||
parts.append(f"需求切口:{'、'.join(requirements)}")
|
||
if tasks:
|
||
parts.append(f"任务切口:{'、'.join(tasks)}")
|
||
return ";".join(parts)
|
||
|
||
async def _recent_same_input_draft_summaries(self, *, row: PMFlowLaunchDraftJob, limit: int = 3) -> list[str]:
|
||
normalized_input = self._normalized_launch_input(row.input_text)
|
||
if not normalized_input:
|
||
return []
|
||
result = await self.db.execute(
|
||
select(PMFlowLaunchDraft)
|
||
.where(
|
||
and_(
|
||
PMFlowLaunchDraft.tenant_id == self.tenant_id,
|
||
PMFlowLaunchDraft.user_id == int(row.user_id),
|
||
PMFlowLaunchDraft.status == "draft",
|
||
PMFlowLaunchDraft.expires_at > now_cn_naive(),
|
||
)
|
||
)
|
||
.order_by(PMFlowLaunchDraft.id.desc())
|
||
.limit(12)
|
||
)
|
||
summaries: list[str] = []
|
||
for draft in result.scalars().all():
|
||
if self._normalized_launch_input(str(draft.input_text or "")) != normalized_input:
|
||
continue
|
||
summary = self._variant_summary(draft.draft_payload)
|
||
if summary:
|
||
summaries.append(summary)
|
||
if len(summaries) >= limit:
|
||
break
|
||
return summaries
|
||
|
||
async def _job_generation_context(self, row: PMFlowLaunchDraftJob) -> str:
|
||
mode = str(row.mode or "create").strip().lower()
|
||
blocks: list[str] = [f"本轮生成ID:{row.revision_id}。"]
|
||
if mode == "regenerate":
|
||
blocks.append(
|
||
"用户请求重新生成草案。请保持同一创意和事实边界,但生成一版可替代方案:"
|
||
"重新选择更清晰的首个可收费切口、MVP范围、风险优先级和首批任务组织方式;"
|
||
"不要逐句复刻上一版标题、需求、任务和风险。"
|
||
)
|
||
elif mode == "merge_supplement":
|
||
blocks.append("用户请求基于补充信息修订草案。请保留仍成立的结构,只改写被补充信息影响的部分。")
|
||
|
||
if row.parent_draft_id:
|
||
parent = await self._load_draft_for_update(draft_id=row.parent_draft_id, user_id=int(row.user_id))
|
||
parent_summary = self._variant_summary(parent.draft_payload)
|
||
if parent_summary:
|
||
blocks.append(f"上一版草案摘要:{parent_summary}")
|
||
elif mode == "create":
|
||
summaries = await self._recent_same_input_draft_summaries(row=row)
|
||
if summaries:
|
||
blocks.append(
|
||
"当前用户近期已经基于同一输入生成过未确认草案。请生成另一版可比较的候选草案,"
|
||
"保持事实不编造,但在定位切口、MVP边界、任务拆分和风险排序上做有意义的差异。"
|
||
)
|
||
blocks.extend(f"历史草案 {index + 1}:{summary}" for index, summary in enumerate(summaries))
|
||
|
||
if len(blocks) == 1:
|
||
return "无额外上下文。"
|
||
return "\n".join(blocks)
|
||
|
||
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"]
|