464 lines
19 KiB
Python
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 "",
|
|
}
|