"""PMFlow OS change review state machine service.""" from __future__ import annotations from typing import Any, Optional from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( PMFlowApproval, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, PMFlowThread, PMFlowThreadMessage, ) from ..schemas.pmflow_os import ( PMFlowChangeItemEditRequest, PMFlowExtractionCreateRequest, PMFlowThreadMessageCreateRequest, ) 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: 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]: 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 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 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 _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_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 _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 != "open": raise PMFlowChangeReviewError("changeset_not_open", "changeset_not_open", status_code=409) if item.status != "pending_review": 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 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]: 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": "open" if row.status == "open" else "reviewed", "itemIds": [str(item.id) for item in items], "sourceMessageId": str(row.source_message_id), "rawStatus": row.status, } @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"} 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 "", }