PMFlow/backend/app/services/pmflow_change_review_servic...

464 lines
19 KiB
Python

"""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 "",
}