PMFlow/backend/app/services/pmflow_launch_service.py

662 lines
26 KiB
Python

"""PMFlow Project Launch draft and confirm service."""
from __future__ import annotations
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,
PMFlowThread,
PMFlowThreadMessage,
Project,
SemanticFact,
TruthNode,
UserProject,
)
from ..schemas.pmflow_launch import (
PMFlowLaunchConfirmResponse,
PMFlowLaunchDraftPayload,
PMFlowLaunchDraftRequest,
PMFlowLaunchDraftResponse,
)
from ..services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
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.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
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def create_draft(
self,
*,
body: PMFlowLaunchDraftRequest,
user_id: int,
) -> PMFlowLaunchDraftResponse:
prompt = PROMPT_PMFLOW_LAUNCH_DRAFT.format(
input_text=str(body.input or "")[:4000],
attachments_json=json.dumps([item.model_dump(mode="json") for item in body.attachments], ensure_ascii=False),
)
try:
structured = await llm_service.generate_structured_object(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="pmflow_launch_draft_v1",
temperature=0.0,
max_tokens=2200,
response_mode="fast",
routing_meta={
"scene": "pmflow_project_launch",
"tier_alias": "low",
"response_contract": "structured",
},
)
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 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 3 <= len(payload.requirements) <= 5:
warnings.append(f"requirements_count_out_of_range: expected 3-5, got {len(payload.requirements)}")
if not 3 <= len(payload.tasks) <= 7:
warnings.append(f"tasks_count_out_of_range: expected 3-7, got {len(payload.tasks)}")
if not 2 <= len(payload.risks) <= 4:
warnings.append(f"risks_count_out_of_range: expected 2-4, got {len(payload.risks)}")
if not 1 <= len(payload.decisions) <= 3:
warnings.append(f"decisions_count_out_of_range: expected 1-3, 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"]