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

1064 lines
45 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 sqlalchemy.orm.attributes import flag_modified
from ..models import (
PMFlowApproval,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowThread,
PMFlowThreadMessage,
SemanticFact,
Task,
TruthNode,
User,
UserProject,
)
from ..schemas.pmflow_os import (
PMFlowChangeItemEditRequest,
PMFlowChangeSetCommitRequest,
PMFlowExtractionCreateRequest,
PMFlowReviewDecisionRequest,
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:
COMMIT_OBJECT_TYPES = {"Requirement", "Decision", "Task", "Risk"}
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 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"
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"}
]
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,
"committedItemIds": [item["changeItemId"] for item in committed_items],
"skippedItemIds": [item["changeItemId"] for item in skipped_items],
"failedItemIds": [],
},
"committedItems": [
{key: value for key, value in item.items() if key != "changeItemIdRaw"} for item in 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
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 != "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 _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,
"routingMeta": row.routing_meta or {},
}
@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 "",
}