"""PMFlow OS change review state machine service.""" from __future__ import annotations import json from typing import Any, Optional from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified from ..config.ai_prompts import PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION from ..config.ai_function_runtime_profiles import DEFAULT_AI_FUNCTION_RUNTIME_PROFILES from ..models import ( PMFlowApproval, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, PMFlowThread, PMFlowThreadMessage, SemanticFact, Task, TruthNode, User, UserProject, ) from ..schemas.pmflow_os import ( PMFlowChangeItemEditRequest, PMFlowChangeSetCommitRequest, PMFlowExtractionCreateRequest, PMFlowProposedChangeItem, PMFlowReviewDecisionRequest, PMFlowThreadMessageCreateRequest, ) from .ai_function_runtime_profile_service import PLATFORM_RUNTIME_TENANT_ID, AIFunctionRuntimeProfile, AIFunctionRuntimeProfileService from .llm_service import llm_service from .prompt_runtime_service import PromptRuntimeService from ..utils.timezone import now_cn_naive class PMFlowChangeReviewError(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 PMFlowChangeReviewService: COMMIT_OBJECT_TYPES = {"Requirement", "Decision", "Task", "Risk"} AI_EXTRACTION_FUNCTION_NAME = "pmflow_change_review_extraction_v1" AI_EXTRACTION_SCENE_KEY = "change_review" AI_EXTRACTION_PROMPT_KEY = "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION" def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db self.tenant_id = int(tenant_id) async def create_thread_message( self, *, project_id: int, thread_id: str, body: PMFlowThreadMessageCreateRequest, user_id: int, ) -> dict[str, Any]: thread = await self._resolve_or_create_thread(project_id=project_id, thread_id=thread_id) row = PMFlowThreadMessage( tenant_id=self.tenant_id, thread_id=int(thread.id), project_id=int(project_id), role="user", content=body.content.strip(), source=(body.source or "web")[:32], attachments=body.attachments, message_metadata={"created_by": int(user_id), "source": body.source or "web"}, status="sent", ) self.db.add(row) await self.db.commit() await self.db.refresh(row) return self.serialize_message(row) async def create_extraction( self, *, project_id: int, body: PMFlowExtractionCreateRequest, user_id: int, ) -> dict[str, Any]: if not body.proposed_items: return await self.create_ai_extraction(project_id=project_id, body=body, user_id=user_id) thread, message = await self._load_thread_message( project_id=project_id, thread_id=body.thread_id, message_id=body.message_id, ) headline = (body.headline or "本轮结构化提取").strip()[:255] summary = (body.summary or f"本轮对话生成 {len(body.proposed_items)} 个待审变更项。").strip() change_set = PMFlowChangeSet( tenant_id=self.tenant_id, project_id=int(project_id), thread_id=int(thread.id), source_message_id=int(message.id), status="open", summary=summary, headline=headline, extraction_scope=(body.scope or "latest_message")[:64], routing_meta=body.routing_meta, llm_call_id=body.llm_call_id, created_by=int(user_id), ) self.db.add(change_set) await self.db.flush() items: list[PMFlowChangeItem] = [] for proposed in body.proposed_items: payload = proposed.payload or {} item = PMFlowChangeItem( tenant_id=self.tenant_id, change_set_id=int(change_set.id), project_id=int(project_id), group=proposed.group, object_type=proposed.object_type, object_id=proposed.object_id, title=proposed.title.strip(), summary=proposed.summary.strip(), diff=(proposed.diff or proposed.summary).strip(), payload=payload, edited_payload=None, confidence=float(proposed.confidence or 0.0), risk_level=proposed.risk_level, status="pending_review", ) self.db.add(item) items.append(item) await self.db.flush() evidence = PMFlowEvidenceLink( tenant_id=self.tenant_id, project_id=int(project_id), change_set_id=int(change_set.id), change_item_id=None, source_type="thread_message", source_id=str(message.id), title="项目线程消息", excerpt=message.content[:1200], display_label="项目线程", evidence_metadata={ "thread_id": int(thread.id), "message_id": int(message.id), "source": message.source, }, ) self.db.add(evidence) await self.db.commit() return await self.get_change_set_detail(project_id=project_id, change_set_id=int(change_set.id)) async def create_ai_extraction( self, *, project_id: int, body: PMFlowExtractionCreateRequest, user_id: int, ) -> dict[str, Any]: thread, message = await self._load_thread_message( project_id=project_id, thread_id=body.thread_id, message_id=body.message_id, ) message_text = str(message.content or "").strip() if not message_text: raise PMFlowChangeReviewError("extraction_evidence_insufficient", "结构化失败:项目线程消息为空,待补充。", status_code=409) structured, runtime_profile, routing_meta = await self._generate_ai_extraction_structured( message_text=message_text, attachments=list(message.attachments or []), profile_stage="active", user_id=user_id, routing_meta_extra=dict(body.routing_meta or {}), ) payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} proposed_items = self._normalize_ai_extraction_payload(payload) if not proposed_items: await self._ensure_extraction_clarification_message( thread=thread, source_message=message, user_id=user_id, code="extraction_evidence_insufficient", missing_info=payload.get("missing_info") if isinstance(payload.get("missing_info"), list) else [], warnings=payload.get("warnings") if isinstance(payload.get("warnings"), list) else [], ) raise PMFlowChangeReviewError("extraction_evidence_insufficient", "证据不足:AI 未能抽取可审查对象,待补充。", status_code=409) ai_body = PMFlowExtractionCreateRequest( thread_id=body.thread_id, message_id=body.message_id, scope=body.scope, headline=body.headline or str(payload.get("change_summary") or payload.get("title") or "AI 结构化提取待确认")[:255], summary=body.summary or self._ai_extraction_summary(payload=payload, item_count=len(proposed_items)), routing_meta={ **routing_meta, "aiExtraction": { "functionName": self.AI_EXTRACTION_FUNCTION_NAME, "confidence": payload.get("confidence"), "warnings": payload.get("warnings") if isinstance(payload.get("warnings"), list) else [], "sourceMessageId": str(message.id), "runtimeProfileVersion": runtime_profile.profile_version, "maxTokens": runtime_profile.max_tokens, }, }, llm_call_id=body.llm_call_id, proposed_items=proposed_items, ) return await self.create_extraction(project_id=project_id, body=ai_body, user_id=user_id) async def _ensure_extraction_clarification_message( self, *, thread: PMFlowThread, source_message: PMFlowThreadMessage, user_id: int, code: str, missing_info: list[Any], warnings: list[Any], ) -> None: source_message_id = str(source_message.id) existing_result = await self.db.execute( select(PMFlowThreadMessage.id).where( and_( PMFlowThreadMessage.tenant_id == self.tenant_id, PMFlowThreadMessage.project_id == int(source_message.project_id), PMFlowThreadMessage.thread_id == int(thread.id), PMFlowThreadMessage.role == "assistant", PMFlowThreadMessage.message_metadata.op("->>")("source") == "change_review_extraction_clarification", PMFlowThreadMessage.message_metadata.op("->>")("source_message_id") == source_message_id, ) ) ) if existing_result.scalar_one_or_none() is not None: return clean_missing = [str(item).strip() for item in missing_info if str(item).strip()][:3] clean_warnings = [str(item).strip() for item in warnings if str(item).strip()][:3] detail = clean_missing or clean_warnings if detail: detail_text = "还缺少:" + "、".join(detail) + "。" else: detail_text = "还缺少明确的项目目标、对象、约束或下一步动作。" row = PMFlowThreadMessage( tenant_id=self.tenant_id, thread_id=int(thread.id), project_id=int(source_message.project_id), role="assistant", content=( "我收到了。这条消息暂时没有形成可审查的需求、任务、风险或决策,所以不会写入项目记忆。\n\n" f"{detail_text}继续补充后,我会重新提取待确认变更。" ), source="pmflow_os", attachments=[], message_metadata={ "source": "change_review_extraction_clarification", "source_message_id": source_message_id, "extraction_error_code": code, "missing_info": clean_missing, "warnings": clean_warnings, "created_by": int(user_id), }, status="resolved", ) self.db.add(row) await self.db.commit() async def generate_extraction_payload_for_readiness_check(self, *, user_id: int, profile_stage: str = "draft") -> dict[str, Any]: structured, _profile, _routing_meta = await self._generate_ai_extraction_structured( message_text="增加一个场景评估功能,用于开始前先收集家长最需要解决的高频问题场景。", attachments=[], profile_stage=profile_stage, user_id=user_id, routing_meta_extra={"readiness_check": True, "source": "system_smoke"}, ) payload = structured.payload if structured is not None and isinstance(structured.payload, dict) else {} proposed_items = self._normalize_ai_extraction_payload(payload) if not proposed_items: raise PMFlowChangeReviewError("extraction_evidence_insufficient", "变更抽取 smoke 未产出可审查对象", status_code=409) return {"payload": payload, "proposed_items": proposed_items} async def _generate_ai_extraction_structured( self, *, message_text: str, attachments: list[Any], profile_stage: str = "active", user_id: int | None = None, routing_meta_extra: dict[str, Any] | None = None, ): prompt_result = await PromptRuntimeService(self.db).render( tenant_id=PLATFORM_RUNTIME_TENANT_ID, prompt_key=self.AI_EXTRACTION_PROMPT_KEY, fallback_template=PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION, message_text=str(message_text or "")[:4000], attachments_json=json.dumps(attachments or [], ensure_ascii=False), ) runtime_profile = await self._resolve_ai_extraction_runtime_profile(stage=profile_stage) routing_meta = { "scene": "pmflow_os_thread_extraction", "scene_key": self.AI_EXTRACTION_SCENE_KEY, "source": "project_thread_message", "response_contract": "structured", "change_review_contract": "proposed_items_v1", "prompt_runtime_tenant_id": PLATFORM_RUNTIME_TENANT_ID, **(routing_meta_extra if isinstance(routing_meta_extra, dict) else {}), **runtime_profile.routing_meta(), **prompt_result.routing_meta(), } try: structured = await llm_service.generate_structured_object( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name=self.AI_EXTRACTION_FUNCTION_NAME, temperature=runtime_profile.temperature, max_tokens=runtime_profile.max_tokens, response_mode=runtime_profile.response_mode, routing_meta=routing_meta, user_id=int(user_id or 0), ) except PMFlowChangeReviewError: raise except Exception as exc: raise PMFlowChangeReviewError("ai_extraction_failed", f"结构化失败:AI 提取失败,待补充。{exc}", status_code=502) from exc return structured, runtime_profile, routing_meta async def _resolve_ai_extraction_runtime_profile(self, *, stage: str = "active") -> AIFunctionRuntimeProfile: try: return await AIFunctionRuntimeProfileService(self.db).resolve_profile( function_name=self.AI_EXTRACTION_FUNCTION_NAME, scene_key=self.AI_EXTRACTION_SCENE_KEY, prompt_key=self.AI_EXTRACTION_PROMPT_KEY, stage=stage, ) except Exception: profile = dict(DEFAULT_AI_FUNCTION_RUNTIME_PROFILES[self.AI_EXTRACTION_FUNCTION_NAME]) return AIFunctionRuntimeProfile( function_name=self.AI_EXTRACTION_FUNCTION_NAME, scene_key=self.AI_EXTRACTION_SCENE_KEY, prompt_key=self.AI_EXTRACTION_PROMPT_KEY, required_slot=str(profile.get("required_slot") or "structured"), response_contract=str(profile.get("response_contract") or "structured"), max_tokens=int(profile.get("max_tokens") or 7200), repair_max_tokens=int(profile.get("repair_max_tokens") or 3600), temperature=float(profile.get("temperature") or 0.0), foreground_wait_seconds=int(profile.get("foreground_wait_seconds") or 10), provider_timeout_seconds=int(profile.get("provider_timeout_seconds") or 90), job_timeout_seconds=int(profile.get("job_timeout_seconds") or 180), max_retries=int(profile.get("max_retries") or 1), large_input_policy=str(profile.get("large_input_policy") or "preprocess_then_generate"), response_mode=str(profile.get("response_mode") or "fast"), response_format=str(profile.get("response_format") or "json_object"), runtime_mode=str(profile.get("runtime_mode") or "active"), quality_gate_mode=str(profile.get("quality_gate_mode") or "strict"), profile_version="code-default", profile_source="code_default", ) async def get_change_set_detail(self, *, project_id: int, change_set_id: int) -> dict[str, Any]: change_set = await self._load_change_set(project_id=project_id, change_set_id=change_set_id) items = await self._load_items(project_id=project_id, change_set_id=int(change_set.id)) evidence = await self._load_evidence(project_id=project_id, change_set_id=int(change_set.id)) approvals = await self._load_approvals(project_id=project_id, change_set_id=int(change_set.id)) return { "changeSet": self.serialize_change_set(change_set, items), "items": [self.serialize_change_item(item, evidence) for item in items], "evidence": [self.serialize_evidence(row) for row in evidence], "approvals": [self.serialize_approval(row) for row in approvals], } async def get_item_project_id(self, *, change_item_id: int) -> Optional[int]: result = await self.db.execute( select(PMFlowChangeItem.project_id).where( and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.id == int(change_item_id)) ) ) value = result.scalar_one_or_none() return int(value) if value is not None else None async def get_change_set_project_id(self, *, change_set_id: int) -> Optional[int]: result = await self.db.execute( select(PMFlowChangeSet.project_id).where( and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.id == int(change_set_id)) ) ) value = result.scalar_one_or_none() return int(value) if value is not None else None async def accept_item(self, *, change_item_id: int, user_id: int) -> dict[str, Any]: item, change_set = await self._load_item_with_set(change_item_id=change_item_id) await self._review_item(item=item, change_set=change_set, action="accept", decision="approved", user_id=user_id) await self.db.commit() return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id)) async def edit_item(self, *, change_item_id: int, body: PMFlowChangeItemEditRequest, user_id: int) -> dict[str, Any]: item, change_set = await self._load_item_with_set(change_item_id=change_item_id) before_payload = dict(item.edited_payload or item.payload or {}) base_payload = dict(item.edited_payload or item.payload or {}) base_payload.update(body.payload_patch or {}) if body.summary is not None: item.summary = body.summary.strip() base_payload["summary"] = item.summary if body.diff is not None: item.diff = body.diff.strip() base_payload["diff"] = item.diff item.edited_payload = base_payload await self._review_item( item=item, change_set=change_set, action="edit", decision="edited", user_id=user_id, reason=body.reason, before_payload_override=before_payload, ) await self.db.commit() return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id)) async def defer_item(self, *, change_item_id: int, user_id: int, reason: Optional[str] = None) -> dict[str, Any]: item, change_set = await self._load_item_with_set(change_item_id=change_item_id) await self._review_item( item=item, change_set=change_set, action="defer", decision="deferred", user_id=user_id, reason=reason, ) await self.db.commit() return await self.get_change_set_detail(project_id=int(item.project_id), change_set_id=int(item.change_set_id)) async def commit_change_set( self, *, change_set_id: int, body: PMFlowChangeSetCommitRequest, user_id: int, ) -> dict[str, Any]: try: change_set = await self._load_change_set_for_commit(change_set_id=change_set_id) items = await self._load_items(project_id=int(change_set.project_id), change_set_id=int(change_set.id)) evidence = await self._load_evidence(project_id=int(change_set.project_id), change_set_id=int(change_set.id)) approvals = await self._load_approvals(project_id=int(change_set.project_id), change_set_id=int(change_set.id)) selected_ids = self._parse_commit_item_ids(body.item_ids) self._validate_commit_preflight(change_set=change_set, items=items, selected_ids=selected_ids) eligible_items = [ item for item in items if item.status in {"accepted", "edited"} and (not selected_ids or int(item.id) in selected_ids) ] if not eligible_items: raise PMFlowChangeReviewError("no_accepted_items", "no_accepted_items", status_code=409) committed_items: list[dict[str, Any]] = [] for item in eligible_items: self._validate_commit_item(item=item, evidence=evidence, approvals=approvals) for item in eligible_items: committed_items.append( await self._commit_item( item=item, change_set=change_set, evidence=evidence, user_id=user_id, ) ) now = now_cn_naive() for item in eligible_items: item.status = "committed" change_set.committed_by = int(user_id) remaining_open = [ item for item in items if item.id not in {committed["changeItemIdRaw"] for committed in committed_items} and item.status in {"pending_review", "accepted", "edited"} ] if not remaining_open: change_set.status = "committed" change_set.committed_at = now await self.db.commit() skipped_items = [ self._serialize_skipped_commit_item(item) for item in items if item.id not in {committed["changeItemIdRaw"] for committed in committed_items} and item.status in {"deferred", "skipped"} ] committed_by = await self._load_user_display_name(user_id=user_id) serialized_committed_items = [ { **{key: value for key, value in item.items() if key != "changeItemIdRaw"}, "committedAt": now.isoformat(), "committedById": str(user_id), "committedBy": committed_by, } for item in committed_items ] return { "changeSet": { "id": str(change_set.id), "status": change_set.status, "rawStatus": change_set.status, "committedAt": change_set.committed_at.isoformat() if change_set.committed_at else None, "writebackAt": now.isoformat(), "committedById": str(user_id), "committedBy": committed_by, "committedItemIds": [item["changeItemId"] for item in committed_items], "skippedItemIds": [item["changeItemId"] for item in skipped_items], "failedItemIds": [], }, "committedItems": serialized_committed_items, "skippedItems": skipped_items, "failedItems": [], } except PMFlowChangeReviewError: await self.db.rollback() raise except Exception as exc: await self.db.rollback() raise PMFlowChangeReviewError("commit_failed", "commit_failed", status_code=500) from exc async def record_review_decision( self, *, change_set_id: int, body: PMFlowReviewDecisionRequest, user_id: int, ) -> dict[str, Any]: try: change_set = await self._load_change_set_for_review_decision(change_set_id=change_set_id) items = await self._load_items(project_id=int(change_set.project_id), change_set_id=int(change_set.id)) selected_items = self._select_review_decision_items(items=items, raw_item_ids=body.item_ids) warnings: list[str] = [] if body.action == "request_review": reviewer = await self._load_project_reviewer( project_id=int(change_set.project_id), reviewer_id=body.reviewer_id, ) if reviewer is None: warnings.append("reviewer_missing") else: self._mark_change_set_review_meta( change_set=change_set, action="request_review", approval_state="requested", user_id=user_id, reviewer_id=int(reviewer.id), reason=body.reason, selected_items=selected_items, ) await self.db.commit() return { "changeSetDetail": await self.get_change_set_detail( project_id=int(change_set.project_id), change_set_id=int(change_set.id), ), "warnings": warnings, } if not selected_items: raise PMFlowChangeReviewError("review_decision_item_empty", "review_decision_item_empty", status_code=409) before_status_by_id = {int(item.id): item.status for item in selected_items} if body.action == "approve": decision = "approved" approval_state = "approved" after_status = "accepted" for item in selected_items: item.status = "accepted" item.reviewed_at = now_cn_naive() elif body.action == "request_changes": decision = "changes_requested" approval_state = "changes_requested" after_status = "pending_review" elif body.action == "reject": decision = "rejected" approval_state = "rejected" after_status = "deferred" for item in selected_items: item.status = "deferred" item.reviewed_at = now_cn_naive() else: raise PMFlowChangeReviewError("invalid_review_decision", "invalid_review_decision", status_code=422) actor_id = int(user_id) reviewer_meta_id = int(body.reviewer_id or user_id) for item in selected_items: self.db.add( PMFlowApproval( tenant_id=self.tenant_id, project_id=int(item.project_id), change_set_id=int(item.change_set_id), change_item_id=int(item.id), actor_user_id=actor_id, action=body.action, decision=decision, before_status=before_status_by_id[int(item.id)], after_status=after_status if body.action != "approve" else item.status, before_payload=dict(item.edited_payload or item.payload or {}), after_payload={ **dict(item.edited_payload or item.payload or {}), "reviewDecision": body.action, "approvalState": approval_state, }, reason=body.reason, ) ) self._mark_change_set_review_meta( change_set=change_set, action=body.action, approval_state=approval_state, user_id=user_id, reviewer_id=reviewer_meta_id, reason=body.reason, selected_items=selected_items, ) if body.action in {"approve", "reject"}: self._mark_change_set_reviewed_if_no_pending(change_set=change_set, items=items) await self.db.commit() return { "changeSetDetail": await self.get_change_set_detail( project_id=int(change_set.project_id), change_set_id=int(change_set.id), ), "warnings": warnings, } except PMFlowChangeReviewError: await self.db.rollback() raise def _normalize_ai_extraction_payload(self, payload: dict[str, Any]) -> list[PMFlowProposedChangeItem]: direct = payload.get("proposed_items") if isinstance(direct, list): return self._coerce_proposed_items(direct) items: list[dict[str, Any]] = [] confidence = self._confidence(payload.get("confidence"), default=0.72) for raw in self._list_of_dicts(payload.get("requirements")): title = self._clean_text(raw.get("title")) summary = self._clean_text(raw.get("summary") or raw.get("description")) if not title or not summary: continue acceptance = raw.get("acceptance_criteria") or raw.get("acceptanceCriteria") or [] items.append( { "group": "new_object", "object_type": "Requirement", "title": self._prefixed_title(title, "新增需求"), "summary": summary, "diff": f"新增待确认需求:{summary}", "payload": { "summary": summary, "acceptanceCriteria": self._string_list(acceptance), "stage": "planning", }, "confidence": confidence, "risk_level": "medium", } ) for raw in self._list_of_dicts(payload.get("tasks")): title = self._clean_text(raw.get("title")) summary = self._clean_text(raw.get("summary") or raw.get("description")) if not title or not summary: continue priority = self._priority(raw.get("priority")) items.append( { "group": "new_object", "object_type": "Task", "title": self._prefixed_title(title, "新增任务"), "summary": summary, "diff": f"新增待确认任务:{summary}", "payload": { "summary": summary, "description": summary, "priority": priority, "acceptanceCriteria": self._string_list(raw.get("acceptance_criteria") or raw.get("acceptanceCriteria")), "requirementIndex": raw.get("requirement_index"), "stage": "development", }, "confidence": confidence, "risk_level": "medium" if priority != "high" else "high", } ) for raw in self._list_of_dicts(payload.get("risks")): title = self._clean_text(raw.get("title")) summary = self._clean_text(raw.get("summary") or raw.get("description")) if not title or not summary: continue level = self._risk_level(raw.get("level") or raw.get("risk_level")) mitigation = self._clean_text(raw.get("mitigation") or raw.get("mitigation_plan")) or "待补充缓解策略。" items.append( { "group": "risk", "object_type": "Risk", "title": self._prefixed_title(title, "新增风险"), "summary": summary, "diff": f"新增待确认风险与缓解策略:{mitigation}", "payload": { "summary": summary, "mitigation": mitigation, "stage": "planning", }, "confidence": confidence, "risk_level": level, } ) for raw in self._list_of_dicts(payload.get("decisions")): title = self._clean_text(raw.get("title")) summary = self._clean_text(raw.get("summary") or raw.get("reason")) if not title or not summary: continue items.append( { "group": "decision", "object_type": "Decision", "title": self._prefixed_title(title, "待确认决策"), "summary": summary, "diff": f"新增待确认决策:{summary}", "payload": { "summary": summary, "owner": self._clean_text(raw.get("owner")) or "未分配", "stage": "planning", }, "confidence": confidence, "risk_level": "medium", } ) return self._coerce_proposed_items(items) @staticmethod def _coerce_proposed_items(items: list[Any]) -> list[PMFlowProposedChangeItem]: proposed: list[PMFlowProposedChangeItem] = [] for raw in items[:24]: if not isinstance(raw, dict): continue try: proposed.append(PMFlowProposedChangeItem.model_validate(raw)) except Exception: continue return proposed @staticmethod def _ai_extraction_summary(*, payload: dict[str, Any], item_count: int) -> str: warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else [] suffix = f";警告:{';'.join(str(item) for item in warnings[:3])}" if warnings else "" return f"AI 从本轮项目线程生成 {item_count} 个待审变更项,尚未写入项目记忆{suffix}。" @staticmethod def _list_of_dicts(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] @staticmethod def _clean_text(value: Any) -> str: return str(value or "").strip() @classmethod def _string_list(cls, value: Any) -> list[str]: if isinstance(value, list): return [cls._clean_text(item) for item in value if cls._clean_text(item)] text = cls._clean_text(value) return [text] if text else [] @staticmethod def _prefixed_title(title: str, prefix: str) -> str: if title.startswith(prefix): return title[:255] return f"{prefix}:{title}"[:255] @staticmethod def _confidence(value: Any, *, default: float) -> float: try: number = float(value) except Exception: number = default return max(0.0, min(1.0, number)) @staticmethod def _risk_level(value: Any) -> str: text = str(value or "medium").strip().lower() if text in {"low", "medium", "high"}: return text if text in {"urgent", "critical", "p0"}: return "high" return "medium" async def _resolve_or_create_thread(self, *, project_id: int, thread_id: str) -> PMFlowThread: cleaned = str(thread_id or "").strip() if cleaned.isdigit(): result = await self.db.execute( select(PMFlowThread).where( and_( PMFlowThread.tenant_id == self.tenant_id, PMFlowThread.project_id == int(project_id), PMFlowThread.id == int(cleaned), ) ) ) thread = result.scalar_one_or_none() if thread is None: raise PMFlowChangeReviewError("thread_not_found", "thread_not_found", status_code=404) return thread if cleaned not in {"default", f"thread-project-{int(project_id)}"}: raise PMFlowChangeReviewError("thread_not_found", "thread_not_found", status_code=404) result = await self.db.execute( select(PMFlowThread) .where(and_(PMFlowThread.tenant_id == self.tenant_id, PMFlowThread.project_id == int(project_id), PMFlowThread.status == "active")) .order_by(PMFlowThread.created_at.asc(), PMFlowThread.id.asc()) .limit(1) ) thread = result.scalar_one_or_none() if thread is not None: return thread thread = PMFlowThread( tenant_id=self.tenant_id, project_id=int(project_id), title="项目默认线程", objective="项目内 PMFlow OS 对话线程。", status="active", source="web", ) self.db.add(thread) await self.db.flush() return thread async def _load_thread_message(self, *, project_id: int, thread_id: str, message_id: str) -> tuple[PMFlowThread, PMFlowThreadMessage]: if not str(thread_id).isdigit() or not str(message_id).isdigit(): raise PMFlowChangeReviewError("message_not_found", "message_not_found", status_code=404) result = await self.db.execute( select(PMFlowThread, PMFlowThreadMessage) .join(PMFlowThreadMessage, PMFlowThreadMessage.thread_id == PMFlowThread.id) .where( and_( PMFlowThread.tenant_id == self.tenant_id, PMFlowThread.project_id == int(project_id), PMFlowThread.id == int(thread_id), PMFlowThreadMessage.tenant_id == self.tenant_id, PMFlowThreadMessage.project_id == int(project_id), PMFlowThreadMessage.id == int(message_id), ) ) ) row = result.first() if row is None: raise PMFlowChangeReviewError("message_not_found", "message_not_found", status_code=404) return row[0], row[1] async def _load_change_set(self, *, project_id: int, change_set_id: int) -> PMFlowChangeSet: result = await self.db.execute( select(PMFlowChangeSet).where( and_( PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id), PMFlowChangeSet.id == int(change_set_id), ) ) ) row = result.scalar_one_or_none() if row is None: raise PMFlowChangeReviewError("changeset_not_found", "changeset_not_found", status_code=404) return row async def _load_change_set_for_commit(self, *, change_set_id: int) -> PMFlowChangeSet: result = await self.db.execute( select(PMFlowChangeSet) .where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.id == int(change_set_id))) .with_for_update() ) row = result.scalar_one_or_none() if row is None: raise PMFlowChangeReviewError("changeset_not_found", "changeset_not_found", status_code=404) return row async def _load_change_set_for_review_decision(self, *, change_set_id: int) -> PMFlowChangeSet: result = await self.db.execute( select(PMFlowChangeSet) .where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.id == int(change_set_id))) .with_for_update() ) row = result.scalar_one_or_none() if row is None: raise PMFlowChangeReviewError("changeset_not_found", "changeset_not_found", status_code=404) if row.status == "committed": raise PMFlowChangeReviewError("changeset_already_committed", "changeset_already_committed", status_code=409) if row.status not in {"open", "reviewed"}: raise PMFlowChangeReviewError("changeset_not_open", "changeset_not_open", status_code=409) return row async def _load_items(self, *, project_id: int, change_set_id: int) -> list[PMFlowChangeItem]: result = await self.db.execute( select(PMFlowChangeItem) .where( and_( PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.project_id == int(project_id), PMFlowChangeItem.change_set_id == int(change_set_id), ) ) .order_by(PMFlowChangeItem.created_at.asc(), PMFlowChangeItem.id.asc()) ) return list(result.scalars().all()) async def _load_evidence(self, *, project_id: int, change_set_id: int) -> list[PMFlowEvidenceLink]: result = await self.db.execute( select(PMFlowEvidenceLink) .where( and_( PMFlowEvidenceLink.tenant_id == self.tenant_id, PMFlowEvidenceLink.project_id == int(project_id), PMFlowEvidenceLink.change_set_id == int(change_set_id), ) ) .order_by(PMFlowEvidenceLink.created_at.asc(), PMFlowEvidenceLink.id.asc()) ) return list(result.scalars().all()) async def _load_approvals(self, *, project_id: int, change_set_id: int) -> list[PMFlowApproval]: result = await self.db.execute( select(PMFlowApproval) .where( and_( PMFlowApproval.tenant_id == self.tenant_id, PMFlowApproval.project_id == int(project_id), PMFlowApproval.change_set_id == int(change_set_id), ) ) .order_by(PMFlowApproval.created_at.asc(), PMFlowApproval.id.asc()) ) return list(result.scalars().all()) async def _load_item_with_set(self, *, change_item_id: int) -> tuple[PMFlowChangeItem, PMFlowChangeSet]: result = await self.db.execute( select(PMFlowChangeItem, PMFlowChangeSet) .join(PMFlowChangeSet, PMFlowChangeSet.id == PMFlowChangeItem.change_set_id) .where(and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.id == int(change_item_id))) .with_for_update() ) row = result.first() if row is None: raise PMFlowChangeReviewError("change_item_not_found", "change_item_not_found", status_code=404) return row[0], row[1] async def _load_project_reviewer(self, *, project_id: int, reviewer_id: Optional[int]) -> Optional[User]: if reviewer_id is None: return None result = await self.db.execute( select(User) .join(UserProject, UserProject.user_id == User.id) .where( and_( User.tenant_id == self.tenant_id, User.id == int(reviewer_id), UserProject.tenant_id == self.tenant_id, UserProject.project_id == int(project_id), UserProject.user_id == int(reviewer_id), User.role.in_(["OWNER", "REVIEWER"]), ) ) ) return result.scalar_one_or_none() def _select_review_decision_items( self, *, items: list[PMFlowChangeItem], raw_item_ids: list[str], ) -> list[PMFlowChangeItem]: selected_ids = self._parse_review_decision_item_ids(raw_item_ids) if selected_ids: item_ids = {int(item.id) for item in items} if not selected_ids.issubset(item_ids): raise PMFlowChangeReviewError("invalid_review_decision_item_ids", "invalid_review_decision_item_ids", status_code=422) return [item for item in items if int(item.id) in selected_ids] return [item for item in items if item.status == "pending_review"] @staticmethod def _parse_review_decision_item_ids(raw_item_ids: list[str]) -> set[int]: parsed: set[int] = set() for raw in raw_item_ids or []: text = str(raw or "").strip() if not text.isdigit(): raise PMFlowChangeReviewError("invalid_review_decision_item_ids", "invalid_review_decision_item_ids", status_code=422) parsed.add(int(text)) return parsed def _mark_change_set_review_meta( self, *, change_set: PMFlowChangeSet, action: str, approval_state: str, user_id: int, reviewer_id: Optional[int], reason: Optional[str], selected_items: list[PMFlowChangeItem], ) -> None: now = now_cn_naive() routing_meta = dict(change_set.routing_meta or {}) review_meta = dict(routing_meta.get("reviewDecision") or {}) if action == "request_review": review_meta["requestedAt"] = review_meta.get("requestedAt") or now.isoformat() review_meta["requestedBy"] = str(user_id) else: review_meta["decidedAt"] = now.isoformat() review_meta["decidedBy"] = str(user_id) review_meta.update( { "action": action, "approvalState": approval_state, "reviewerId": None if reviewer_id is None else str(reviewer_id), "decisionReason": reason, "itemIds": [str(item.id) for item in selected_items], } ) routing_meta["reviewDecision"] = review_meta change_set.routing_meta = routing_meta flag_modified(change_set, "routing_meta") @staticmethod def _mark_change_set_reviewed_if_no_pending(*, change_set: PMFlowChangeSet, items: list[PMFlowChangeItem]) -> None: if not any(item.status == "pending_review" for item in items): change_set.status = "reviewed" change_set.reviewed_at = now_cn_naive() async def _review_item( self, *, item: PMFlowChangeItem, change_set: PMFlowChangeSet, action: str, decision: str, user_id: int, reason: Optional[str] = None, before_payload_override: Optional[dict[str, Any]] = None, ) -> None: if change_set.status not in {"open", "reviewed"}: raise PMFlowChangeReviewError("changeset_not_open", "changeset_not_open", status_code=409) if item.status in {"committed", "skipped"}: raise PMFlowChangeReviewError("item_already_reviewed", "item_already_reviewed", status_code=409) before_status = item.status before_payload = dict(before_payload_override or item.edited_payload or item.payload or {}) next_status = "accepted" if action == "accept" else ("edited" if action == "edit" else "deferred") item.status = next_status item.reviewed_at = now_cn_naive() after_payload = dict(item.edited_payload or item.payload or {}) self.db.add( PMFlowApproval( tenant_id=self.tenant_id, project_id=int(item.project_id), change_set_id=int(item.change_set_id), change_item_id=int(item.id), actor_user_id=int(user_id), action=action, decision=decision, before_status=before_status, after_status=next_status, before_payload=before_payload, after_payload=after_payload, reason=reason, ) ) await self.db.flush() pending_count = await self.db.scalar( select(func.count(PMFlowChangeItem.id)).where( and_( PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.change_set_id == int(change_set.id), PMFlowChangeItem.status == "pending_review", ) ) ) if int(pending_count or 0) == 0: change_set.status = "reviewed" change_set.reviewed_at = now_cn_naive() @staticmethod def _parse_commit_item_ids(raw_item_ids: list[str]) -> set[int]: parsed: set[int] = set() for raw in raw_item_ids or []: text = str(raw or "").strip() if not text.isdigit(): raise PMFlowChangeReviewError("invalid_commit_item_ids", "invalid_commit_item_ids", status_code=422) parsed.add(int(text)) return parsed def _validate_commit_preflight( self, *, change_set: PMFlowChangeSet, items: list[PMFlowChangeItem], selected_ids: set[int], ) -> None: if change_set.status == "committed": raise PMFlowChangeReviewError("changeset_already_committed", "changeset_already_committed", status_code=409) if change_set.status not in {"open", "reviewed"}: raise PMFlowChangeReviewError("changeset_not_reviewed", "changeset_not_reviewed", status_code=409) item_ids = {int(item.id) for item in items} if selected_ids and not selected_ids.issubset(item_ids): raise PMFlowChangeReviewError("invalid_commit_item_ids", "invalid_commit_item_ids", status_code=422) if selected_ids: invalid_selected = [ item for item in items if int(item.id) in selected_ids and item.status not in {"accepted", "edited"} ] if invalid_selected: raise PMFlowChangeReviewError("invalid_commit_item_ids", "invalid_commit_item_ids", status_code=422) return if any(item.status == "pending_review" for item in items): raise PMFlowChangeReviewError("changeset_not_reviewed", "changeset_not_reviewed", status_code=409) def _validate_commit_item( self, *, item: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink], approvals: list[PMFlowApproval], ) -> None: if item.object_type not in self.COMMIT_OBJECT_TYPES: raise PMFlowChangeReviewError("unsupported_object_type", "unsupported_object_type", status_code=422) payload = self._effective_payload(item) self._require_commit_text(item.title, field="title") self._require_commit_text(str(payload.get("summary") or item.summary), field="summary") if item.object_type == "Risk": self._require_commit_text(str(payload.get("mitigation") or payload.get("mitigation_plan") or item.summary), field="mitigation") if item.object_type == "Task": self._require_commit_text(str(payload.get("description") or payload.get("summary") or item.summary), field="description") if str(item.risk_level or "").lower() == "high": approval_ok = any( int(row.change_item_id) == int(item.id) and row.decision in {"approved", "edited"} and row.after_status in {"accepted", "edited"} for row in approvals ) if not approval_ok or not self._item_evidence(item=item, evidence=evidence): raise PMFlowChangeReviewError("high_risk_approval_required", "high_risk_approval_required", status_code=409) async def _commit_item( self, *, item: PMFlowChangeItem, change_set: PMFlowChangeSet, evidence: list[PMFlowEvidenceLink], user_id: int, ) -> dict[str, Any]: if item.object_type == "Task": target_type, target_id = await self._commit_task_item( item=item, change_set=change_set, evidence=evidence, user_id=user_id, ) else: target_type, target_id = await self._commit_truth_node_item( item=item, change_set=change_set, evidence=evidence, user_id=user_id, ) item.object_id = str(target_id) evidence_ids = [str(row.id) for row in self._item_evidence(item=item, evidence=evidence)] return { "changeItemIdRaw": int(item.id), "changeItemId": str(item.id), "objectType": item.object_type, "targetType": target_type, "targetId": str(target_id), "status": "committed", "evidenceIds": evidence_ids, "semanticFactStatus": "seeded", } async def _commit_truth_node_item( self, *, item: PMFlowChangeItem, change_set: PMFlowChangeSet, evidence: list[PMFlowEvidenceLink], user_id: int, ) -> tuple[str, int]: node_type = { "Requirement": "requirement", "Decision": "decision", "Risk": "risk", }.get(item.object_type) if node_type is None: raise PMFlowChangeReviewError("unsupported_object_type", "unsupported_object_type", status_code=422) content = self._commit_content(item=item, change_set=change_set, evidence=evidence, user_id=user_id) node = TruthNode( tenant_id=self.tenant_id, node_type=node_type, project_id=int(item.project_id), signal_id=None, created_by=int(user_id), title=item.title[:500], content=content, version=1, status="confirmed", ) self.db.add(node) await self.db.flush() self._add_semantic_fact_for_commit(node=node, item=item, evidence=evidence) return "truth_node", int(node.id) async def _commit_task_item( self, *, item: PMFlowChangeItem, change_set: PMFlowChangeSet, evidence: list[PMFlowEvidenceLink], user_id: int, ) -> tuple[str, int]: content = self._commit_content(item=item, change_set=change_set, evidence=evidence, user_id=user_id) node = TruthNode( tenant_id=self.tenant_id, node_type="task", project_id=int(item.project_id), signal_id=None, created_by=int(user_id), title=item.title[:500], content=content, version=1, status="confirmed", ) self.db.add(node) await self.db.flush() payload = self._effective_payload(item) task = Task( tenant_id=self.tenant_id, truth_node_id=int(node.id), project_id=int(item.project_id), assignee_id=None, ai_recommended_assignees=None, ai_breakdown_payload={"source": "pmflow_change_set_commit", "payload": payload}, manual_overrides={ "change_set_id": int(change_set.id), "change_item_id": int(item.id), "acceptance_criteria": self._acceptance_criteria(payload), }, title=item.title[:500], description=str(payload.get("description") or payload.get("summary") or item.summary), required_skill_tag_ids=[], status="todo", priority=self._priority(payload.get("priority")), ) self.db.add(task) await self.db.flush() self._add_semantic_fact_for_commit(node=node, item=item, evidence=evidence) self._add_semantic_fact_for_commit(node=None, item=item, evidence=evidence, task=task) return "task", int(task.id) def _commit_content( self, *, item: PMFlowChangeItem, change_set: PMFlowChangeSet, evidence: list[PMFlowEvidenceLink], user_id: int, ) -> dict[str, Any]: payload = self._effective_payload(item) evidence_ids = [str(row.id) for row in self._item_evidence(item=item, evidence=evidence)] return { "source": "pmflow_change_set_commit", "change_set_id": int(change_set.id), "change_item_id": int(item.id), "object_type": item.object_type, "summary": str(payload.get("summary") or item.summary), "diff": item.diff, "payload": payload, "stage": str(payload.get("stage") or ("development" if item.object_type == "Task" else "planning")), "owner": str(payload.get("owner") or payload.get("decision_owner") or "未分配"), "mitigation": str(payload.get("mitigation") or payload.get("mitigation_plan") or item.summary), "evidence_ids": evidence_ids, "confidence": float(item.confidence or 0.0), "risk_level": item.risk_level, "committed_by": int(user_id), "committed_at": now_cn_naive().isoformat(), } def _add_semantic_fact_for_commit( self, *, node: Optional[TruthNode], item: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink], task: Optional[Task] = None, ) -> None: target_type = "task" if task is not None else "truth_node" target_id = int(task.id) if task is not None else int(node.id) # type: ignore[union-attr] source_evidence = self._item_evidence(item=item, evidence=evidence) first_evidence = source_evidence[0] if source_evidence else None text = str(self._effective_payload(item).get("summary") or item.summary).strip() if not text: return self.db.add( SemanticFact( tenant_id=self.tenant_id, entity_type=target_type, entity_id=target_id, fact_namespace="pmflow_change_commit", fact_key="summary", fact_value_text=text, fact_value_norm=text[:255], value_type="text", confidence=float(item.confidence or 0.0), evidence_source_type="pmflow_evidence_link" if first_evidence is not None else None, evidence_source_id=int(first_evidence.id) if first_evidence is not None else None, evidence_excerpt=first_evidence.excerpt if first_evidence is not None else None, extract_version="pmflow_change_commit_v1", freshness_at=now_cn_naive().isoformat(), is_active=True, ) ) @staticmethod def _effective_payload(item: PMFlowChangeItem) -> dict[str, Any]: payload = item.edited_payload if item.status == "edited" and item.edited_payload is not None else item.payload return dict(payload or {}) @staticmethod def _item_evidence(*, item: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink]) -> list[PMFlowEvidenceLink]: return [row for row in evidence if row.change_item_id in {None, item.id}] @staticmethod def _require_commit_text(value: str, *, field: str) -> None: if not str(value or "").strip(): raise PMFlowChangeReviewError("invalid_change_payload", f"missing_{field}", status_code=422) @staticmethod def _priority(value: Any) -> str: text = str(value or "medium").strip().lower() if text in {"high", "medium", "low"}: return text if text in {"p0", "critical", "urgent"}: return "high" if text in {"p2", "p3"}: return "low" return "medium" @staticmethod def _acceptance_criteria(payload: dict[str, Any]) -> list[str]: raw = payload.get("acceptanceCriteria") or payload.get("acceptance_criteria") or [] if isinstance(raw, list): return [str(item).strip() for item in raw if str(item).strip()] text = str(raw or "").strip() return [text] if text else [] @staticmethod def _serialize_skipped_commit_item(item: PMFlowChangeItem) -> dict[str, Any]: return { "changeItemId": str(item.id), "objectType": item.object_type, "status": item.status, "reason": "item_not_accepted_or_edited", } @staticmethod def serialize_message(row: PMFlowThreadMessage) -> dict[str, Any]: return { "id": str(row.id), "threadId": str(row.thread_id), "projectId": str(row.project_id), "role": row.role, "author": "你" if row.role == "user" else "PMFlow", "content": row.content, "createdAt": row.created_at.isoformat() if row.created_at else "", "status": "sent", "source": row.source, "metadata": row.message_metadata or {}, } @staticmethod def serialize_change_set(row: PMFlowChangeSet, items: list[PMFlowChangeItem]) -> dict[str, Any]: status = row.status if row.status in {"open", "reviewed", "committed", "partially_committed"} else "reviewed" return { "id": str(row.id), "threadId": str(row.thread_id), "summary": row.summary, "headline": row.headline, "createdAt": row.created_at.isoformat() if row.created_at else "", "status": status, "itemIds": [str(item.id) for item in items], "sourceMessageId": str(row.source_message_id), "rawStatus": row.status, "committedAt": row.committed_at.isoformat() if row.committed_at else None, "committedById": str(row.committed_by) if row.committed_by else None, "routingMeta": row.routing_meta or {}, } async def _load_user_display_name(self, *, user_id: int) -> str: result = await self.db.execute( select(User).where(and_(User.tenant_id == self.tenant_id, User.id == int(user_id))) ) user = result.scalar_one_or_none() if user is None: return str(user_id) return str(user.name or user.username or user.email or user.id) @staticmethod def serialize_change_item(row: PMFlowChangeItem, evidence: list[PMFlowEvidenceLink]) -> dict[str, Any]: evidence_ids = [str(item.id) for item in evidence if item.change_item_id in {None, row.id}] return { "id": str(row.id), "changeSetId": str(row.change_set_id), "group": row.group, "objectType": row.object_type, "objectId": str(row.object_id or f"pending-{row.id}"), "title": row.title, "summary": row.summary, "diff": row.diff, "confidence": float(row.confidence or 0.0), "riskLevel": row.risk_level, "status": row.status if row.status in {"pending_review", "accepted", "edited", "deferred", "committed", "skipped"} else "deferred", "evidenceIds": evidence_ids, "payload": row.payload or {}, "editedPayload": row.edited_payload, } @staticmethod def serialize_evidence(row: PMFlowEvidenceLink) -> dict[str, Any]: kind = "conversation" if row.source_type == "thread_message" else "document" return { "id": str(row.id), "kind": kind, "title": row.title, "excerpt": row.excerpt, "sourceLabel": row.display_label, "sourceType": row.source_type, "sourceId": row.source_id, "changeSetId": str(row.change_set_id), "changeItemId": str(row.change_item_id) if row.change_item_id is not None else None, "metadata": row.evidence_metadata or {}, } @staticmethod def serialize_approval(row: PMFlowApproval) -> dict[str, Any]: return { "id": str(row.id), "title": f"{row.action} {row.change_item_id}", "summary": row.reason or f"{row.before_status} -> {row.after_status}", "stage": "planning", "status": "confirmed" if row.decision in {"approved", "edited"} else "draft", "owner": str(row.actor_user_id), "changeSetId": str(row.change_set_id), "changeItemId": str(row.change_item_id), "action": row.action, "decision": row.decision, "createdAt": row.created_at.isoformat() if row.created_at else "", }