662 lines
26 KiB
Python
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"]
|