6596 lines
314 KiB
Python
6596 lines
314 KiB
Python
"""PMFlow OS read-only projection service."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import secrets
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from typing import Any, Optional
|
||
|
||
from sqlalchemy import and_, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
|
||
from ..models import (
|
||
ComplianceCheckRun,
|
||
FeedbackIngest,
|
||
IterationRecommendation,
|
||
ModelCatalogItem,
|
||
ModelEvalRun,
|
||
ModelRecommendationSnapshot,
|
||
PMFlowApproval,
|
||
PMFlowAIFeedbackEvent,
|
||
PMFlowChangeItem,
|
||
PMFlowChangeSet,
|
||
PMFlowEvidenceLink,
|
||
PMFlowThreadMessage,
|
||
PMFlowThread as PMFlowThreadModel,
|
||
PMFlowExecutionRuntimeCredential,
|
||
ProgressEvent,
|
||
Project,
|
||
ProjectModelBinding,
|
||
PromptPackage,
|
||
ReleaseValidationRun,
|
||
RepoBinding,
|
||
RevenueEvent,
|
||
ROISnapshot,
|
||
SemanticFact,
|
||
Task,
|
||
TruthNode,
|
||
User,
|
||
UserProject,
|
||
)
|
||
from ..schemas.pmflow_os import (
|
||
ExecutionSummaryProjection,
|
||
PMFlowDecision,
|
||
PMFlowExecutionAcceptanceDecisionRequest,
|
||
PMFlowArtifactReviewActionRequest,
|
||
PMFlowArtifactReworkPackageRequest,
|
||
PMFlowExecutionHandoffConsumeRequest,
|
||
PMFlowExecutionHandoffRequest,
|
||
PMFlowExecutionRuntimeCredentialCreateRequest,
|
||
PMFlowExecutionRuntimeCredentialResponse,
|
||
PMFlowExecutionMemoryWritebackRequest,
|
||
PMFlowExecutionProgressEventRequest,
|
||
PMFlowRuntimeExecutionPackageRequest,
|
||
PMFlowRuntimeExecutionPackageResponse,
|
||
PMFlowRuntimeExecutionResultRequest,
|
||
PMFlowExecutionContext,
|
||
PMFlowFeedEvent,
|
||
PMFlowIterationRecommendationChangeSetDraftRequest,
|
||
PMFlowIterationRecommendationGroupChangeSetDraftRequest,
|
||
PMFlowMetricSignal,
|
||
PMFlowModelDecision,
|
||
PMFlowProjectMemory,
|
||
PMFlowProjectSummary,
|
||
PMFlowRelease,
|
||
PMFlowReleaseFeedbackIngestRequest,
|
||
PMFlowReleaseValidationRunCreateRequest,
|
||
PMFlowRequirement,
|
||
PMFlowRisk,
|
||
PMFlowTask,
|
||
PMFlowTestRun,
|
||
PMFlowThread,
|
||
ProjectTruthStateProjection,
|
||
)
|
||
from ..core.config import settings
|
||
from .ai_project_control_service import AIProjectControlService
|
||
from .pmflow_ai_feedback_service import PMFlowAIFeedbackService
|
||
from .pmflow_change_review_service import PMFlowChangeReviewService
|
||
from ..utils.timezone import now_cn_naive
|
||
|
||
|
||
class PMFlowOSProjectionService:
|
||
"""Builds PMFlow OS compatible read models from existing production tables."""
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int):
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
|
||
async def get_truth_state(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ProjectTruthStateProjection:
|
||
project = await self._load_project(project_id=project_id)
|
||
truth_nodes = await self._load_truth_nodes(project_id=project_id)
|
||
tasks = await self._load_tasks(project_id=project_id)
|
||
task_items = self._project_tasks(tasks)
|
||
execution_summary = await self.get_execution_summary(
|
||
project_id=project_id,
|
||
current_user_id=current_user_id,
|
||
)
|
||
project_stage = self._infer_project_stage(truth_nodes=truth_nodes, tasks=tasks, execution=execution_summary.execution)
|
||
launch_thread, launch_messages = await self._load_project_thread(project_id=project_id)
|
||
thread_id = str(launch_thread.id) if launch_thread is not None else f"thread-project-{project.id}"
|
||
requirements = self._project_requirements(truth_nodes=truth_nodes, tasks=task_items)
|
||
releases = await self._project_releases(truth_nodes=truth_nodes, project_id=project_id)
|
||
test_runs = await self._project_test_runs(project_id=project_id)
|
||
metric_signals = await self._project_metric_signals(project_id=project_id)
|
||
memory = await self._project_memory(
|
||
project=project,
|
||
truth_nodes=truth_nodes,
|
||
task_ids=[int(task.id) for task, _ in tasks],
|
||
)
|
||
review_projection = await self._load_review_projection(project_id=project_id)
|
||
active_change_set_id = self._active_change_set_id(review_projection["change_sets"])
|
||
latest_writeback = self._latest_writeback_meta(review_projection["change_sets"])
|
||
project_members = await self._load_project_members(project=project)
|
||
reviewer_routing, reviewer_warnings = self._project_reviewer_routing(
|
||
project_id=project_id,
|
||
change_sets=review_projection["change_sets"],
|
||
change_items=review_projection["change_items"],
|
||
evidence=review_projection["evidence"],
|
||
members=project_members,
|
||
)
|
||
loop_stage = self._review_loop_stage(
|
||
change_sets=review_projection["change_sets"],
|
||
change_items=review_projection["change_items"],
|
||
)
|
||
warnings: list[str] = []
|
||
if launch_thread is None:
|
||
warnings.append("thread_projection_empty: no pmflow_threads row was found; returning an empty canonical thread.")
|
||
if not launch_messages:
|
||
warnings.append("thread_messages_empty: no pmflow_thread_messages rows were found for this project.")
|
||
if not truth_nodes:
|
||
warnings.append("truth_nodes_empty: no confirmed project truth nodes were found for this project.")
|
||
if not tasks:
|
||
warnings.append("tasks_empty: no task rows were found for this project.")
|
||
ai_feedback = await self._load_ai_feedback(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
return ProjectTruthStateProjection(
|
||
loopStage=loop_stage,
|
||
lastWritebackAt=latest_writeback.get("at")
|
||
or self._latest_iso([node.updated_at for node in truth_nodes] + [task.updated_at for task, _ in tasks]),
|
||
lastWritebackBy=latest_writeback.get("by"),
|
||
lastWritebackById=latest_writeback.get("by_id"),
|
||
project=PMFlowProjectSummary(
|
||
id=str(project.id),
|
||
name=project.title or project.name,
|
||
goal=self._project_goal(project=project, truth_nodes=truth_nodes),
|
||
stage=project_stage,
|
||
threadId=thread_id,
|
||
),
|
||
thread=PMFlowThread(
|
||
id=thread_id,
|
||
title=launch_thread.title if launch_thread is not None else f"{project.title or project.name} 项目线程",
|
||
projectId=str(project.id),
|
||
objective=launch_thread.objective if launch_thread is not None else self._project_goal(project=project, truth_nodes=truth_nodes),
|
||
extractionStatus="idle",
|
||
messageIds=[str(message.id) for message in launch_messages],
|
||
),
|
||
messages=self._project_messages(launch_messages),
|
||
changeSets=review_projection["change_sets"],
|
||
changeItems=review_projection["change_items"],
|
||
evidence=review_projection["evidence"],
|
||
requirements=requirements,
|
||
decisions=self._project_decisions(truth_nodes),
|
||
tasks=task_items,
|
||
risks=self._project_risks(truth_nodes),
|
||
modelDecisions=await self._project_model_decisions(project_id=project_id),
|
||
testRuns=test_runs,
|
||
releases=releases,
|
||
metricSignals=metric_signals,
|
||
aiFeedback=ai_feedback,
|
||
feed=self._project_feed(
|
||
review_projection=review_projection,
|
||
reviewer_routing=reviewer_routing,
|
||
execution_summary=execution_summary,
|
||
test_runs=test_runs,
|
||
releases=releases,
|
||
metric_signals=metric_signals,
|
||
),
|
||
approvals=review_projection["approvals"],
|
||
reviewerRouting=reviewer_routing,
|
||
activeChangeSetId=active_change_set_id,
|
||
selectedTaskId=execution_summary.execution.taskId,
|
||
execution=execution_summary.execution,
|
||
projectMemory=memory,
|
||
projectionWarnings=warnings + reviewer_warnings + execution_summary.warnings,
|
||
)
|
||
|
||
async def get_project_memory_overview(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0)
|
||
assets_payload = await self.get_project_memory_assets(project_id=project_id, limit=200, offset=0)
|
||
context_payload = await self.get_project_memory_context_packs(project_id=project_id)
|
||
writebacks_payload = await self.get_project_memory_writebacks(project_id=project_id, limit=8, offset=0)
|
||
items = list(items_payload.get("items") or [])
|
||
assets = list(assets_payload.get("assets") or [])
|
||
context_packs = list(context_payload.get("contextPacks") or [])
|
||
writebacks = list(writebacks_payload.get("writebacks") or [])
|
||
metric_counts = self._memory_metric_counts(items=items, assets=assets, context_packs=context_packs)
|
||
return {
|
||
"projectId": str(project.id),
|
||
"projectName": project.title or project.name,
|
||
"generatedAt": now_cn_naive().isoformat(),
|
||
"metrics": [
|
||
{"key": "confirmed_facts", "label": "已确认事实", "value": metric_counts["confirmed_facts"]},
|
||
{"key": "requirements", "label": "需求与范围", "value": metric_counts["requirements"]},
|
||
{"key": "decisions", "label": "决策", "value": metric_counts["decisions"]},
|
||
{"key": "constraints", "label": "约束条件", "value": metric_counts["constraints"]},
|
||
{"key": "evidence", "label": "证据来源", "value": metric_counts["evidence"]},
|
||
{"key": "context_packs", "label": "上下文包", "value": metric_counts["context_packs"]},
|
||
{"key": "assets", "label": "项目资产", "value": metric_counts["assets"]},
|
||
],
|
||
"facts": [item for item in items if item.get("category") == "fact"][:5],
|
||
"requirements": [item for item in items if item.get("category") == "requirement"][:5],
|
||
"decisions": [item for item in items if item.get("category") == "decision"][:5],
|
||
"constraints": [item for item in items if item.get("category") in {"constraint", "risk"}][:5],
|
||
"evidence": assets[:5],
|
||
"contextPacks": context_packs[:5],
|
||
"writebacks": writebacks[:5],
|
||
"aiSuggestions": self._memory_ai_suggestions(
|
||
items=items,
|
||
assets=assets,
|
||
context_packs=context_packs,
|
||
writebacks=writebacks,
|
||
),
|
||
"warnings": [],
|
||
}
|
||
|
||
async def get_project_memory_items(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
category: Optional[str] = None,
|
||
stage: Optional[str] = None,
|
||
source_type: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
query: Optional[str] = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
truth_nodes = await self._load_truth_nodes(project_id=project_id)
|
||
tasks = await self._load_tasks(project_id=project_id)
|
||
semantic_facts = await self._load_project_memory_semantic_facts(project_id=project_id, truth_nodes=truth_nodes, tasks=tasks)
|
||
evidence_by_item = await self._memory_evidence_by_item(project_id=project_id)
|
||
items: list[dict[str, Any]] = []
|
||
for node in truth_nodes:
|
||
items.append(self._memory_item_from_truth_node(node, evidence_by_item=evidence_by_item))
|
||
for task, user in tasks:
|
||
items.append(self._memory_item_from_task(task, user=user))
|
||
for fact in semantic_facts:
|
||
items.append(self._memory_item_from_semantic_fact(fact))
|
||
filtered = self._filter_memory_rows(items, category=category, stage=stage, source_type=source_type, status=status, query=query)
|
||
filtered.sort(key=lambda item: item.get("updatedAt") or item.get("createdAt") or "", reverse=True)
|
||
safe_limit = max(1, min(int(limit or 50), 200))
|
||
safe_offset = max(0, int(offset or 0))
|
||
return {
|
||
"projectId": str(project.id),
|
||
"total": len(filtered),
|
||
"limit": safe_limit,
|
||
"offset": safe_offset,
|
||
"items": filtered[safe_offset : safe_offset + safe_limit],
|
||
}
|
||
|
||
async def get_project_memory_item(self, *, project_id: int, item_id: str) -> dict[str, Any]:
|
||
items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0)
|
||
item_id_value = str(item_id or "").strip()
|
||
for item in items_payload.get("items") or []:
|
||
if str(item.get("id")) == item_id_value:
|
||
return {"projectId": str(project_id), "item": item}
|
||
raise ValueError("project_memory_item_not_found")
|
||
|
||
async def get_project_memory_assets(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
asset_type: Optional[str] = None,
|
||
source_type: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
query: Optional[str] = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
assets: list[dict[str, Any]] = []
|
||
evidence_result = await self.db.execute(
|
||
select(PMFlowEvidenceLink)
|
||
.where(and_(PMFlowEvidenceLink.tenant_id == self.tenant_id, PMFlowEvidenceLink.project_id == int(project_id)))
|
||
.order_by(PMFlowEvidenceLink.updated_at.desc(), PMFlowEvidenceLink.created_at.desc(), PMFlowEvidenceLink.id.desc())
|
||
.limit(500)
|
||
)
|
||
for evidence in evidence_result.scalars().all():
|
||
assets.append(self._memory_asset_from_evidence(evidence))
|
||
message_result = await self.db.execute(
|
||
select(PMFlowThreadMessage)
|
||
.where(and_(PMFlowThreadMessage.tenant_id == self.tenant_id, PMFlowThreadMessage.project_id == int(project_id)))
|
||
.order_by(PMFlowThreadMessage.updated_at.desc(), PMFlowThreadMessage.created_at.desc(), PMFlowThreadMessage.id.desc())
|
||
.limit(300)
|
||
)
|
||
for message in message_result.scalars().all():
|
||
assets.append(self._memory_asset_from_thread_message(message))
|
||
filtered = [asset for asset in assets if not asset_type or str(asset.get("assetType")) == str(asset_type)]
|
||
filtered = self._filter_memory_rows(filtered, category=None, stage=None, source_type=source_type, status=status, query=query)
|
||
filtered.sort(key=lambda item: item.get("updatedAt") or item.get("createdAt") or "", reverse=True)
|
||
safe_limit = max(1, min(int(limit or 50), 200))
|
||
safe_offset = max(0, int(offset or 0))
|
||
return {
|
||
"projectId": str(project.id),
|
||
"total": len(filtered),
|
||
"limit": safe_limit,
|
||
"offset": safe_offset,
|
||
"assets": filtered[safe_offset : safe_offset + safe_limit],
|
||
}
|
||
|
||
async def get_project_memory_context_packs(self, *, project_id: int) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
items_payload = await self.get_project_memory_items(project_id=project_id, limit=500, offset=0)
|
||
assets_payload = await self.get_project_memory_assets(project_id=project_id, limit=200, offset=0)
|
||
items = list(items_payload.get("items") or [])
|
||
assets = list(assets_payload.get("assets") or [])
|
||
packs = [
|
||
self._memory_context_pack(
|
||
key="project-truth",
|
||
title="项目可信真相包",
|
||
summary="已确认事实、需求、决策与约束,供项目问答和执行主链优先引用。",
|
||
usage="项目问答 / 需求拆解 / 执行前上下文",
|
||
item_ids=[item["id"] for item in items if item.get("category") in {"fact", "requirement", "decision", "constraint", "risk"}],
|
||
asset_ids=[asset["id"] for asset in assets[:20]],
|
||
),
|
||
self._memory_context_pack(
|
||
key="development",
|
||
title="开发执行上下文包",
|
||
summary="面向开发 Agent 的需求、决策、风险和任务上下文。",
|
||
usage="开发任务交接 / Prompt Package / PR 前检查",
|
||
item_ids=[item["id"] for item in items if item.get("category") in {"requirement", "decision", "constraint", "risk", "task"}],
|
||
asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"conversation", "evidence", "code"}][:20],
|
||
),
|
||
self._memory_context_pack(
|
||
key="testing",
|
||
title="测试与验收上下文包",
|
||
summary="面向测试计划、验收标准和风险检查的上下文。",
|
||
usage="测试计划 / 验收用例 / 发布门禁",
|
||
item_ids=[item["id"] for item in items if item.get("category") in {"requirement", "constraint", "risk", "task", "test"}],
|
||
asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"test", "evidence", "conversation"}][:20],
|
||
),
|
||
self._memory_context_pack(
|
||
key="release-growth",
|
||
title="发布与增长上下文包",
|
||
summary="面向发布判断、反馈回流和下一轮迭代的上下文。",
|
||
usage="发布检查 / 增长复盘 / 迭代建议",
|
||
item_ids=[item["id"] for item in items if item.get("category") in {"release", "metric", "decision", "risk"}],
|
||
asset_ids=[asset["id"] for asset in assets if asset.get("assetType") in {"release", "metric", "feedback", "evidence"}][:20],
|
||
),
|
||
]
|
||
return {"projectId": str(project.id), "total": len(packs), "contextPacks": packs}
|
||
|
||
async def get_project_memory_writebacks(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
result = await self.db.execute(
|
||
select(PMFlowChangeSet)
|
||
.where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id)))
|
||
.order_by(PMFlowChangeSet.committed_at.desc(), PMFlowChangeSet.updated_at.desc(), PMFlowChangeSet.created_at.desc())
|
||
.limit(300)
|
||
)
|
||
change_sets = list(result.scalars().all())
|
||
change_set_ids = [int(change_set.id) for change_set in change_sets]
|
||
committed_user_ids = [int(change_set.committed_by) for change_set in change_sets if change_set.committed_by]
|
||
committed_users = await self._load_user_display_map(user_ids=committed_user_ids)
|
||
by_change_set: dict[int, list[PMFlowChangeItem]] = {}
|
||
if change_set_ids:
|
||
items_result = await self.db.execute(
|
||
select(PMFlowChangeItem)
|
||
.where(and_(PMFlowChangeItem.tenant_id == self.tenant_id, PMFlowChangeItem.change_set_id.in_(change_set_ids)))
|
||
.order_by(PMFlowChangeItem.updated_at.desc(), PMFlowChangeItem.created_at.desc(), PMFlowChangeItem.id.desc())
|
||
)
|
||
for item in items_result.scalars().all():
|
||
by_change_set.setdefault(int(item.change_set_id), []).append(item)
|
||
execution_result = await self.db.execute(
|
||
select(ProgressEvent, Task, User)
|
||
.join(Task, Task.id == ProgressEvent.task_id)
|
||
.outerjoin(User, User.id == ProgressEvent.user_id)
|
||
.where(
|
||
and_(
|
||
ProgressEvent.tenant_id == self.tenant_id,
|
||
ProgressEvent.project_id == int(project_id),
|
||
ProgressEvent.event_type == "execution_memory_writeback",
|
||
)
|
||
)
|
||
.order_by(ProgressEvent.occurred_at.desc(), ProgressEvent.id.desc())
|
||
.limit(300)
|
||
)
|
||
rows = [
|
||
self._memory_writeback_from_change_set(
|
||
change_set,
|
||
items=by_change_set.get(int(change_set.id), []),
|
||
committed_by=committed_users.get(int(change_set.committed_by)) if change_set.committed_by else None,
|
||
)
|
||
for change_set in change_sets
|
||
]
|
||
rows.extend(
|
||
self._memory_writeback_from_execution_event(event=event, task=task, user=user)
|
||
for event, task, user in execution_result.all()
|
||
)
|
||
rows.sort(key=lambda item: str(item.get("committedAt") or item.get("updatedAt") or item.get("createdAt") or ""), reverse=True)
|
||
safe_limit = max(1, min(int(limit or 50), 200))
|
||
safe_offset = max(0, int(offset or 0))
|
||
return {
|
||
"projectId": str(project.id),
|
||
"total": len(rows),
|
||
"limit": safe_limit,
|
||
"offset": safe_offset,
|
||
"writebacks": rows[safe_offset : safe_offset + safe_limit],
|
||
}
|
||
|
||
async def get_execution_summary(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
await self._load_project(project_id=project_id)
|
||
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
|
||
dev_sync = await control.get_project_dev_sync_summary(
|
||
project_id=int(project_id),
|
||
current_user_id=current_user_id,
|
||
)
|
||
latest_package = await self._load_latest_prompt_package(project_id=project_id)
|
||
warnings: list[str] = []
|
||
events = list(dev_sync.get("recent_progress_events") or [])
|
||
active_tasks = list(dev_sync.get("active_dev_tasks") or [])
|
||
repos = list(dev_sync.get("repo_bindings") or [])
|
||
deliveries = list(dev_sync.get("recent_deliveries") or [])
|
||
|
||
if not repos:
|
||
warnings.append("repo_bindings_empty: no active repo binding is available for this project.")
|
||
if not active_tasks:
|
||
warnings.append("active_dev_tasks_empty: no active dev task is available in dev-sync summary.")
|
||
if not latest_package:
|
||
warnings.append("prompt_package_empty: no prompt package has been generated for this project.")
|
||
if not events:
|
||
warnings.append("progress_events_empty: no execution progress events have been recorded.")
|
||
|
||
task_id = self._pick_execution_task_id(active_tasks=active_tasks, latest_package=latest_package, events=events)
|
||
latest_event = events[0] if events else None
|
||
status = self._map_execution_status(
|
||
task_status=self._pick_task_status(active_tasks, task_id),
|
||
event=latest_event,
|
||
)
|
||
prompt = latest_package.package_text if latest_package is not None else ""
|
||
commit_log = self._event_commit_log(events)
|
||
pr_status = self._project_pr_status(active_tasks=active_tasks, task_id=task_id, events=events)
|
||
test_summary = self._project_test_summary(events)
|
||
writeback = self._project_writeback_status(latest_event)
|
||
latest_release_validation = await self._load_latest_release_validation_run(project_id=project_id)
|
||
quality_contract = self._execution_quality_contract_for_summary(
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
active_tasks=active_tasks,
|
||
latest_package=latest_package,
|
||
repo_bindings=repos,
|
||
events=events,
|
||
)
|
||
delivery_artifacts = self._delivery_artifacts_projection(events)
|
||
artifact_completeness = self._artifact_completeness(
|
||
delivery_artifacts=delivery_artifacts,
|
||
events=events,
|
||
quality_contract=quality_contract,
|
||
)
|
||
artifact_review = self._artifact_review_projection(
|
||
delivery_artifacts=delivery_artifacts,
|
||
artifact_completeness=artifact_completeness,
|
||
events=events,
|
||
)
|
||
acceptance = self._build_execution_acceptance(
|
||
events=events,
|
||
quality_contract=quality_contract,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
release_gate = self._build_release_gate(
|
||
events=events,
|
||
deliveries=deliveries,
|
||
acceptance=acceptance,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
if latest_release_validation is not None:
|
||
release_gate["latestValidationRun"] = self._serialize_release_validation_run(latest_release_validation)
|
||
release_signals = await self._build_release_signals(
|
||
project_id=project_id,
|
||
release_gate=release_gate,
|
||
latest_release_validation=latest_release_validation,
|
||
)
|
||
|
||
return ExecutionSummaryProjection(
|
||
projectId=str(project_id),
|
||
execution=PMFlowExecutionContext(
|
||
taskId=task_id,
|
||
status=status,
|
||
prompt=prompt,
|
||
commitLog=commit_log,
|
||
prStatus=pr_status,
|
||
testSummary=test_summary,
|
||
writebackStatus=writeback,
|
||
),
|
||
qualityContract=quality_contract,
|
||
acceptance=acceptance,
|
||
releaseGate=release_gate,
|
||
releaseSignals=release_signals,
|
||
devSync=dev_sync.get("summary") or {},
|
||
promptPackage=None if latest_package is None else self._serialize_prompt_package(latest_package),
|
||
repoBindings=repos,
|
||
activeDevTasks=active_tasks,
|
||
recentProgressEvents=events,
|
||
recentDeliveries=deliveries,
|
||
deliveryArtifacts=delivery_artifacts,
|
||
artifactCompleteness=artifact_completeness,
|
||
artifactReview=artifact_review,
|
||
warnings=warnings,
|
||
)
|
||
|
||
async def create_execution_handoff(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowExecutionHandoffRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
project = await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=body.repo_binding_id)
|
||
prompt_package = await self._load_active_prompt_package_for_task(project_id=project_id, task_id=int(task.id))
|
||
if prompt_package is None:
|
||
prompt_package = await self._create_execution_prompt_package(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
)
|
||
else:
|
||
payload = dict(self._payload(prompt_package.package_payload))
|
||
if str(payload.get("handoff_type") or "") == "artifact_rework":
|
||
prompt_package = await self._create_execution_prompt_package(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
)
|
||
payload = dict(self._payload(prompt_package.package_payload))
|
||
payload["latest_handoff"] = self._handoff_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
prompt_package_id=int(prompt_package.id),
|
||
)
|
||
prompt_package.package_payload = payload
|
||
flag_modified(prompt_package, "package_payload")
|
||
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
event_type="handoff_created",
|
||
source="pmflow_os",
|
||
derived_task_status="pushed",
|
||
summary=self._handoff_summary(task=task, repo=repo, runtime_target=body.runtime_target),
|
||
payload=self._handoff_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
prompt_package_id=int(prompt_package.id),
|
||
),
|
||
occurred_at=now_cn_naive(),
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def create_execution_artifact_rework_package(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowArtifactReworkPackageRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
project = await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=body.repo_binding_id)
|
||
current_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
artifact_review = dict(current_summary.artifactReview or {})
|
||
items = [item for item in list(artifact_review.get("items") or []) if isinstance(item, dict)]
|
||
available_ids = {str(item.get("id") or "") for item in items}
|
||
selected_ids = [str(item).strip() for item in list(body.review_item_ids or []) if str(item).strip()]
|
||
if selected_ids and not set(selected_ids).issubset(available_ids):
|
||
raise ValueError("invalid_artifact_review_item_ids")
|
||
selected_items = [item for item in items if not selected_ids or str(item.get("id") or "") in set(selected_ids)]
|
||
if not selected_items:
|
||
raise ValueError("artifact_review_empty")
|
||
prompt_package = await self._create_artifact_rework_prompt_package(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
current_summary=current_summary,
|
||
review_items=selected_items,
|
||
)
|
||
event_payload = self._handoff_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=body.runtime_target,
|
||
note=body.note,
|
||
prompt_package_id=int(prompt_package.id),
|
||
)
|
||
event_payload.update(
|
||
{
|
||
"protocol_version": "artifact_rework_package_v1",
|
||
"handoff_type": "artifact_rework",
|
||
"review_item_ids": [str(item.get("id") or "") for item in selected_items],
|
||
"review_items": selected_items[:20],
|
||
"artifact_review_status": artifact_review.get("status"),
|
||
"boundary": "PMFlow creates a rework handoff package only; execution, branch operations, deployment, and Project Memory writes remain outside this action.",
|
||
}
|
||
)
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
event_type="artifact_rework_package_created",
|
||
source="pmflow_os",
|
||
derived_task_status="pushed",
|
||
summary=self._artifact_rework_package_summary(task=task, items=selected_items, note=body.note),
|
||
payload=event_payload,
|
||
occurred_at=now_cn_naive(),
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def record_execution_acceptance_decision(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowExecutionAcceptanceDecisionRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
current_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
task_events = [
|
||
event
|
||
for event in list(current_summary.recentProgressEvents or [])
|
||
if str((event.get("task") or {}).get("id") or current_summary.execution.taskId or "") == str(task.id)
|
||
]
|
||
quality_contract = dict(current_summary.qualityContract or {})
|
||
acceptance = self._build_execution_acceptance(
|
||
events=task_events,
|
||
quality_contract=quality_contract,
|
||
artifact_completeness=current_summary.artifactCompleteness,
|
||
artifact_review=current_summary.artifactReview,
|
||
)
|
||
if body.decision in {"accept", "approve_technical_review"}:
|
||
if not self._acceptance_has_required_test_evidence(task_events):
|
||
raise ValueError("acceptance_test_evidence_required")
|
||
if body.decision == "accept" and bool((quality_contract.get("acceptanceGate") or {}).get("technicalReviewRequired")):
|
||
if acceptance.get("status") != "technical_review_approved":
|
||
raise ValueError("technical_review_required")
|
||
|
||
event_type = {
|
||
"accept": "accepted",
|
||
"request_changes": "changes_requested",
|
||
"reject": "acceptance_rejected",
|
||
"request_technical_review": "technical_review_requested",
|
||
"approve_technical_review": "technical_review_approved",
|
||
}[body.decision]
|
||
workflow_state = {
|
||
"accept": "accepted",
|
||
"request_changes": "changes_requested",
|
||
"reject": "failed",
|
||
"request_technical_review": "pending_technical_review",
|
||
"approve_technical_review": "technical_review_approved",
|
||
}[body.decision]
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=None,
|
||
event_type=event_type,
|
||
source="pmflow_os",
|
||
derived_task_status="ready_for_acceptance" if body.decision in {"accept", "approve_technical_review"} else workflow_state,
|
||
summary=self._execution_acceptance_summary(decision=body.decision, task=task, note=body.note),
|
||
payload={
|
||
"protocol_version": "execution_acceptance_decision_v1",
|
||
"workflow_state": workflow_state,
|
||
"decision": body.decision,
|
||
"note": body.note,
|
||
"evidence_event_ids": list(body.evidence_event_ids or []),
|
||
"quality_contract_id": quality_contract.get("qualityContractId"),
|
||
"boundary": "PMFlow records human acceptance/review decision only; it does not merge, deploy, or write Project Memory.",
|
||
},
|
||
occurred_at=now_cn_naive(),
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def record_execution_memory_writeback(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowExecutionMemoryWritebackRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
current_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
task_events = [
|
||
event
|
||
for event in list(current_summary.recentProgressEvents or [])
|
||
if str((event.get("task") or {}).get("id") or current_summary.execution.taskId or "") == str(task.id)
|
||
]
|
||
if any(self._event_type(event) == "execution_memory_writeback" for event in task_events):
|
||
raise ValueError("writeback_already_exists")
|
||
quality_contract = dict(current_summary.qualityContract or {})
|
||
acceptance = self._build_execution_acceptance(
|
||
events=task_events,
|
||
quality_contract=quality_contract,
|
||
artifact_completeness=current_summary.artifactCompleteness,
|
||
artifact_review=current_summary.artifactReview,
|
||
)
|
||
if acceptance.get("status") != "accepted" or not bool(acceptance.get("userAccepted")):
|
||
raise ValueError("execution_acceptance_required")
|
||
|
||
execution_evidence_events = [event for event in task_events if self._is_execution_result_evidence_event(event)]
|
||
if not execution_evidence_events:
|
||
raise ValueError("execution_evidence_required")
|
||
selected_event_ids = {str(item).strip() for item in list(body.progress_event_ids or []) if str(item).strip()}
|
||
available_event_ids = {str(event.get("id")) for event in execution_evidence_events if event.get("id") is not None}
|
||
if selected_event_ids and not selected_event_ids.issubset(available_event_ids):
|
||
raise ValueError("invalid_progress_event_ids")
|
||
evidence_event_ids = sorted(selected_event_ids or available_event_ids)
|
||
failed_events = self._unresolved_failed_evidence_events(
|
||
execution_evidence_events,
|
||
artifact_completeness=current_summary.artifactCompleteness,
|
||
artifact_review=current_summary.artifactReview,
|
||
)
|
||
if body.result == "completed" and failed_events:
|
||
raise ValueError("unresolved_blocker_exists")
|
||
writeback_proof = self._execution_evidence_proof(
|
||
events=execution_evidence_events,
|
||
artifact_completeness=current_summary.artifactCompleteness,
|
||
artifact_review=current_summary.artifactReview,
|
||
evidence_event_ids=evidence_event_ids,
|
||
)
|
||
|
||
now = now_cn_naive()
|
||
next_task_status = {
|
||
"completed": "done",
|
||
"blocked": "blocked",
|
||
"changes_requested": "blocked",
|
||
}.get(body.result, "done")
|
||
task.status = next_task_status
|
||
if next_task_status == "done":
|
||
task.completed_at = now
|
||
task.manual_overrides = self._execution_memory_writeback_task_payload(
|
||
task=task,
|
||
existing=task.manual_overrides,
|
||
result=body.result,
|
||
evidence_event_ids=evidence_event_ids,
|
||
delivery_artifacts=dict(current_summary.deliveryArtifacts or {}),
|
||
artifact_completeness=dict(current_summary.artifactCompleteness or {}),
|
||
artifact_review=dict(current_summary.artifactReview or {}),
|
||
release_gate=dict(current_summary.releaseGate or {}),
|
||
writeback_proof=writeback_proof,
|
||
note=body.note,
|
||
current_user_id=current_user_id,
|
||
occurred_at=now,
|
||
)
|
||
flag_modified(task, "manual_overrides")
|
||
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=self._latest_repo_binding_id(execution_evidence_events),
|
||
event_type="execution_memory_writeback",
|
||
source="pmflow_os",
|
||
derived_task_status=next_task_status,
|
||
summary=self._execution_memory_writeback_summary(task=task, result=body.result, note=body.note),
|
||
payload={
|
||
"protocol_version": "execution_memory_writeback_v1",
|
||
"workflow_state": "project_memory_written" if body.result == "completed" else body.result,
|
||
"result": body.result,
|
||
"note": body.note,
|
||
"task_id": str(task.id),
|
||
"task_status": next_task_status,
|
||
"evidence_event_ids": evidence_event_ids,
|
||
"acceptance": acceptance,
|
||
"delivery_artifacts": current_summary.deliveryArtifacts,
|
||
"artifact_completeness": current_summary.artifactCompleteness,
|
||
"artifact_review": current_summary.artifactReview,
|
||
"release_gate_status": (current_summary.releaseGate or {}).get("status"),
|
||
"release_readiness_proof": (current_summary.releaseGate or {}).get("releaseReadinessProof") or {},
|
||
"writeback_proof": writeback_proof,
|
||
"quality_contract_id": quality_contract.get("qualityContractId"),
|
||
"boundary": "PMFlow records accepted execution facts into Project Memory; it does not merge, deploy, publish, or execute commands.",
|
||
},
|
||
occurred_at=now,
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
execution_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
truth_state = await self.get_truth_state(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"writeback": self._memory_writeback_from_execution_event(event=event, task=task, user=None),
|
||
"executionSummary": execution_summary.model_dump(mode="json"),
|
||
"projectTruthState": truth_state.model_dump(mode="json"),
|
||
}
|
||
|
||
async def create_release_validation_run(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowReleaseValidationRunCreateRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
await self._load_project(project_id=project_id)
|
||
summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
release_gate = dict(summary.releaseGate or {})
|
||
evidence = list(release_gate.get("evidence") or [])
|
||
if str(release_gate.get("status") or "empty") == "empty" or not evidence:
|
||
raise ValueError("release_validation_evidence_empty")
|
||
|
||
status = self._release_validation_status(gate_status=str(release_gate.get("status") or ""), decision=body.decision)
|
||
run = ReleaseValidationRun(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project_id),
|
||
status=status,
|
||
score=self._release_validation_score(status),
|
||
key_metrics=self._release_validation_metrics(release_gate),
|
||
risk_items=self._release_validation_risks(release_gate, decision=body.decision),
|
||
rollback_plan=self._release_validation_rollback_plan(release_gate),
|
||
validation_summary={
|
||
"source": "pmflow_os_release_gate",
|
||
"projectId": str(project_id),
|
||
"status": status,
|
||
"releaseGateStatus": release_gate.get("status"),
|
||
"humanDecision": body.decision,
|
||
"note": body.note,
|
||
"createdBy": None if current_user_id is None else str(current_user_id),
|
||
"createdAt": now_cn_naive().isoformat(),
|
||
"deploymentExecuted": False,
|
||
"evidence": evidence,
|
||
"blockingReasons": list(release_gate.get("blockingReasons") or []),
|
||
"nextActions": list(release_gate.get("nextActions") or []),
|
||
"checks": list(release_gate.get("checks") or []),
|
||
},
|
||
)
|
||
self.db.add(run)
|
||
await self.db.commit()
|
||
await self.db.refresh(run)
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"releaseValidationRun": self._serialize_release_validation_run(run),
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": [] if status != "blocked" else list(release_gate.get("blockingReasons") or []),
|
||
}
|
||
|
||
async def create_release_feedback_ingest(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowReleaseFeedbackIngestRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
await self._load_project(project_id=project_id)
|
||
charter_id = self._optional_int(body.charter_id, error_code="charter_not_found")
|
||
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
|
||
try:
|
||
payload = await control.ingest_feedback(
|
||
project_id=int(project_id),
|
||
actor_user_id=current_user_id,
|
||
source_type=body.source_type,
|
||
title=body.title,
|
||
content=body.content,
|
||
charter_id=charter_id,
|
||
metrics=dict(body.metrics or {}),
|
||
samples=list(body.samples or []),
|
||
)
|
||
except ValueError as exc:
|
||
if str(exc) == "反馈内容不能为空":
|
||
raise ValueError("feedback_content_empty") from exc
|
||
raise
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
release_signals = dict(updated_summary.releaseSignals or {})
|
||
raw_ingest_id = str((payload.get("ingest") or {}).get("id") or "")
|
||
projected_ingest = next(
|
||
(item for item in list(release_signals.get("feedback") or []) if str(item.get("id")) == raw_ingest_id),
|
||
None,
|
||
)
|
||
projected_recommendation = next(
|
||
(
|
||
item
|
||
for item in list(release_signals.get("recommendations") or [])
|
||
if str(item.get("feedbackIngestId") or "") == raw_ingest_id
|
||
),
|
||
None,
|
||
)
|
||
return {
|
||
"ingest": projected_ingest or payload.get("ingest"),
|
||
"recommendation": projected_recommendation or payload.get("recommendation"),
|
||
"releaseSignals": release_signals,
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": list(release_signals.get("warnings") or []),
|
||
}
|
||
|
||
async def create_iteration_recommendation_change_set_draft(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
recommendation_id: int,
|
||
body: PMFlowIterationRecommendationChangeSetDraftRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
recommendation = await self._load_iteration_recommendation(
|
||
project_id=project_id,
|
||
recommendation_id=recommendation_id,
|
||
)
|
||
summary_payload = dict(recommendation.recommendation_summary or {})
|
||
existing_change_set_id = self._optional_int(
|
||
summary_payload.get("changeSetId"),
|
||
error_code="iteration_recommendation_not_found",
|
||
)
|
||
if existing_change_set_id is not None:
|
||
existing = await self._load_recommendation_change_set(
|
||
project_id=project_id,
|
||
change_set_id=existing_change_set_id,
|
||
)
|
||
if existing is not None:
|
||
detail = await PMFlowChangeReviewService(self.db, tenant_id=self.tenant_id).get_change_set_detail(
|
||
project_id=project_id,
|
||
change_set_id=int(existing.id),
|
||
)
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"changeSetDetail": detail,
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": ["iteration_recommendation_changeset_reused"],
|
||
}
|
||
|
||
feedback = await self._load_feedback_ingest(
|
||
project_id=project_id,
|
||
feedback_ingest_id=recommendation.feedback_ingest_id,
|
||
)
|
||
thread = await self._resolve_or_create_iteration_thread(project=project)
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=self.tenant_id,
|
||
thread_id=int(thread.id),
|
||
project_id=int(project_id),
|
||
role="assistant",
|
||
content=self._iteration_recommendation_message(
|
||
recommendation=recommendation,
|
||
feedback=feedback,
|
||
note=body.note,
|
||
),
|
||
source="pmflow_os",
|
||
attachments=[],
|
||
message_metadata={
|
||
"source": "iteration_recommendation",
|
||
"recommendation_id": str(recommendation.id),
|
||
"feedback_ingest_id": None if feedback is None else str(feedback.id),
|
||
"created_by": None if current_user_id is None else int(current_user_id),
|
||
"auto_commit": False,
|
||
"note": body.note,
|
||
},
|
||
status="sent",
|
||
)
|
||
self.db.add(message)
|
||
await self.db.flush()
|
||
|
||
items = self._iteration_recommendation_change_items(
|
||
recommendation=recommendation,
|
||
feedback=feedback,
|
||
project_id=project_id,
|
||
)
|
||
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=self._iteration_change_set_summary(recommendation=recommendation, feedback=feedback),
|
||
headline=self._iteration_change_set_headline(recommendation=recommendation, feedback=feedback),
|
||
extraction_scope="iteration_recommendation",
|
||
routing_meta={
|
||
"source": "release_feedback_iteration_loop",
|
||
"recommendationId": str(recommendation.id),
|
||
"feedbackIngestId": None if feedback is None else str(feedback.id),
|
||
"releaseFeedbackLoop": True,
|
||
"autoCommit": False,
|
||
"note": body.note,
|
||
},
|
||
llm_call_id=None,
|
||
created_by=int(current_user_id or project.owner_id),
|
||
)
|
||
self.db.add(change_set)
|
||
await self.db.flush()
|
||
|
||
for item in items:
|
||
item.change_set_id = int(change_set.id)
|
||
self.db.add(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="iteration_recommendation",
|
||
source_id=str(recommendation.id),
|
||
title="发布后反馈迭代建议",
|
||
excerpt=self._iteration_recommendation_excerpt(recommendation=recommendation, feedback=feedback),
|
||
display_label="Release Feedback",
|
||
evidence_metadata={
|
||
"recommendation_id": str(recommendation.id),
|
||
"feedback_ingest_id": None if feedback is None else str(feedback.id),
|
||
"feedback_title": None if feedback is None else feedback.title,
|
||
"priority": recommendation.priority,
|
||
"change_set_created": True,
|
||
},
|
||
)
|
||
self.db.add(evidence)
|
||
|
||
summary_payload.update(
|
||
{
|
||
"changeSetId": str(change_set.id),
|
||
"changeSetCreated": True,
|
||
"changeSetCreatedAt": now_cn_naive().isoformat(),
|
||
"changeSetCreatedBy": None if current_user_id is None else str(current_user_id),
|
||
}
|
||
)
|
||
recommendation.recommendation_summary = summary_payload
|
||
recommendation.status = "changeset_drafted"
|
||
flag_modified(recommendation, "recommendation_summary")
|
||
await self.db.commit()
|
||
await self.db.refresh(change_set)
|
||
|
||
detail = await PMFlowChangeReviewService(self.db, tenant_id=self.tenant_id).get_change_set_detail(
|
||
project_id=project_id,
|
||
change_set_id=int(change_set.id),
|
||
)
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"changeSetDetail": detail,
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": [],
|
||
}
|
||
|
||
async def create_iteration_recommendation_group_change_set_draft(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
group_key: str,
|
||
body: PMFlowIterationRecommendationGroupChangeSetDraftRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
project = await self._load_project(project_id=project_id)
|
||
rows = await self._load_iteration_recommendations(project_id=project_id)
|
||
feedback_by_id = await self._load_feedback_ingests_for_recommendations(project_id=project_id, recommendations=rows)
|
||
quality_by_id = {
|
||
int(row.id): self._recommendation_quality_profile(
|
||
recommendation=row,
|
||
feedback=feedback_by_id.get(int(row.feedback_ingest_id)) if row.feedback_ingest_id is not None else None,
|
||
)
|
||
for row in rows
|
||
}
|
||
requested_ids = self._parse_recommendation_ids(body.recommendation_ids)
|
||
normalized_group_key = self._normalize_group_key(group_key)
|
||
selected = [
|
||
row
|
||
for row in rows
|
||
if (requested_ids and int(row.id) in requested_ids) or (not requested_ids and quality_by_id[int(row.id)]["groupKey"] == normalized_group_key)
|
||
]
|
||
if requested_ids and len({int(row.id) for row in selected}) != len(requested_ids):
|
||
raise ValueError("iteration_recommendation_not_found")
|
||
if not selected:
|
||
raise ValueError("iteration_recommendation_group_not_found")
|
||
selected_quality = self._finalize_recommendation_quality(
|
||
recommendations=selected,
|
||
quality_by_id={int(row.id): quality_by_id[int(row.id)] for row in selected},
|
||
)
|
||
canonical_group_key = str(next(iter(selected_quality.values())).get("groupKey") or normalized_group_key)
|
||
existing_change_set = await self._find_existing_recommendation_group_change_set(
|
||
project_id=project_id,
|
||
recommendations=selected,
|
||
)
|
||
if existing_change_set is not None:
|
||
await self._mark_recommendations_for_group_change_set(
|
||
recommendations=selected,
|
||
change_set_id=int(existing_change_set.id),
|
||
group_key=canonical_group_key,
|
||
current_user_id=current_user_id,
|
||
reused=True,
|
||
)
|
||
await self.db.commit()
|
||
detail = await PMFlowChangeReviewService(self.db, tenant_id=self.tenant_id).get_change_set_detail(
|
||
project_id=project_id,
|
||
change_set_id=int(existing_change_set.id),
|
||
)
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"changeSetDetail": detail,
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": ["iteration_recommendation_group_changeset_reused"],
|
||
}
|
||
|
||
thread = await self._resolve_or_create_iteration_thread(project=project)
|
||
message = PMFlowThreadMessage(
|
||
tenant_id=self.tenant_id,
|
||
thread_id=int(thread.id),
|
||
project_id=int(project_id),
|
||
role="assistant",
|
||
content=self._iteration_group_message(
|
||
recommendations=selected,
|
||
feedback_by_id=feedback_by_id,
|
||
quality_by_id=selected_quality,
|
||
note=body.note,
|
||
),
|
||
source="pmflow_os",
|
||
attachments=[],
|
||
message_metadata={
|
||
"source": "iteration_recommendation_group",
|
||
"group_key": canonical_group_key,
|
||
"recommendation_ids": [str(row.id) for row in selected],
|
||
"created_by": None if current_user_id is None else int(current_user_id),
|
||
"auto_commit": False,
|
||
"note": body.note,
|
||
},
|
||
status="sent",
|
||
)
|
||
self.db.add(message)
|
||
await self.db.flush()
|
||
|
||
items = self._iteration_recommendation_group_change_items(
|
||
recommendations=selected,
|
||
feedback_by_id=feedback_by_id,
|
||
quality_by_id=selected_quality,
|
||
project_id=project_id,
|
||
)
|
||
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=self._iteration_group_change_set_summary(recommendations=selected, quality_by_id=selected_quality),
|
||
headline=self._iteration_group_change_set_headline(recommendations=selected, quality_by_id=selected_quality),
|
||
extraction_scope="iteration_recommendation_group",
|
||
routing_meta={
|
||
"source": "release_feedback_iteration_group",
|
||
"groupKey": canonical_group_key,
|
||
"recommendationIds": [str(row.id) for row in selected],
|
||
"releaseFeedbackLoop": True,
|
||
"autoCommit": False,
|
||
"note": body.note,
|
||
},
|
||
llm_call_id=None,
|
||
created_by=int(current_user_id or project.owner_id),
|
||
)
|
||
self.db.add(change_set)
|
||
await self.db.flush()
|
||
|
||
for item in items:
|
||
item.change_set_id = int(change_set.id)
|
||
self.db.add(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="iteration_recommendation_group",
|
||
source_id=canonical_group_key,
|
||
title="发布后反馈迭代建议组",
|
||
excerpt=self._iteration_group_excerpt(recommendations=selected, feedback_by_id=feedback_by_id),
|
||
display_label="Release Feedback Group",
|
||
evidence_metadata={
|
||
"groupKey": canonical_group_key,
|
||
"recommendationIds": [str(row.id) for row in selected],
|
||
"feedbackIngestIds": [
|
||
str(row.feedback_ingest_id) for row in selected if row.feedback_ingest_id is not None
|
||
],
|
||
"quality": next(iter(selected_quality.values()), {}),
|
||
"change_set_created": True,
|
||
},
|
||
)
|
||
self.db.add(evidence)
|
||
|
||
await self._mark_recommendations_for_group_change_set(
|
||
recommendations=selected,
|
||
change_set_id=int(change_set.id),
|
||
group_key=canonical_group_key,
|
||
current_user_id=current_user_id,
|
||
reused=False,
|
||
)
|
||
await self.db.commit()
|
||
await self.db.refresh(change_set)
|
||
|
||
detail = await PMFlowChangeReviewService(self.db, tenant_id=self.tenant_id).get_change_set_detail(
|
||
project_id=project_id,
|
||
change_set_id=int(change_set.id),
|
||
)
|
||
updated_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
return {
|
||
"changeSetDetail": detail,
|
||
"executionSummary": updated_summary.model_dump(mode="json"),
|
||
"warnings": [],
|
||
}
|
||
|
||
async def consume_execution_handoff(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
prompt_package_id: int,
|
||
body: PMFlowExecutionHandoffConsumeRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
await self._load_project(project_id=project_id)
|
||
prompt_package = await self._load_execution_prompt_package(
|
||
project_id=project_id,
|
||
prompt_package_id=prompt_package_id,
|
||
)
|
||
if prompt_package.task_id is None:
|
||
raise ValueError("task_not_found")
|
||
task = await self._load_project_task(project_id=project_id, task_id=str(prompt_package.task_id))
|
||
payload = dict(self._payload(prompt_package.package_payload))
|
||
latest_handoff = dict(self._payload(payload.get("latest_handoff")))
|
||
runtime_target = body.runtime_target or str(latest_handoff.get("runtime_target") or payload.get("runtime_target") or "manual")
|
||
repo_binding_id = str(latest_handoff.get("repo_binding_id") or "").strip() or None
|
||
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=repo_binding_id)
|
||
consumer_label = (body.consumer_label or runtime_target or "runtime").strip()
|
||
consumed_at = now_cn_naive().isoformat()
|
||
consumed_payload = self._handoff_consumed_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=runtime_target,
|
||
note=body.note,
|
||
prompt_package_id=int(prompt_package.id),
|
||
consumer_label=consumer_label,
|
||
consumed_at=consumed_at,
|
||
)
|
||
latest_handoff.update(consumed_payload)
|
||
payload["latest_handoff"] = latest_handoff
|
||
payload.setdefault("handoff_consumptions", [])
|
||
if isinstance(payload["handoff_consumptions"], list):
|
||
payload["handoff_consumptions"] = [consumed_payload, *payload["handoff_consumptions"][:9]]
|
||
prompt_package.package_payload = payload
|
||
flag_modified(prompt_package, "package_payload")
|
||
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
event_type="handoff_consumed",
|
||
source="pmflow_os",
|
||
derived_task_status="coding",
|
||
summary=self._handoff_consumed_summary(task=task, runtime_target=runtime_target, consumer_label=consumer_label),
|
||
payload=consumed_payload,
|
||
occurred_at=now_cn_naive(),
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def create_execution_runtime_credential(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
prompt_package_id: int,
|
||
body: PMFlowExecutionRuntimeCredentialCreateRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> PMFlowExecutionRuntimeCredentialResponse:
|
||
await self._load_project(project_id=project_id)
|
||
prompt_package = await self._load_execution_prompt_package(
|
||
project_id=project_id,
|
||
prompt_package_id=int(prompt_package_id),
|
||
)
|
||
if prompt_package.task_id is None:
|
||
raise ValueError("task_not_found")
|
||
task = await self._load_project_task(project_id=project_id, task_id=str(prompt_package.task_id))
|
||
package_payload = self._payload(prompt_package.package_payload)
|
||
latest_handoff = self._payload(package_payload.get("latest_handoff"))
|
||
runtime_target = body.runtime_target or str(
|
||
latest_handoff.get("runtime_target") or package_payload.get("runtime_target") or "local_bridge"
|
||
)
|
||
if runtime_target not in {"local_bridge", "codex", "github_agent", "manual"}:
|
||
raise ValueError("runtime_target_invalid")
|
||
repo_binding_id = str(latest_handoff.get("repo_binding_id") or package_payload.get("repo_binding_id") or "").strip() or None
|
||
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=repo_binding_id)
|
||
allowed_sources = self._runtime_allowed_sources(body.allowed_sources, runtime_target=runtime_target)
|
||
expires_at = now_cn_naive() + timedelta(seconds=int(body.expires_in_seconds))
|
||
key_id = f"erc_{secrets.token_urlsafe(18)}"[:80]
|
||
secret = self._runtime_secret_for_key(key_id)
|
||
credential = PMFlowExecutionRuntimeCredential(
|
||
tenant_id=self.tenant_id,
|
||
key_id=key_id,
|
||
secret_hash=self._runtime_secret_hash(secret),
|
||
project_id=int(project_id),
|
||
task_id=int(task.id),
|
||
prompt_package_id=int(prompt_package.id),
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
runtime_target=runtime_target,
|
||
allowed_sources=allowed_sources,
|
||
status="active",
|
||
expires_at=expires_at,
|
||
created_by=int(current_user_id) if current_user_id is not None else None,
|
||
use_count=0,
|
||
)
|
||
self.db.add(credential)
|
||
await self.db.commit()
|
||
return self._runtime_credential_response(
|
||
credential=credential,
|
||
secret=secret,
|
||
task=task,
|
||
repo=repo,
|
||
note=body.note,
|
||
)
|
||
|
||
async def record_runtime_execution_result(
|
||
self,
|
||
*,
|
||
body: PMFlowRuntimeExecutionResultRequest,
|
||
key_id: str,
|
||
timestamp: str,
|
||
signature: str,
|
||
delivery_id: str,
|
||
raw_body: bytes,
|
||
) -> ExecutionSummaryProjection:
|
||
credential = await self._load_runtime_credential(key_id=key_id)
|
||
self.tenant_id = int(credential.tenant_id)
|
||
self._validate_runtime_credential(
|
||
credential=credential,
|
||
body=body,
|
||
timestamp=timestamp,
|
||
signature=signature,
|
||
raw_body=raw_body,
|
||
)
|
||
body.delivery_id = body.delivery_id or delivery_id
|
||
if credential.repo_binding_id is not None:
|
||
if body.repo_binding_id and str(body.repo_binding_id) != str(credential.repo_binding_id):
|
||
raise ValueError("runtime_handoff_mismatch")
|
||
body.repo_binding_id = str(credential.repo_binding_id)
|
||
credential.last_used_at = now_cn_naive()
|
||
credential.use_count = int(credential.use_count or 0) + 1
|
||
summary = await self.record_execution_progress_event(
|
||
project_id=int(credential.project_id),
|
||
body=body,
|
||
current_user_id=None,
|
||
)
|
||
return summary
|
||
|
||
async def record_runtime_execution_package_pull(
|
||
self,
|
||
*,
|
||
body: PMFlowRuntimeExecutionPackageRequest,
|
||
key_id: str,
|
||
timestamp: str,
|
||
signature: str,
|
||
delivery_id: str,
|
||
raw_body: bytes,
|
||
) -> PMFlowRuntimeExecutionPackageResponse:
|
||
credential = await self._load_runtime_credential(key_id=key_id)
|
||
self.tenant_id = int(credential.tenant_id)
|
||
self._validate_runtime_credential(
|
||
credential=credential,
|
||
body=body,
|
||
timestamp=timestamp,
|
||
signature=signature,
|
||
raw_body=raw_body,
|
||
)
|
||
body.delivery_id = body.delivery_id or delivery_id
|
||
project = await self._load_project(project_id=int(credential.project_id))
|
||
prompt_package = await self._load_execution_prompt_package(
|
||
project_id=int(credential.project_id),
|
||
prompt_package_id=int(credential.prompt_package_id),
|
||
)
|
||
task = await self._load_project_task(project_id=int(credential.project_id), task_id=str(credential.task_id))
|
||
repo = await self._load_project_repo_binding(
|
||
project_id=int(credential.project_id),
|
||
repo_binding_id=None if credential.repo_binding_id is None else str(credential.repo_binding_id),
|
||
)
|
||
truth_nodes = await self._load_truth_nodes(project_id=int(credential.project_id))
|
||
memory = await self._project_memory(project=project, truth_nodes=truth_nodes[:50], task_ids=[int(task.id)])
|
||
pull_event = await self._record_execution_package_pull_event(
|
||
credential=credential,
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
body=body,
|
||
)
|
||
credential.last_used_at = now_cn_naive()
|
||
credential.use_count = int(credential.use_count or 0) + 1
|
||
await self.db.commit()
|
||
return self._runtime_execution_package_response(
|
||
credential=credential,
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
prompt_package=prompt_package,
|
||
memory=memory,
|
||
pull_event=pull_event,
|
||
)
|
||
|
||
async def record_execution_progress_event(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowExecutionProgressEventRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
repo = await self._load_project_repo_binding(project_id=project_id, repo_binding_id=body.repo_binding_id)
|
||
payload = self._progress_event_payload(body=body, repo=repo)
|
||
summary = self._progress_event_summary(body=body, task=task, repo=repo)
|
||
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
|
||
await control.create_or_apply_progress_event(
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
event_type=body.event_type,
|
||
source=body.source,
|
||
payload=payload,
|
||
summary=summary,
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
delivery_id=body.delivery_id,
|
||
event_source_id=body.event_source_id,
|
||
occurred_at=body.occurred_at,
|
||
)
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def record_execution_artifact_review_action(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
body: PMFlowArtifactReviewActionRequest,
|
||
current_user_id: Optional[int] = None,
|
||
) -> ExecutionSummaryProjection:
|
||
await self._load_project(project_id=project_id)
|
||
task = await self._load_project_task(project_id=project_id, task_id=body.task_id)
|
||
current_summary = await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
artifact_review = dict(current_summary.artifactReview or {})
|
||
items = [item for item in list(artifact_review.get("items") or []) if isinstance(item, dict)]
|
||
available_ids = {str(item.get("id") or "") for item in items}
|
||
selected_ids = [str(item).strip() for item in list(body.review_item_ids or []) if str(item).strip()]
|
||
if selected_ids and not set(selected_ids).issubset(available_ids):
|
||
raise ValueError("invalid_artifact_review_item_ids")
|
||
selected_items = [item for item in items if not selected_ids or str(item.get("id") or "") in set(selected_ids)]
|
||
event_type = {
|
||
"request_fix": "artifact_review_requested",
|
||
"record_fix": "artifact_fix_recorded",
|
||
"mark_recheck_ready": "artifact_recheck_ready",
|
||
}.get(body.action, "artifact_review_requested")
|
||
payload = {
|
||
"protocol_version": "artifact_review_action_v1",
|
||
"workflow_state": event_type,
|
||
"action": body.action,
|
||
"task_id": str(task.id),
|
||
"review_item_ids": selected_ids,
|
||
"review_items": selected_items[:20],
|
||
"artifact_review_status": artifact_review.get("status"),
|
||
"artifact_completeness": current_summary.artifactCompleteness,
|
||
"note": body.note,
|
||
"boundary": "PMFlow records artifact review and rework intent only; command execution, branch operations, deployment, and Project Memory writes stay outside this action.",
|
||
}
|
||
event = ProgressEvent(
|
||
tenant_id=self.tenant_id,
|
||
task_id=int(task.id),
|
||
project_id=int(project_id),
|
||
user_id=int(current_user_id) if current_user_id is not None else None,
|
||
repo_binding_id=self._latest_repo_binding_id(list(current_summary.recentProgressEvents or [])),
|
||
event_type=event_type,
|
||
source="pmflow_os",
|
||
derived_task_status="blocked" if body.action == "request_fix" else "testing",
|
||
summary=self._artifact_review_action_summary(action=body.action, items=selected_items, note=body.note),
|
||
payload=payload,
|
||
occurred_at=now_cn_naive(),
|
||
)
|
||
self.db.add(event)
|
||
await self.db.commit()
|
||
return await self.get_execution_summary(project_id=project_id, current_user_id=current_user_id)
|
||
|
||
async def _load_project(self, *, project_id: int) -> Project:
|
||
result = await self.db.execute(
|
||
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == self.tenant_id))
|
||
)
|
||
project = result.scalar_one_or_none()
|
||
if project is None:
|
||
raise ValueError("project_not_found")
|
||
return project
|
||
|
||
async def _load_truth_nodes(self, *, project_id: int) -> list[TruthNode]:
|
||
result = await self.db.execute(
|
||
select(TruthNode)
|
||
.where(and_(TruthNode.tenant_id == self.tenant_id, TruthNode.project_id == int(project_id)))
|
||
.order_by(TruthNode.updated_at.desc(), TruthNode.created_at.desc(), TruthNode.id.desc())
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def _load_tasks(self, *, project_id: int) -> list[tuple[Task, Optional[User]]]:
|
||
result = await self.db.execute(
|
||
select(Task, User)
|
||
.outerjoin(User, User.id == Task.assignee_id)
|
||
.where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))
|
||
.order_by(Task.updated_at.desc(), Task.created_at.desc(), Task.id.desc())
|
||
)
|
||
return [(task, user) for task, user in result.all()]
|
||
|
||
async def _load_project_members(self, *, project: Project) -> list[User]:
|
||
result = await self.db.execute(
|
||
select(User)
|
||
.outerjoin(UserProject, and_(UserProject.user_id == User.id, UserProject.project_id == int(project.id)))
|
||
.where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
or_(UserProject.project_id == int(project.id), User.id == project.owner_id),
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
.order_by(User.id.asc())
|
||
)
|
||
members: dict[int, User] = {}
|
||
for user in result.scalars().all():
|
||
members[int(user.id)] = user
|
||
return list(members.values())
|
||
|
||
async def _load_project_task(self, *, project_id: int, task_id: str) -> Task:
|
||
try:
|
||
normalized_task_id = int(str(task_id).strip())
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("task_not_found") from exc
|
||
result = await self.db.execute(
|
||
select(Task).where(
|
||
and_(
|
||
Task.tenant_id == self.tenant_id,
|
||
Task.project_id == int(project_id),
|
||
Task.id == normalized_task_id,
|
||
)
|
||
)
|
||
)
|
||
task = result.scalar_one_or_none()
|
||
if task is None:
|
||
raise ValueError("task_not_found")
|
||
return task
|
||
|
||
async def _load_project_repo_binding(self, *, project_id: int, repo_binding_id: Optional[str]) -> Optional[RepoBinding]:
|
||
if repo_binding_id is None or str(repo_binding_id).strip() == "":
|
||
return None
|
||
try:
|
||
normalized_repo_id = int(str(repo_binding_id).strip())
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("repo_binding_not_found") from exc
|
||
result = await self.db.execute(
|
||
select(RepoBinding).where(
|
||
and_(
|
||
RepoBinding.tenant_id == self.tenant_id,
|
||
RepoBinding.project_id == int(project_id),
|
||
RepoBinding.id == normalized_repo_id,
|
||
RepoBinding.status == "active",
|
||
)
|
||
)
|
||
)
|
||
repo = result.scalar_one_or_none()
|
||
if repo is None:
|
||
raise ValueError("repo_binding_not_found")
|
||
return repo
|
||
|
||
async def _load_latest_prompt_package(self, *, project_id: int) -> Optional[PromptPackage]:
|
||
result = await self.db.execute(
|
||
select(PromptPackage)
|
||
.where(and_(PromptPackage.tenant_id == self.tenant_id, PromptPackage.project_id == int(project_id)))
|
||
.order_by(PromptPackage.updated_at.desc(), PromptPackage.created_at.desc(), PromptPackage.id.desc())
|
||
.limit(1)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _load_latest_release_validation_run(self, *, project_id: int) -> Optional[ReleaseValidationRun]:
|
||
result = await self.db.execute(
|
||
select(ReleaseValidationRun)
|
||
.where(and_(ReleaseValidationRun.tenant_id == self.tenant_id, ReleaseValidationRun.project_id == int(project_id)))
|
||
.order_by(ReleaseValidationRun.updated_at.desc(), ReleaseValidationRun.created_at.desc(), ReleaseValidationRun.id.desc())
|
||
.limit(1)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _load_iteration_recommendation(self, *, project_id: int, recommendation_id: int) -> IterationRecommendation:
|
||
result = await self.db.execute(
|
||
select(IterationRecommendation).where(
|
||
and_(
|
||
IterationRecommendation.tenant_id == self.tenant_id,
|
||
IterationRecommendation.project_id == int(project_id),
|
||
IterationRecommendation.id == int(recommendation_id),
|
||
)
|
||
)
|
||
)
|
||
row = result.scalar_one_or_none()
|
||
if row is None:
|
||
raise ValueError("iteration_recommendation_not_found")
|
||
return row
|
||
|
||
async def _load_iteration_recommendations(self, *, project_id: int) -> list[IterationRecommendation]:
|
||
result = await self.db.execute(
|
||
select(IterationRecommendation)
|
||
.where(and_(IterationRecommendation.tenant_id == self.tenant_id, IterationRecommendation.project_id == int(project_id)))
|
||
.order_by(IterationRecommendation.updated_at.desc(), IterationRecommendation.created_at.desc(), IterationRecommendation.id.desc())
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def _load_recommendation_change_set(self, *, project_id: int, change_set_id: int) -> Optional[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),
|
||
)
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _find_existing_recommendation_group_change_set(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
recommendations: list[IterationRecommendation],
|
||
) -> Optional[PMFlowChangeSet]:
|
||
candidate_ids: list[int] = []
|
||
for recommendation in recommendations:
|
||
summary = dict(recommendation.recommendation_summary or {})
|
||
change_set_id = self._optional_int(summary.get("changeSetId"), error_code="iteration_recommendation_not_found")
|
||
if change_set_id is not None:
|
||
candidate_ids.append(change_set_id)
|
||
for change_set_id in candidate_ids:
|
||
row = await self._load_recommendation_change_set(project_id=project_id, change_set_id=change_set_id)
|
||
if row is not None:
|
||
return row
|
||
return None
|
||
|
||
async def _load_feedback_ingest(self, *, project_id: int, feedback_ingest_id: Optional[int]) -> Optional[FeedbackIngest]:
|
||
if feedback_ingest_id is None:
|
||
return None
|
||
result = await self.db.execute(
|
||
select(FeedbackIngest).where(
|
||
and_(
|
||
FeedbackIngest.tenant_id == self.tenant_id,
|
||
FeedbackIngest.project_id == int(project_id),
|
||
FeedbackIngest.id == int(feedback_ingest_id),
|
||
)
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _load_feedback_ingests_for_recommendations(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
recommendations: list[IterationRecommendation],
|
||
) -> dict[int, FeedbackIngest]:
|
||
ids = sorted({int(row.feedback_ingest_id) for row in recommendations if row.feedback_ingest_id is not None})
|
||
if not ids:
|
||
return {}
|
||
result = await self.db.execute(
|
||
select(FeedbackIngest).where(
|
||
and_(
|
||
FeedbackIngest.tenant_id == self.tenant_id,
|
||
FeedbackIngest.project_id == int(project_id),
|
||
FeedbackIngest.id.in_(ids),
|
||
)
|
||
)
|
||
)
|
||
return {int(row.id): row for row in result.scalars().all()}
|
||
|
||
async def _resolve_or_create_iteration_thread(self, *, project: Project) -> PMFlowThreadModel:
|
||
result = await self.db.execute(
|
||
select(PMFlowThreadModel)
|
||
.where(and_(PMFlowThreadModel.tenant_id == self.tenant_id, PMFlowThreadModel.project_id == int(project.id)))
|
||
.order_by(PMFlowThreadModel.created_at.asc(), PMFlowThreadModel.id.asc())
|
||
.limit(1)
|
||
)
|
||
row = result.scalar_one_or_none()
|
||
if row is not None:
|
||
return row
|
||
row = PMFlowThreadModel(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project.id),
|
||
title=f"{project.title or project.name} 项目线程",
|
||
objective=self._project_goal(project=project, truth_nodes=[]),
|
||
status="active",
|
||
source="iteration_loop",
|
||
)
|
||
self.db.add(row)
|
||
await self.db.flush()
|
||
return row
|
||
|
||
async def _build_release_signals(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
release_gate: dict[str, Any],
|
||
latest_release_validation: Optional[ReleaseValidationRun],
|
||
) -> dict[str, Any]:
|
||
feedback_result = await self.db.execute(
|
||
select(FeedbackIngest)
|
||
.where(and_(FeedbackIngest.tenant_id == self.tenant_id, FeedbackIngest.project_id == int(project_id)))
|
||
.order_by(FeedbackIngest.updated_at.desc(), FeedbackIngest.created_at.desc(), FeedbackIngest.id.desc())
|
||
.limit(10)
|
||
)
|
||
feedback_rows = list(feedback_result.scalars().all())
|
||
feedback_items = [self._serialize_feedback_ingest(item) for item in feedback_rows]
|
||
|
||
recommendation_result = await self.db.execute(
|
||
select(IterationRecommendation)
|
||
.where(and_(IterationRecommendation.tenant_id == self.tenant_id, IterationRecommendation.project_id == int(project_id)))
|
||
.order_by(IterationRecommendation.updated_at.desc(), IterationRecommendation.created_at.desc(), IterationRecommendation.id.desc())
|
||
.limit(10)
|
||
)
|
||
recommendation_rows = list(recommendation_result.scalars().all())
|
||
feedback_by_id_for_recommendations = await self._load_feedback_ingests_for_recommendations(
|
||
project_id=project_id,
|
||
recommendations=recommendation_rows,
|
||
)
|
||
base_quality_by_id = {
|
||
int(item.id): self._recommendation_quality_profile(
|
||
recommendation=item,
|
||
feedback=feedback_by_id_for_recommendations.get(int(item.feedback_ingest_id)) if item.feedback_ingest_id is not None else None,
|
||
)
|
||
for item in recommendation_rows
|
||
}
|
||
quality_by_id = self._finalize_recommendation_quality(
|
||
recommendations=recommendation_rows,
|
||
quality_by_id=base_quality_by_id,
|
||
)
|
||
recommendations = [
|
||
self._serialize_iteration_recommendation(item, quality=quality_by_id.get(int(item.id)))
|
||
for item in recommendation_rows
|
||
]
|
||
recommendation_groups = self._serialize_recommendation_groups(recommendations)
|
||
|
||
revenue_result = await self.db.execute(
|
||
select(RevenueEvent)
|
||
.where(and_(RevenueEvent.tenant_id == self.tenant_id, RevenueEvent.project_id == int(project_id)))
|
||
.order_by(RevenueEvent.occurred_at.desc(), RevenueEvent.created_at.desc(), RevenueEvent.id.desc())
|
||
.limit(10)
|
||
)
|
||
revenue_rows = list(revenue_result.scalars().all())
|
||
revenue_events = [self._serialize_revenue_event(item) for item in revenue_rows]
|
||
|
||
roi_result = await self.db.execute(
|
||
select(ROISnapshot)
|
||
.where(and_(ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id)))
|
||
.order_by(ROISnapshot.updated_at.desc(), ROISnapshot.created_at.desc(), ROISnapshot.id.desc())
|
||
.limit(10)
|
||
)
|
||
roi_rows = list(roi_result.scalars().all())
|
||
roi_snapshots = [self._serialize_roi_snapshot(item) for item in roi_rows]
|
||
|
||
compliance_result = await self.db.execute(
|
||
select(ComplianceCheckRun)
|
||
.where(and_(ComplianceCheckRun.tenant_id == self.tenant_id, ComplianceCheckRun.project_id == int(project_id)))
|
||
.order_by(ComplianceCheckRun.updated_at.desc(), ComplianceCheckRun.created_at.desc(), ComplianceCheckRun.id.desc())
|
||
.limit(10)
|
||
)
|
||
compliance_rows = list(compliance_result.scalars().all())
|
||
compliance_checks = [self._serialize_compliance_check(item) for item in compliance_rows]
|
||
|
||
warnings: list[str] = []
|
||
if not feedback_items:
|
||
warnings.append("release_feedback_empty: no post-release feedback has been ingested.")
|
||
if not revenue_events:
|
||
warnings.append("release_revenue_empty: no revenue event has been recorded.")
|
||
if not roi_snapshots:
|
||
warnings.append("release_roi_empty: no ROI snapshot has been recorded.")
|
||
if not compliance_checks:
|
||
warnings.append("release_compliance_empty: no compliance check has been recorded.")
|
||
if not recommendations:
|
||
warnings.append("iteration_recommendations_empty: no iteration recommendation has been generated.")
|
||
if any(item.get("riskLevel") == "high" for item in roi_snapshots):
|
||
warnings.append("release_roi_risk: latest ROI snapshot indicates negative or missing return.")
|
||
if any(item.get("riskLevel") == "high" for item in compliance_checks):
|
||
warnings.append("release_compliance_risk: compliance check is blocked or has open issues.")
|
||
|
||
serialized_times = [
|
||
str(item.get("updatedAt") or item.get("createdAt"))
|
||
for item in [*feedback_items, *recommendations, *revenue_events, *roi_snapshots, *compliance_checks]
|
||
if item.get("updatedAt") or item.get("createdAt")
|
||
]
|
||
return {
|
||
"status": "active" if feedback_items or revenue_events or roi_snapshots or compliance_checks or recommendations else "empty",
|
||
"relationship": {
|
||
"releaseGateStatus": release_gate.get("status") or "empty",
|
||
"latestValidationRunId": None if latest_release_validation is None else str(latest_release_validation.id),
|
||
"latestValidationRunStatus": None if latest_release_validation is None else latest_release_validation.status,
|
||
"source": "pmflow_os_release_growth",
|
||
"changeSetCreated": any(bool(item.get("changeSetCreated")) for item in recommendations),
|
||
"businessSignalTypes": self._unique_strings(
|
||
[
|
||
*("feedback" for _ in feedback_items),
|
||
*("revenue" for _ in revenue_events),
|
||
*("roi" for _ in roi_snapshots),
|
||
*("compliance" for _ in compliance_checks),
|
||
]
|
||
),
|
||
},
|
||
"summary": {
|
||
"feedbackCount": len(feedback_items),
|
||
"revenueEventCount": len(revenue_events),
|
||
"roiSnapshotCount": len(roi_snapshots),
|
||
"complianceCheckCount": len(compliance_checks),
|
||
"iterationRecommendationCount": len(recommendations),
|
||
"riskSignalCount": sum(
|
||
1
|
||
for item in [*roi_snapshots, *compliance_checks]
|
||
if str(item.get("riskLevel") or "low") in {"medium", "high"}
|
||
),
|
||
"latestSignalAt": max(serialized_times) if serialized_times else None,
|
||
},
|
||
"feedback": feedback_items,
|
||
"revenueEvents": revenue_events,
|
||
"roiSnapshots": roi_snapshots,
|
||
"complianceChecks": compliance_checks,
|
||
"recommendations": recommendations,
|
||
"recommendationGroups": recommendation_groups,
|
||
"nextActions": self._release_signal_next_actions(
|
||
feedback_items=feedback_items,
|
||
revenue_events=revenue_events,
|
||
roi_snapshots=roi_snapshots,
|
||
compliance_checks=compliance_checks,
|
||
recommendations=recommendations,
|
||
release_gate=release_gate,
|
||
),
|
||
"warnings": warnings,
|
||
}
|
||
|
||
async def _load_active_prompt_package_for_task(self, *, project_id: int, task_id: int) -> Optional[PromptPackage]:
|
||
result = await self.db.execute(
|
||
select(PromptPackage)
|
||
.where(
|
||
and_(
|
||
PromptPackage.tenant_id == self.tenant_id,
|
||
PromptPackage.project_id == int(project_id),
|
||
PromptPackage.task_id == int(task_id),
|
||
PromptPackage.status == "active",
|
||
)
|
||
)
|
||
.order_by(PromptPackage.updated_at.desc(), PromptPackage.created_at.desc(), PromptPackage.id.desc())
|
||
.limit(1)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _load_execution_prompt_package(self, *, project_id: int, prompt_package_id: int) -> PromptPackage:
|
||
result = await self.db.execute(
|
||
select(PromptPackage).where(
|
||
and_(
|
||
PromptPackage.tenant_id == self.tenant_id,
|
||
PromptPackage.project_id == int(project_id),
|
||
PromptPackage.id == int(prompt_package_id),
|
||
)
|
||
)
|
||
)
|
||
prompt_package = result.scalar_one_or_none()
|
||
if prompt_package is None:
|
||
raise ValueError("prompt_package_not_found")
|
||
if prompt_package.package_kind != "execution_handoff":
|
||
raise ValueError("execution_handoff_not_found")
|
||
return prompt_package
|
||
|
||
async def _load_runtime_credential(self, *, key_id: str) -> PMFlowExecutionRuntimeCredential:
|
||
normalized = str(key_id or "").strip()
|
||
if not normalized:
|
||
raise ValueError("runtime_credential_missing")
|
||
result = await self.db.execute(
|
||
select(PMFlowExecutionRuntimeCredential).where(PMFlowExecutionRuntimeCredential.key_id == normalized)
|
||
)
|
||
credential = result.scalar_one_or_none()
|
||
if credential is None:
|
||
raise ValueError("runtime_credential_missing")
|
||
return credential
|
||
|
||
def _runtime_allowed_sources(self, values: list[str], *, runtime_target: str) -> list[str]:
|
||
allowed: list[str] = []
|
||
for value in list(values or []):
|
||
normalized = str(value or "").strip()
|
||
if normalized in {"local_bridge", "codex", "github_agent", "manual"} and normalized not in allowed:
|
||
allowed.append(normalized)
|
||
if not allowed:
|
||
allowed.append(runtime_target if runtime_target in {"local_bridge", "codex", "github_agent", "manual"} else "manual")
|
||
return allowed
|
||
|
||
def _runtime_secret_for_key(self, key_id: str) -> str:
|
||
material = f"pmflow-runtime-execution:{key_id}".encode("utf-8")
|
||
return hmac.new(str(settings.secret_key).encode("utf-8"), material, hashlib.sha256).hexdigest()
|
||
|
||
@staticmethod
|
||
def _runtime_secret_hash(secret: str) -> str:
|
||
return hashlib.sha256(str(secret).encode("utf-8")).hexdigest()
|
||
|
||
def _validate_runtime_credential(
|
||
self,
|
||
*,
|
||
credential: PMFlowExecutionRuntimeCredential,
|
||
body: Any,
|
||
timestamp: str,
|
||
signature: str,
|
||
raw_body: bytes,
|
||
) -> None:
|
||
if str(credential.status or "") != "active":
|
||
raise ValueError("runtime_credential_revoked")
|
||
if credential.expires_at is None or credential.expires_at < now_cn_naive():
|
||
raise ValueError("runtime_credential_expired")
|
||
secret = self._runtime_secret_for_key(str(credential.key_id))
|
||
if not hmac.compare_digest(self._runtime_secret_hash(secret), str(credential.secret_hash or "")):
|
||
raise ValueError("runtime_credential_invalid")
|
||
if not self._runtime_timestamp_is_fresh(timestamp):
|
||
raise ValueError("runtime_timestamp_invalid")
|
||
expected = hmac.new(
|
||
secret.encode("utf-8"),
|
||
f"{timestamp}.".encode("utf-8") + raw_body,
|
||
hashlib.sha256,
|
||
).hexdigest()
|
||
normalized_signature = str(signature or "").strip()
|
||
if normalized_signature.startswith("sha256="):
|
||
normalized_signature = normalized_signature[len("sha256=") :]
|
||
if not hmac.compare_digest(expected, normalized_signature):
|
||
raise ValueError("runtime_signature_invalid")
|
||
if str(body.prompt_package_id) != str(credential.prompt_package_id) or str(body.task_id) != str(credential.task_id):
|
||
raise ValueError("runtime_handoff_mismatch")
|
||
if str(body.source or "") not in set(credential.allowed_sources or []):
|
||
raise ValueError("runtime_source_not_allowed")
|
||
|
||
@staticmethod
|
||
def _runtime_timestamp_is_fresh(value: str) -> bool:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return False
|
||
try:
|
||
occurred = datetime.fromtimestamp(float(raw))
|
||
except ValueError:
|
||
try:
|
||
occurred = datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||
except ValueError:
|
||
return False
|
||
return abs((now_cn_naive() - occurred).total_seconds()) <= 300
|
||
|
||
def _runtime_credential_response(
|
||
self,
|
||
*,
|
||
credential: PMFlowExecutionRuntimeCredential,
|
||
secret: str,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
note: Optional[str],
|
||
) -> PMFlowExecutionRuntimeCredentialResponse:
|
||
timestamp = str(int(now_cn_naive().timestamp()))
|
||
example_package_payload: dict[str, Any] = {
|
||
"prompt_package_id": str(credential.prompt_package_id),
|
||
"task_id": str(credential.task_id),
|
||
"source": (credential.allowed_sources or [credential.runtime_target])[0],
|
||
"delivery_id": "runtime-package-pull-unique-id",
|
||
"event_source_id": f"{credential.runtime_target}:run-id:package-pulled",
|
||
}
|
||
example_payload: dict[str, Any] = {
|
||
"prompt_package_id": str(credential.prompt_package_id),
|
||
"task_id": str(credential.task_id),
|
||
"repo_binding_id": None if credential.repo_binding_id is None else str(credential.repo_binding_id),
|
||
"event_type": "test_passed",
|
||
"source": (credential.allowed_sources or [credential.runtime_target])[0],
|
||
"branch_name": "codex/example-runtime-bridge",
|
||
"commit_sha": "replace-with-real-commit",
|
||
"test_summary": "unit / contract / smoke passed",
|
||
"execution_status": "testing",
|
||
"delivery_id": "runtime-delivery-unique-id",
|
||
"event_source_id": f"{credential.runtime_target}:run-id:event-id",
|
||
"delivery_artifacts": {
|
||
"changed_files": [
|
||
{
|
||
"path": "frontend/src/features/pmflow-os/pages/ExecutionBridgePage.tsx",
|
||
"change_type": "modified",
|
||
"summary": "展示交付证据包",
|
||
"risk_level": "medium",
|
||
}
|
||
],
|
||
"commands_run": [{"command": "cd frontend && npm run build", "status": "passed", "summary": "构建通过"}],
|
||
"test_outputs": [
|
||
{
|
||
"name": "pmflow-os focused tests",
|
||
"status": "passed",
|
||
"command": "cd frontend && npm run test -- src/features/pmflow-os",
|
||
"summary": "相关测试通过",
|
||
}
|
||
],
|
||
"screenshots": [],
|
||
"logs": [{"level": "info", "summary": "运行端完成交付并回写结构化证据。"}],
|
||
"risk_notes": [],
|
||
"review_checklist": [{"item": "测试证据可追溯", "status": "passed"}],
|
||
},
|
||
}
|
||
if note:
|
||
example_payload["summary"] = note
|
||
if repo is not None:
|
||
example_payload["repository_full_name"] = f"{repo.owner}/{repo.repo_name}"
|
||
return PMFlowExecutionRuntimeCredentialResponse(
|
||
keyId=str(credential.key_id),
|
||
secret=secret,
|
||
secretPreview=f"{secret[:8]}...{secret[-6:]}",
|
||
projectId=str(credential.project_id),
|
||
taskId=str(task.id),
|
||
promptPackageId=str(credential.prompt_package_id),
|
||
repoBindingId=None if credential.repo_binding_id is None else str(credential.repo_binding_id),
|
||
runtimeTarget=credential.runtime_target, # type: ignore[arg-type]
|
||
allowedSources=list(credential.allowed_sources or []),
|
||
expiresAt=credential.expires_at.isoformat(),
|
||
status=str(credential.status),
|
||
exampleHeaders={
|
||
"X-PMFlow-Key-Id": credential.key_id,
|
||
"X-PMFlow-Timestamp": timestamp,
|
||
"X-PMFlow-Signature": "sha256=<hmac_sha256(secret, timestamp + '.' + raw_body)>",
|
||
"X-PMFlow-Delivery-Id": "runtime-delivery-unique-id",
|
||
},
|
||
examplePackagePayload=example_package_payload,
|
||
examplePayload=example_payload,
|
||
)
|
||
|
||
async def _record_execution_package_pull_event(
|
||
self,
|
||
*,
|
||
credential: PMFlowExecutionRuntimeCredential,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
body: PMFlowRuntimeExecutionPackageRequest,
|
||
) -> dict[str, Any]:
|
||
payload = {
|
||
"protocol_version": "runtime_execution_package_pull_v1",
|
||
"workflow_state": "package_pulled",
|
||
"prompt_package_id": str(credential.prompt_package_id),
|
||
"task_id": str(task.id),
|
||
"source": body.source,
|
||
"runtime_target": credential.runtime_target,
|
||
"repo_binding_id": None if repo is None else str(repo.id),
|
||
"repository_full_name": None if repo is None else f"{repo.owner}/{repo.repo_name}",
|
||
"delivery_id": body.delivery_id,
|
||
"event_source_id": body.event_source_id,
|
||
"boundary": "PMFlow returns a machine-readable execution package only; command execution stays outside backend runtime.",
|
||
}
|
||
control = AIProjectControlService(self.db, tenant_id=self.tenant_id)
|
||
return await control.create_or_apply_progress_event(
|
||
task_id=int(task.id),
|
||
project_id=int(project.id),
|
||
event_type="execution_package_pulled",
|
||
source=body.source,
|
||
payload=payload,
|
||
summary=f"运行端已领取执行包:{task.title} / {body.source}",
|
||
repo_binding_id=int(repo.id) if repo is not None else None,
|
||
delivery_id=body.delivery_id,
|
||
event_source_id=body.event_source_id,
|
||
)
|
||
|
||
def _runtime_execution_package_response(
|
||
self,
|
||
*,
|
||
credential: PMFlowExecutionRuntimeCredential,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
prompt_package: PromptPackage,
|
||
memory: PMFlowProjectMemory,
|
||
pull_event: dict[str, Any],
|
||
) -> PMFlowRuntimeExecutionPackageResponse:
|
||
package_payload = self._payload(prompt_package.package_payload)
|
||
task_payload = self._payload(task.ai_breakdown_payload)
|
||
return PMFlowRuntimeExecutionPackageResponse(
|
||
package={
|
||
"project": {
|
||
"id": str(project.id),
|
||
"name": project.name,
|
||
"title": project.title,
|
||
"description": project.description,
|
||
},
|
||
"task": {
|
||
"id": str(task.id),
|
||
"title": task.title,
|
||
"description": task.description,
|
||
"status": task.status,
|
||
"priority": task.priority,
|
||
"truthNodeId": None if task.truth_node_id is None else str(task.truth_node_id),
|
||
"acceptanceCriteria": list(task_payload.get("acceptance_criteria") or []),
|
||
"breakdownPayload": task_payload,
|
||
},
|
||
"repo": None
|
||
if repo is None
|
||
else {
|
||
"id": str(repo.id),
|
||
"provider": repo.provider,
|
||
"owner": repo.owner,
|
||
"repoName": repo.repo_name,
|
||
"defaultBranch": repo.default_branch,
|
||
"status": repo.status,
|
||
},
|
||
"promptPackage": self._serialize_prompt_package(prompt_package),
|
||
"reworkContract": package_payload.get("rework_contract") or {},
|
||
"artifactReview": package_payload.get("artifact_review") or {},
|
||
"projectMemoryContext": memory.model_dump(mode="json"),
|
||
"qualityContract": package_payload.get("quality_contract") or package_payload.get("qualityContract") or {},
|
||
"constraints": [
|
||
"Use Project Memory and task acceptance criteria as the execution source of truth.",
|
||
"Return factual branch / commit / PR / test / blocker metadata and structured delivery_artifacts through the signed callback.",
|
||
*(
|
||
["Resolve every item in reworkContract.reviewItems and return updated delivery_artifacts."]
|
||
if package_payload.get("rework_contract")
|
||
else []
|
||
),
|
||
"Do not treat package pull, test pass, or technical review as Project Memory writeback.",
|
||
],
|
||
"forbiddenActions": [
|
||
"Do not execute commands inside PMFlow backend.",
|
||
"Do not write host configuration such as ~/.codex/config.toml.",
|
||
"Do not merge, deploy, publish, or commit Project Memory through this package pull endpoint.",
|
||
],
|
||
},
|
||
callback={
|
||
"ingestEndpoint": "/api/pmflow-os/runtime/execution-results",
|
||
"headers": {
|
||
"X-PMFlow-Key-Id": credential.key_id,
|
||
"X-PMFlow-Timestamp": "<unix_seconds_or_iso8601>",
|
||
"X-PMFlow-Signature": "sha256=<hmac_sha256(secret, timestamp + '.' + raw_body)>",
|
||
"X-PMFlow-Delivery-Id": "<stable_delivery_id>",
|
||
},
|
||
"resultProtocol": "execution_result_protocol_v1",
|
||
"requiredEvidence": ["branch_or_commit", "test_summary_or_blocker", "execution_status", "delivery_artifacts"],
|
||
"deliveryArtifactSchema": {
|
||
"changed_files": ["path", "change_type", "summary", "risk_level"],
|
||
"commands_run": ["command", "status", "summary", "duration_ms"],
|
||
"test_outputs": ["name", "status", "command", "summary", "output_ref"],
|
||
"screenshots": ["label", "artifact_ref", "artifact_url", "summary"],
|
||
"logs": ["level", "summary", "artifact_ref"],
|
||
"risk_notes": ["risk", "mitigation", "severity"],
|
||
"review_checklist": ["item", "status", "summary"],
|
||
},
|
||
"idempotency": ["delivery_id", "event_source_id"],
|
||
"allowedSources": list(credential.allowed_sources or []),
|
||
"boundary": "Only execution result metadata is accepted; PMFlow backend does not run commands, push code, merge, deploy, release, or write Project Memory.",
|
||
},
|
||
audit={
|
||
"credentialId": str(credential.key_id),
|
||
"packagePulledEventId": str(pull_event.get("id") or ""),
|
||
"idempotentReplay": bool(pull_event.get("idempotent_replay")),
|
||
"expiresAt": credential.expires_at.isoformat() if credential.expires_at else None,
|
||
},
|
||
)
|
||
|
||
async def _create_execution_prompt_package(
|
||
self,
|
||
*,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
runtime_target: str,
|
||
note: Optional[str],
|
||
) -> PromptPackage:
|
||
truth_nodes = await self._load_truth_nodes(project_id=int(project.id))
|
||
memory = await self._project_memory(project=project, truth_nodes=truth_nodes, task_ids=[int(task.id)])
|
||
payload = self._execution_prompt_payload(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=runtime_target,
|
||
note=note,
|
||
memory=memory,
|
||
)
|
||
package = PromptPackage(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project.id),
|
||
charter_id=int(task.truth_node_id),
|
||
task_id=int(task.id),
|
||
package_kind="execution_handoff",
|
||
status="active",
|
||
package_text=self._execution_prompt_text(payload),
|
||
package_payload=payload,
|
||
generated_from={
|
||
"source": "pmflow_os_execution_handoff",
|
||
"project_id": str(project.id),
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": None if repo is None else str(repo.id),
|
||
"runtime_target": runtime_target,
|
||
"generated_at": now_cn_naive().isoformat(),
|
||
},
|
||
)
|
||
self.db.add(package)
|
||
await self.db.flush()
|
||
package.package_payload = {
|
||
**payload,
|
||
"latest_handoff": self._handoff_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=runtime_target,
|
||
note=note,
|
||
prompt_package_id=int(package.id),
|
||
),
|
||
}
|
||
return package
|
||
|
||
async def _create_artifact_rework_prompt_package(
|
||
self,
|
||
*,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
runtime_target: str,
|
||
note: Optional[str],
|
||
current_summary: ExecutionSummaryProjection,
|
||
review_items: list[dict[str, Any]],
|
||
) -> PromptPackage:
|
||
truth_nodes = await self._load_truth_nodes(project_id=int(project.id))
|
||
memory = await self._project_memory(project=project, truth_nodes=truth_nodes, task_ids=[int(task.id)])
|
||
payload = self._execution_prompt_payload(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=runtime_target,
|
||
note=note,
|
||
memory=memory,
|
||
)
|
||
rework_contract = self._artifact_rework_contract(
|
||
current_summary=current_summary,
|
||
review_items=review_items,
|
||
note=note,
|
||
)
|
||
payload.update(
|
||
{
|
||
"handoff_type": "artifact_rework",
|
||
"rework_contract": rework_contract,
|
||
"artifact_review": current_summary.artifactReview,
|
||
"artifact_completeness": current_summary.artifactCompleteness,
|
||
"delivery_artifacts": current_summary.deliveryArtifacts,
|
||
}
|
||
)
|
||
package = PromptPackage(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project.id),
|
||
charter_id=int(task.truth_node_id),
|
||
task_id=int(task.id),
|
||
package_kind="execution_handoff",
|
||
status="active",
|
||
package_text=self._execution_prompt_text(payload),
|
||
package_payload=payload,
|
||
generated_from={
|
||
"source": "pmflow_os_artifact_rework_package",
|
||
"project_id": str(project.id),
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": None if repo is None else str(repo.id),
|
||
"runtime_target": runtime_target,
|
||
"review_item_ids": [str(item.get("id") or "") for item in review_items],
|
||
"generated_at": now_cn_naive().isoformat(),
|
||
},
|
||
)
|
||
self.db.add(package)
|
||
await self.db.flush()
|
||
latest_handoff = self._handoff_payload(
|
||
task=task,
|
||
repo=repo,
|
||
runtime_target=runtime_target,
|
||
note=note,
|
||
prompt_package_id=int(package.id),
|
||
)
|
||
latest_handoff.update(
|
||
{
|
||
"handoff_type": "artifact_rework",
|
||
"rework_contract": rework_contract,
|
||
"review_item_ids": [str(item.get("id") or "") for item in review_items],
|
||
}
|
||
)
|
||
package.package_payload = {**payload, "latest_handoff": latest_handoff}
|
||
return package
|
||
|
||
def _artifact_rework_contract(
|
||
self,
|
||
*,
|
||
current_summary: ExecutionSummaryProjection,
|
||
review_items: list[dict[str, Any]],
|
||
note: Optional[str],
|
||
) -> dict[str, Any]:
|
||
expected_evidence: list[str] = []
|
||
commands: list[str] = []
|
||
for item in review_items:
|
||
expected_evidence.extend(str(value) for value in list(item.get("expectedEvidence") or []) if str(value))
|
||
suggested = str(item.get("suggestedEventType") or "").strip()
|
||
if suggested:
|
||
commands.append(f"Return event_type={suggested} when evidence is ready")
|
||
return {
|
||
"protocol_version": "artifact_rework_contract_v1",
|
||
"status": "ready",
|
||
"reviewItems": review_items[:30],
|
||
"expectedEvidence": self._unique_strings(expected_evidence),
|
||
"suggestedReturnEvents": self._unique_strings(commands),
|
||
"artifactCompleteness": current_summary.artifactCompleteness,
|
||
"previousDeliveryArtifacts": current_summary.deliveryArtifacts,
|
||
"operatorNote": note,
|
||
"requiredCallback": {
|
||
"endpoint": "/api/pmflow-os/runtime/execution-results",
|
||
"resultProtocol": "execution_result_protocol_v1",
|
||
"mustReturn": ["delivery_artifacts", "test_summary_or_blocker", "execution_status"],
|
||
},
|
||
"boundary": "This rework package instructs an external runtime what evidence to fix or add. PMFlow backend does not execute commands, create branches, merge, deploy, publish, or write Project Memory.",
|
||
}
|
||
|
||
async def _load_project_thread(self, *, project_id: int) -> tuple[Optional[PMFlowThreadModel], list[PMFlowThreadMessage]]:
|
||
thread_result = await self.db.execute(
|
||
select(PMFlowThreadModel)
|
||
.where(
|
||
and_(
|
||
PMFlowThreadModel.tenant_id == self.tenant_id,
|
||
PMFlowThreadModel.project_id == int(project_id),
|
||
PMFlowThreadModel.status == "active",
|
||
)
|
||
)
|
||
.order_by(
|
||
(PMFlowThreadModel.source == "project_launch").desc(),
|
||
PMFlowThreadModel.created_at.asc(),
|
||
PMFlowThreadModel.id.asc(),
|
||
)
|
||
.limit(1)
|
||
)
|
||
thread = thread_result.scalar_one_or_none()
|
||
if thread is None:
|
||
return None, []
|
||
message_result = await self.db.execute(
|
||
select(PMFlowThreadMessage)
|
||
.where(
|
||
and_(
|
||
PMFlowThreadMessage.tenant_id == self.tenant_id,
|
||
PMFlowThreadMessage.project_id == int(project_id),
|
||
PMFlowThreadMessage.thread_id == int(thread.id),
|
||
)
|
||
)
|
||
.order_by(PMFlowThreadMessage.created_at.asc(), PMFlowThreadMessage.id.asc())
|
||
)
|
||
return thread, list(message_result.scalars().all())
|
||
|
||
def _project_messages(self, rows: list[PMFlowThreadMessage]) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"id": str(row.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" if row.status == "sent" else ("analyzing" if row.status == "running" else "resolved"),
|
||
"source": row.source,
|
||
"metadata": row.message_metadata or {},
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
async def _load_ai_feedback(self, *, project_id: int, current_user_id: Optional[int]) -> list[Any]:
|
||
if current_user_id is None:
|
||
return []
|
||
result = await self.db.execute(
|
||
select(PMFlowAIFeedbackEvent)
|
||
.where(
|
||
and_(
|
||
PMFlowAIFeedbackEvent.tenant_id == self.tenant_id,
|
||
PMFlowAIFeedbackEvent.project_id == int(project_id),
|
||
PMFlowAIFeedbackEvent.actor_user_id == int(current_user_id),
|
||
)
|
||
)
|
||
.order_by(PMFlowAIFeedbackEvent.updated_at.desc(), PMFlowAIFeedbackEvent.id.desc())
|
||
)
|
||
return [PMFlowAIFeedbackService.serialize(row) for row in result.scalars().all()]
|
||
|
||
async def _load_review_projection(self, *, project_id: int) -> dict[str, list[dict[str, Any]]]:
|
||
change_set_result = await self.db.execute(
|
||
select(PMFlowChangeSet)
|
||
.where(and_(PMFlowChangeSet.tenant_id == self.tenant_id, PMFlowChangeSet.project_id == int(project_id)))
|
||
.order_by(PMFlowChangeSet.updated_at.desc(), PMFlowChangeSet.created_at.desc(), PMFlowChangeSet.id.desc())
|
||
)
|
||
change_sets = list(change_set_result.scalars().all())
|
||
if not change_sets:
|
||
return {"change_sets": [], "change_items": [], "evidence": [], "approvals": []}
|
||
change_set_ids = [int(row.id) for row in change_sets]
|
||
item_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.in_(change_set_ids),
|
||
)
|
||
)
|
||
.order_by(PMFlowChangeItem.created_at.asc(), PMFlowChangeItem.id.asc())
|
||
)
|
||
item_rows = list(item_result.scalars().all())
|
||
evidence_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.in_(change_set_ids),
|
||
)
|
||
)
|
||
.order_by(PMFlowEvidenceLink.created_at.asc(), PMFlowEvidenceLink.id.asc())
|
||
)
|
||
evidence_rows = list(evidence_result.scalars().all())
|
||
approval_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.in_(change_set_ids),
|
||
)
|
||
)
|
||
.order_by(PMFlowApproval.created_at.asc(), PMFlowApproval.id.asc())
|
||
)
|
||
approval_rows = list(approval_result.scalars().all())
|
||
committed_user_ids = [int(row.committed_by) for row in change_sets if row.committed_by]
|
||
committed_users = await self._load_user_display_map(user_ids=committed_user_ids)
|
||
items_by_set: dict[int, list[PMFlowChangeItem]] = {}
|
||
evidence_by_set: dict[int, list[PMFlowEvidenceLink]] = {}
|
||
for item in item_rows:
|
||
items_by_set.setdefault(int(item.change_set_id), []).append(item)
|
||
for evidence in evidence_rows:
|
||
evidence_by_set.setdefault(int(evidence.change_set_id), []).append(evidence)
|
||
return {
|
||
"change_sets": [
|
||
self._serialize_change_set_projection(
|
||
row,
|
||
items=items_by_set.get(int(row.id), []),
|
||
committed_by=committed_users.get(int(row.committed_by)) if row.committed_by else None,
|
||
)
|
||
for row in change_sets
|
||
],
|
||
"change_items": [
|
||
PMFlowChangeReviewService.serialize_change_item(item, evidence_by_set.get(int(item.change_set_id), []))
|
||
for item in item_rows
|
||
],
|
||
"evidence": [PMFlowChangeReviewService.serialize_evidence(row) for row in evidence_rows],
|
||
"approvals": [PMFlowChangeReviewService.serialize_approval(row) for row in approval_rows],
|
||
}
|
||
|
||
async def _load_user_display_map(self, *, user_ids: list[int]) -> dict[int, str]:
|
||
normalized_ids = sorted({int(user_id) for user_id in user_ids if user_id})
|
||
if not normalized_ids:
|
||
return {}
|
||
result = await self.db.execute(
|
||
select(User).where(and_(User.tenant_id == self.tenant_id, User.id.in_(normalized_ids)))
|
||
)
|
||
return {int(user.id): self._user_display_name(user) for user in result.scalars().all()}
|
||
|
||
def _serialize_change_set_projection(
|
||
self,
|
||
row: PMFlowChangeSet,
|
||
*,
|
||
items: list[PMFlowChangeItem],
|
||
committed_by: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
payload = PMFlowChangeReviewService.serialize_change_set(row, items)
|
||
payload["committedBy"] = committed_by
|
||
payload["committedById"] = str(row.committed_by) if row.committed_by else None
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _latest_writeback_meta(change_sets: list[dict[str, Any]]) -> dict[str, Optional[str]]:
|
||
committed = [
|
||
row
|
||
for row in change_sets
|
||
if row.get("committedAt") and (row.get("committedBy") or row.get("committedById"))
|
||
]
|
||
if not committed:
|
||
return {"at": None, "by": None, "by_id": None}
|
||
latest = max(committed, key=lambda row: str(row.get("committedAt") or ""))
|
||
return {
|
||
"at": str(latest.get("committedAt") or "") or None,
|
||
"by": str(latest.get("committedBy") or "") or None,
|
||
"by_id": str(latest.get("committedById") or "") or None,
|
||
}
|
||
|
||
@staticmethod
|
||
def _user_display_name(user: User) -> str:
|
||
return str(user.name or user.username or user.email or user.id)
|
||
|
||
def _project_reviewer_routing(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
change_sets: list[dict[str, Any]],
|
||
change_items: list[dict[str, Any]],
|
||
evidence: list[dict[str, Any]],
|
||
members: list[User],
|
||
) -> tuple[dict[str, Any], list[str]]:
|
||
all_items_by_set: dict[str, list[dict[str, Any]]] = {}
|
||
items_by_set: dict[str, list[dict[str, Any]]] = {}
|
||
for item in change_items:
|
||
all_items_by_set.setdefault(str(item.get("changeSetId")), []).append(item)
|
||
if item.get("status") != "pending_review":
|
||
continue
|
||
items_by_set.setdefault(str(item.get("changeSetId")), []).append(item)
|
||
|
||
evidence_by_set: dict[str, list[dict[str, Any]]] = {}
|
||
for row in evidence:
|
||
evidence_by_set.setdefault(str(row.get("changeSetId") or ""), []).append(row)
|
||
|
||
routing_items: list[dict[str, Any]] = []
|
||
warnings: list[str] = []
|
||
for change_set in change_sets:
|
||
change_set_id = str(change_set.get("id") or "")
|
||
review_meta = self._change_set_review_decision_meta(change_set)
|
||
review_action = self._review_decision_action(review_meta)
|
||
approval_state = self._approval_state(review_meta)
|
||
pending_items = items_by_set.get(change_set_id, [])
|
||
all_items = all_items_by_set.get(change_set_id, [])
|
||
decision_visible = bool(review_action and approval_state != "not_requested")
|
||
if not change_set_id or (not pending_items and not decision_visible):
|
||
continue
|
||
change_set_evidence = evidence_by_set.get(change_set_id, [])
|
||
review_items = pending_items or all_items
|
||
risk_levels = [str(item.get("riskLevel") or "medium") for item in review_items]
|
||
object_types = sorted({str(item.get("objectType") or "Unknown") for item in review_items})
|
||
risk_groups = sorted({str(item.get("group") or "unknown") for item in review_items})
|
||
high_risk_signal = any(str(item.get("objectType") or "").lower() == "risk" or str(item.get("group") or "").lower() == "risk" for item in review_items)
|
||
highest_risk = "high" if high_risk_signal else self._highest_risk_level(risk_levels)
|
||
cross_domain = len(object_types) > 1 or len(risk_groups) > 1
|
||
review_reasons = self._review_routing_reasons(
|
||
pending_count=len(pending_items),
|
||
highest_risk=highest_risk,
|
||
object_types=object_types,
|
||
risk_groups=risk_groups,
|
||
cross_domain=cross_domain,
|
||
review_action=review_action,
|
||
approval_state=approval_state,
|
||
)
|
||
suggested_reviewer = self._suggest_reviewer(
|
||
members=members,
|
||
highest_risk=highest_risk,
|
||
cross_domain=cross_domain,
|
||
)
|
||
warning = None
|
||
fallback_role = None
|
||
if not members:
|
||
warning = "reviewer_missing"
|
||
fallback_role = "OWNER"
|
||
elif suggested_reviewer is None:
|
||
warning = "reviewer_missing"
|
||
fallback_role = "OWNER"
|
||
elif not change_set_evidence:
|
||
warning = "evidence_missing"
|
||
if decision_visible and not pending_items and approval_state in {"approved", "changes_requested", "rejected"}:
|
||
warning = None
|
||
fallback_role = None
|
||
if warning:
|
||
warnings.append(f"reviewer_routing_warning:{change_set_id}:{warning}")
|
||
source_type = self._reviewer_routing_source_type(change_set_evidence)
|
||
priority = self._reviewer_routing_priority(highest_risk=highest_risk, cross_domain=cross_domain)
|
||
routing_items.append(
|
||
{
|
||
"projectId": str(project_id),
|
||
"changeSetId": change_set_id,
|
||
"changeSetTitle": str(change_set.get("headline") or "待审 ChangeSet"),
|
||
"changeSetStatus": str(change_set.get("status") or change_set.get("rawStatus") or "open"),
|
||
"sourceType": source_type,
|
||
"reviewReason": ";".join(review_reasons),
|
||
"riskLevel": highest_risk,
|
||
"priority": priority,
|
||
"changeTypes": object_types,
|
||
"itemCount": len(change_set.get("itemIds") or review_items),
|
||
"pendingItemCount": len(pending_items),
|
||
"evidenceCount": len(change_set_evidence),
|
||
"suggestedReviewer": suggested_reviewer,
|
||
"fallbackReviewerRole": fallback_role,
|
||
"reviewDecision": review_action,
|
||
"approvalState": approval_state,
|
||
"requestedAt": review_meta.get("requestedAt"),
|
||
"decidedAt": review_meta.get("decidedAt"),
|
||
"decisionReason": review_meta.get("decisionReason"),
|
||
"targetUrl": f"/change-review?projectId={int(project_id)}&changeSetId={change_set_id}",
|
||
"createdAt": change_set.get("createdAt"),
|
||
"updatedAt": change_set.get("updatedAt") or change_set.get("createdAt"),
|
||
}
|
||
)
|
||
missing_reviewer_count = sum(
|
||
1
|
||
for item in routing_items
|
||
if self._reviewer_routing_requires_reviewer(item) and item.get("suggestedReviewer") is None
|
||
)
|
||
warning_code = self._reviewer_routing_warning_code(routing_items)
|
||
status = "empty" if not routing_items else ("warning" if warning_code else "ready")
|
||
return (
|
||
{
|
||
"status": status,
|
||
"warningCode": warning_code,
|
||
"items": routing_items,
|
||
"summary": {
|
||
"totalPendingReview": sum(int(item.get("pendingItemCount") or 0) for item in routing_items),
|
||
"highRiskCount": sum(1 for item in routing_items if item.get("riskLevel") == "high"),
|
||
"missingReviewerCount": missing_reviewer_count,
|
||
"iterationDraftCount": sum(1 for item in routing_items if item.get("sourceType") == "iteration_recommendation"),
|
||
"requestedReviewCount": sum(1 for item in routing_items if item.get("approvalState") == "requested"),
|
||
"approvedCount": sum(1 for item in routing_items if item.get("approvalState") == "approved"),
|
||
"changesRequestedCount": sum(1 for item in routing_items if item.get("approvalState") == "changes_requested"),
|
||
"rejectedCount": sum(1 for item in routing_items if item.get("approvalState") == "rejected"),
|
||
},
|
||
},
|
||
warnings,
|
||
)
|
||
|
||
@staticmethod
|
||
def _highest_risk_level(levels: list[str]) -> str:
|
||
rank = {"low": 0, "medium": 1, "high": 2}
|
||
highest = "low"
|
||
for level in levels:
|
||
normalized = str(level or "medium").lower()
|
||
if rank.get(normalized, 1) > rank[highest]:
|
||
highest = normalized
|
||
return highest
|
||
|
||
@staticmethod
|
||
def _review_routing_reasons(
|
||
*,
|
||
pending_count: int,
|
||
highest_risk: str,
|
||
object_types: list[str],
|
||
risk_groups: list[str],
|
||
cross_domain: bool,
|
||
review_action: Optional[str] = None,
|
||
approval_state: str = "not_requested",
|
||
) -> list[str]:
|
||
reasons = [f"{pending_count} 个变更项仍处于待审状态。"] if pending_count else ["当前 ChangeSet 已形成审查决策,等待下一步处理。"]
|
||
if approval_state == "requested":
|
||
reasons.append("已请求 reviewer 审查,等待审查意见。")
|
||
elif approval_state == "approved":
|
||
reasons.append("reviewer 已通过,仍需人工决定是否 commit。")
|
||
elif approval_state == "changes_requested":
|
||
reasons.append("reviewer 要求修改,不能直接写入 Project Memory。")
|
||
elif approval_state == "rejected":
|
||
reasons.append("reviewer 已拒绝,本轮不得写入 Project Memory。")
|
||
if highest_risk == "high":
|
||
reasons.append("包含 high risk 变更,需要 REVIEWER 或 OWNER 复核。")
|
||
if cross_domain:
|
||
reasons.append(f"变更跨越 {' / '.join(object_types)},需要项目 owner 统一审查口径。")
|
||
if "risk" in {group.lower() for group in risk_groups}:
|
||
reasons.append("包含风险类变更,审查时需要确认缓解策略是否足够。")
|
||
if "decision" in {group.lower() for group in risk_groups}:
|
||
reasons.append("包含决策类变更,审查时需要确认拍板依据。")
|
||
if review_action == "request_changes":
|
||
reasons.append("下一步应回到 Change Review 修改内容后再重新请求审查。")
|
||
return reasons
|
||
|
||
@staticmethod
|
||
def _reviewer_routing_priority(*, highest_risk: str, cross_domain: bool) -> str:
|
||
if highest_risk == "high":
|
||
return "urgent"
|
||
if cross_domain:
|
||
return "elevated"
|
||
return "normal"
|
||
|
||
@staticmethod
|
||
def _reviewer_routing_source_type(evidence: list[dict[str, Any]]) -> str:
|
||
source_types = {str(row.get("sourceType") or "").strip() for row in evidence}
|
||
if "iteration_recommendation_group" in source_types or "iteration_recommendation" in source_types:
|
||
return "iteration_recommendation"
|
||
if "thread_message" in source_types:
|
||
return "ai_extraction"
|
||
if source_types:
|
||
return "manual"
|
||
return "unknown"
|
||
|
||
@staticmethod
|
||
def _reviewer_routing_warning_code(items: list[dict[str, Any]]) -> Optional[str]:
|
||
if not items:
|
||
return None
|
||
active_items = [item for item in items if PMFlowOSProjectionService._reviewer_routing_requires_reviewer(item)]
|
||
if any(item.get("suggestedReviewer") is None for item in active_items):
|
||
return "reviewer_missing"
|
||
if any(int(item.get("evidenceCount") or 0) == 0 for item in active_items):
|
||
return "evidence_missing"
|
||
return None
|
||
|
||
@staticmethod
|
||
def _reviewer_routing_requires_reviewer(item: dict[str, Any]) -> bool:
|
||
approval_state = str(item.get("approvalState") or "not_requested")
|
||
if approval_state in {"approved", "changes_requested", "rejected"}:
|
||
return int(item.get("pendingItemCount") or 0) > 0
|
||
return True
|
||
|
||
@staticmethod
|
||
def _suggest_reviewer(
|
||
*,
|
||
members: list[User],
|
||
highest_risk: str,
|
||
cross_domain: bool,
|
||
) -> Optional[dict[str, str]]:
|
||
reviewers = [user for user in members if str(user.role or "").upper() == "REVIEWER"]
|
||
owners = [user for user in members if str(user.role or "").upper() == "OWNER"]
|
||
if reviewers:
|
||
return PMFlowOSProjectionService._reviewer_payload(
|
||
reviewers[0],
|
||
"REVIEWER 角色,优先复核 high risk / 跨类型 ChangeSet。",
|
||
"high" if highest_risk == "high" else "medium",
|
||
)
|
||
if owners:
|
||
reason = "项目 OWNER,适合统一跨对象变更审查口径。" if cross_domain else "项目 OWNER,可作为本轮变更审查负责人。"
|
||
return PMFlowOSProjectionService._reviewer_payload(owners[0], reason, "medium")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _reviewer_payload(user: User, reason: str, confidence: str) -> dict[str, str]:
|
||
return {
|
||
"userId": str(user.id),
|
||
"displayName": user.name or user.username or f"User#{user.id}",
|
||
"role": str(user.role or "MEMBER"),
|
||
"reason": reason,
|
||
"confidence": confidence,
|
||
}
|
||
|
||
@staticmethod
|
||
def _change_set_review_decision_meta(change_set: dict[str, Any]) -> dict[str, Any]:
|
||
routing_meta = change_set.get("routingMeta")
|
||
if not isinstance(routing_meta, dict):
|
||
return {}
|
||
review_meta = routing_meta.get("reviewDecision")
|
||
return dict(review_meta) if isinstance(review_meta, dict) else {}
|
||
|
||
@staticmethod
|
||
def _review_decision_action(review_meta: dict[str, Any]) -> Optional[str]:
|
||
action = str(review_meta.get("action") or "").strip()
|
||
return action if action in {"request_review", "approve", "request_changes", "reject"} else None
|
||
|
||
@staticmethod
|
||
def _approval_state(review_meta: dict[str, Any]) -> str:
|
||
state = str(review_meta.get("approvalState") or "").strip()
|
||
if state in {"requested", "approved", "changes_requested", "rejected"}:
|
||
return state
|
||
return "not_requested"
|
||
|
||
@staticmethod
|
||
def _active_change_set_id(change_sets: list[dict[str, Any]]) -> Optional[str]:
|
||
for row in change_sets:
|
||
if row.get("rawStatus") == "open" or row.get("status") == "open":
|
||
return str(row.get("id"))
|
||
return None
|
||
|
||
@staticmethod
|
||
def _review_loop_stage(*, change_sets: list[dict[str, Any]], change_items: list[dict[str, Any]]) -> str:
|
||
active = PMFlowOSProjectionService._active_change_set_id(change_sets)
|
||
if active is None:
|
||
return "command_idle"
|
||
if any(str(item.get("changeSetId")) == active and item.get("status") == "pending_review" for item in change_items):
|
||
return "review_open"
|
||
return "extraction_ready"
|
||
|
||
def _project_tasks(self, rows: list[tuple[Task, Optional[User]]]) -> list[PMFlowTask]:
|
||
return [
|
||
PMFlowTask(
|
||
id=str(task.id),
|
||
title=task.title,
|
||
summary=task.description or "",
|
||
stage=self._task_stage(task.status),
|
||
status=self._object_status(task.status),
|
||
assignee=user.name if user is not None else "未分配",
|
||
acceptanceCriteria=self._task_acceptance_criteria(task),
|
||
requirementId=str(task.truth_node_id) if task.truth_node_id is not None else None,
|
||
)
|
||
for task, user in rows
|
||
]
|
||
|
||
def _project_requirements(self, *, truth_nodes: list[TruthNode], tasks: list[PMFlowTask]) -> list[PMFlowRequirement]:
|
||
linked_by_node: dict[str, list[str]] = {}
|
||
for task in tasks:
|
||
if task.requirementId:
|
||
linked_by_node.setdefault(str(task.requirementId), []).append(task.id)
|
||
requirements: list[PMFlowRequirement] = []
|
||
for node in truth_nodes:
|
||
node_type = self._node_type(node)
|
||
if node_type not in {"requirement", "charter"}:
|
||
continue
|
||
requirements.append(
|
||
PMFlowRequirement(
|
||
id=str(node.id),
|
||
title=node.title,
|
||
summary=self._node_summary(node),
|
||
stage=self._stage_from_payload(node.content, default="planning"),
|
||
status=self._truth_status(node.status),
|
||
linkedTaskIds=linked_by_node.get(str(node.id), []),
|
||
)
|
||
)
|
||
return requirements
|
||
|
||
def _project_decisions(self, truth_nodes: list[TruthNode]) -> list[PMFlowDecision]:
|
||
decisions: list[PMFlowDecision] = []
|
||
for node in truth_nodes:
|
||
if self._node_type(node) != "decision":
|
||
continue
|
||
content = self._payload(node.content)
|
||
decisions.append(
|
||
PMFlowDecision(
|
||
id=str(node.id),
|
||
title=node.title,
|
||
summary=self._node_summary(node),
|
||
stage=self._stage_from_payload(content, default="planning"),
|
||
status=self._truth_status(node.status),
|
||
owner=str(content.get("owner") or content.get("decision_owner") or "未分配"),
|
||
)
|
||
)
|
||
return decisions
|
||
|
||
def _project_risks(self, truth_nodes: list[TruthNode]) -> list[PMFlowRisk]:
|
||
risks: list[PMFlowRisk] = []
|
||
for node in truth_nodes:
|
||
if self._node_type(node) != "risk":
|
||
continue
|
||
content = self._payload(node.content)
|
||
risks.append(
|
||
PMFlowRisk(
|
||
id=str(node.id),
|
||
title=node.title,
|
||
summary=self._node_summary(node),
|
||
stage=self._stage_from_payload(content, default="planning"),
|
||
status=self._truth_status(node.status),
|
||
level=self._risk_level(content.get("level") or content.get("risk_level") or node.status),
|
||
mitigation=str(content.get("mitigation") or content.get("mitigation_plan") or "待补充缓解策略"),
|
||
)
|
||
)
|
||
return risks
|
||
|
||
async def _project_model_decisions(self, *, project_id: int) -> list[PMFlowModelDecision]:
|
||
binding_rows = await self.db.execute(
|
||
select(ProjectModelBinding, ModelCatalogItem)
|
||
.join(ModelCatalogItem, ModelCatalogItem.id == ProjectModelBinding.model_catalog_item_id)
|
||
.where(
|
||
and_(
|
||
ProjectModelBinding.tenant_id == self.tenant_id,
|
||
ProjectModelBinding.project_id == int(project_id),
|
||
)
|
||
)
|
||
.order_by(ProjectModelBinding.updated_at.desc(), ProjectModelBinding.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
decisions: list[PMFlowModelDecision] = []
|
||
for binding, catalog in binding_rows.all():
|
||
reason = self._payload(binding.selection_reason)
|
||
decisions.append(
|
||
PMFlowModelDecision(
|
||
id=f"model-binding-{binding.id}",
|
||
title=f"模型绑定:{catalog.display_name}",
|
||
summary=self._short_text(reason) or f"{catalog.provider} / {catalog.model_key}",
|
||
stage="planning",
|
||
status="confirmed" if str(binding.status).lower() == "active" else "draft",
|
||
rationale=self._short_text(reason) or "来自 project_model_bindings.selection_reason",
|
||
)
|
||
)
|
||
snapshot_rows = await self.db.execute(
|
||
select(ModelRecommendationSnapshot)
|
||
.where(
|
||
and_(
|
||
ModelRecommendationSnapshot.tenant_id == self.tenant_id,
|
||
ModelRecommendationSnapshot.project_id == int(project_id),
|
||
)
|
||
)
|
||
.order_by(ModelRecommendationSnapshot.updated_at.desc(), ModelRecommendationSnapshot.created_at.desc())
|
||
.limit(5)
|
||
)
|
||
for snapshot in snapshot_rows.scalars().all():
|
||
first = next(iter(snapshot.recommendations or []), {})
|
||
decisions.append(
|
||
PMFlowModelDecision(
|
||
id=f"model-recommendation-{snapshot.id}",
|
||
title=f"模型推荐快照:{snapshot.recommendation_scope}",
|
||
summary=self._short_text(first) or "模型推荐已生成,等待人工绑定确认。",
|
||
stage="planning",
|
||
status="draft",
|
||
rationale="来自 model_recommendation_snapshots;A3 不把推荐快照标记为已确认事实。",
|
||
)
|
||
)
|
||
return decisions
|
||
|
||
async def _project_test_runs(self, *, project_id: int) -> list[PMFlowTestRun]:
|
||
runs: list[PMFlowTestRun] = []
|
||
eval_result = await self.db.execute(
|
||
select(ModelEvalRun)
|
||
.where(and_(ModelEvalRun.tenant_id == self.tenant_id, ModelEvalRun.project_id == int(project_id)))
|
||
.order_by(ModelEvalRun.updated_at.desc(), ModelEvalRun.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
for run in eval_result.scalars().all():
|
||
result = self._test_result(run.status, run.summary)
|
||
if result is None:
|
||
continue
|
||
runs.append(
|
||
PMFlowTestRun(
|
||
id=f"model-eval-{run.id}",
|
||
title="模型效果验证",
|
||
summary=self._short_text(run.summary) or str(run.status),
|
||
status="done" if result == "passed" else "blocked",
|
||
result=result,
|
||
)
|
||
)
|
||
progress_result = await self.db.execute(
|
||
select(ProgressEvent)
|
||
.where(
|
||
and_(
|
||
ProgressEvent.tenant_id == self.tenant_id,
|
||
ProgressEvent.project_id == int(project_id),
|
||
ProgressEvent.event_type.ilike("%test%"),
|
||
)
|
||
)
|
||
.order_by(ProgressEvent.occurred_at.desc(), ProgressEvent.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
for event in progress_result.scalars().all():
|
||
result = self._test_result(event.derived_task_status or event.event_type, event.payload)
|
||
if result is None:
|
||
continue
|
||
runs.append(
|
||
PMFlowTestRun(
|
||
id=f"progress-test-{event.id}",
|
||
title=event.summary or "执行桥测试回写",
|
||
summary=event.summary or self._short_text(event.payload) or event.event_type,
|
||
status="done" if result == "passed" else "blocked",
|
||
result=result,
|
||
linkedTaskId=str(event.task_id),
|
||
)
|
||
)
|
||
return runs
|
||
|
||
async def _project_releases(self, *, truth_nodes: list[TruthNode], project_id: int) -> list[PMFlowRelease]:
|
||
validation_result = await self.db.execute(
|
||
select(ReleaseValidationRun)
|
||
.where(and_(ReleaseValidationRun.tenant_id == self.tenant_id, ReleaseValidationRun.project_id == int(project_id)))
|
||
.order_by(ReleaseValidationRun.updated_at.desc(), ReleaseValidationRun.created_at.desc())
|
||
)
|
||
validation_by_release = {
|
||
int(run.release_node_id): run
|
||
for run in validation_result.scalars().all()
|
||
if run.release_node_id is not None
|
||
}
|
||
releases: list[PMFlowRelease] = []
|
||
for node in truth_nodes:
|
||
if self._node_type(node) != "release":
|
||
continue
|
||
content = self._payload(node.content)
|
||
validation = validation_by_release.get(int(node.id))
|
||
releases.append(
|
||
PMFlowRelease(
|
||
id=str(node.id),
|
||
title=node.title,
|
||
summary=self._node_summary(node),
|
||
status=self._release_status(node.status, validation.status if validation else None),
|
||
gateItems=self._release_gate_items(content, validation),
|
||
)
|
||
)
|
||
return releases
|
||
|
||
async def _project_metric_signals(self, *, project_id: int) -> list[PMFlowMetricSignal]:
|
||
signals: list[PMFlowMetricSignal] = []
|
||
feedback_result = await self.db.execute(
|
||
select(FeedbackIngest)
|
||
.where(and_(FeedbackIngest.tenant_id == self.tenant_id, FeedbackIngest.project_id == int(project_id)))
|
||
.order_by(FeedbackIngest.updated_at.desc(), FeedbackIngest.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
for feedback in feedback_result.scalars().all():
|
||
for key, value in self._payload(feedback.metrics).items():
|
||
signals.append(
|
||
PMFlowMetricSignal(
|
||
id=f"feedback-{feedback.id}-{key}",
|
||
title=str(key),
|
||
summary=feedback.title or feedback.content[:120],
|
||
value=str(value),
|
||
direction="flat",
|
||
)
|
||
)
|
||
revenue_result = await self.db.execute(
|
||
select(RevenueEvent)
|
||
.where(and_(RevenueEvent.tenant_id == self.tenant_id, RevenueEvent.project_id == int(project_id)))
|
||
.order_by(RevenueEvent.occurred_at.desc(), RevenueEvent.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
for event in revenue_result.scalars().all():
|
||
signals.append(
|
||
PMFlowMetricSignal(
|
||
id=f"revenue-{event.id}",
|
||
title=f"收入事件:{event.category}",
|
||
summary=str(event.external_ref or event.source_type or "收入回写"),
|
||
value=f"{event.currency} {self._jsonable(event.amount_usd)}",
|
||
direction="up" if float(event.amount_usd or 0) > 0 else "flat",
|
||
)
|
||
)
|
||
roi_result = await self.db.execute(
|
||
select(ROISnapshot)
|
||
.where(and_(ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id)))
|
||
.order_by(ROISnapshot.updated_at.desc(), ROISnapshot.created_at.desc())
|
||
.limit(5)
|
||
)
|
||
for snapshot in roi_result.scalars().all():
|
||
signals.append(
|
||
PMFlowMetricSignal(
|
||
id=f"roi-{snapshot.id}",
|
||
title=f"ROI 观察:{snapshot.range_key}",
|
||
summary=self._short_text(snapshot.snapshot_payload) or "ROI 快照",
|
||
value=str(self._jsonable(snapshot.roi_ratio)),
|
||
direction="up" if float(snapshot.roi_ratio or 0) > 0 else "flat",
|
||
)
|
||
)
|
||
return signals
|
||
|
||
async def _load_project_memory_semantic_facts(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
truth_nodes: list[TruthNode],
|
||
tasks: list[tuple[Task, Optional[User]]],
|
||
) -> list[SemanticFact]:
|
||
truth_ids = [int(node.id) for node in truth_nodes]
|
||
task_ids = [int(task.id) for task, _ in tasks]
|
||
clauses = [and_(SemanticFact.entity_type == "project", SemanticFact.entity_id == int(project_id))]
|
||
if truth_ids:
|
||
clauses.append(and_(SemanticFact.entity_type.in_(["truth_node", "truth_nodes"]), SemanticFact.entity_id.in_(truth_ids)))
|
||
if task_ids:
|
||
clauses.append(and_(SemanticFact.entity_type == "task", SemanticFact.entity_id.in_(task_ids)))
|
||
result = await self.db.execute(
|
||
select(SemanticFact)
|
||
.where(and_(SemanticFact.tenant_id == self.tenant_id, SemanticFact.is_active.is_(True), or_(*clauses)))
|
||
.order_by(SemanticFact.updated_at.desc(), SemanticFact.created_at.desc(), SemanticFact.id.desc())
|
||
.limit(500)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
async def _memory_evidence_by_item(self, *, project_id: int) -> dict[str, list[dict[str, Any]]]:
|
||
result = await self.db.execute(
|
||
select(PMFlowEvidenceLink)
|
||
.where(and_(PMFlowEvidenceLink.tenant_id == self.tenant_id, PMFlowEvidenceLink.project_id == int(project_id)))
|
||
.order_by(PMFlowEvidenceLink.updated_at.desc(), PMFlowEvidenceLink.created_at.desc(), PMFlowEvidenceLink.id.desc())
|
||
.limit(500)
|
||
)
|
||
evidence_by_item: dict[str, list[dict[str, Any]]] = {}
|
||
evidence_links = list(result.scalars().all())
|
||
change_item_ids = [int(evidence.change_item_id) for evidence in evidence_links if evidence.change_item_id is not None]
|
||
change_items_by_id: dict[int, PMFlowChangeItem] = {}
|
||
if change_item_ids:
|
||
items_result = await self.db.execute(
|
||
select(PMFlowChangeItem).where(
|
||
and_(
|
||
PMFlowChangeItem.tenant_id == self.tenant_id,
|
||
PMFlowChangeItem.project_id == int(project_id),
|
||
PMFlowChangeItem.id.in_(change_item_ids),
|
||
)
|
||
)
|
||
)
|
||
change_items_by_id = {int(item.id): item for item in items_result.scalars().all()}
|
||
for evidence in evidence_links:
|
||
if evidence.change_item_id is None:
|
||
continue
|
||
evidence_ref = {
|
||
"id": f"evidence-{evidence.id}",
|
||
"sourceType": evidence.source_type,
|
||
"sourceId": evidence.source_id,
|
||
"title": evidence.title,
|
||
"summary": evidence.excerpt,
|
||
"displayLabel": evidence.display_label,
|
||
}
|
||
evidence_by_item.setdefault(str(evidence.change_item_id), []).append(evidence_ref)
|
||
change_item = change_items_by_id.get(int(evidence.change_item_id))
|
||
if change_item is not None and change_item.object_id:
|
||
evidence_by_item.setdefault(str(change_item.object_id), []).append(evidence_ref)
|
||
return evidence_by_item
|
||
|
||
def _memory_item_from_truth_node(self, node: TruthNode, *, evidence_by_item: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
||
content = self._payload(node.content)
|
||
node_type = self._node_type(node)
|
||
category = self._memory_category_from_truth_type(node_type)
|
||
return {
|
||
"id": f"truth-node-{node.id}",
|
||
"objectId": str(node.id),
|
||
"objectType": node_type or "truth_node",
|
||
"category": category,
|
||
"title": node.title,
|
||
"summary": self._node_summary(node),
|
||
"status": self._truth_status(node.status),
|
||
"stage": self._stage_from_payload(content, default="planning"),
|
||
"riskLevel": self._risk_level(content.get("level") or content.get("risk_level") or node.status) if category in {"risk", "constraint"} else None,
|
||
"sourceType": "truth_nodes",
|
||
"sourceId": str(node.id),
|
||
"confidence": content.get("confidence"),
|
||
"tags": [node_type] + self._list_from_payload(content, "tags", "labels", "keywords"),
|
||
"evidenceRefs": evidence_by_item.get(str(node.id), []),
|
||
"impactRefs": self._memory_impact_refs(category=category),
|
||
"createdAt": node.created_at.isoformat() if node.created_at else None,
|
||
"updatedAt": node.updated_at.isoformat() if node.updated_at else None,
|
||
"metadata": {
|
||
"version": node.version,
|
||
"rawStatus": node.status,
|
||
"truthNodeType": node.node_type,
|
||
"content": self._jsonable(content),
|
||
},
|
||
}
|
||
|
||
def _memory_item_from_task(self, task: Task, *, user: Optional[User]) -> dict[str, Any]:
|
||
return {
|
||
"id": f"task-{task.id}",
|
||
"objectId": str(task.id),
|
||
"objectType": "task",
|
||
"category": "task",
|
||
"title": task.title,
|
||
"summary": task.description or "",
|
||
"status": self._object_status(task.status),
|
||
"stage": self._task_stage(task.status),
|
||
"riskLevel": None,
|
||
"sourceType": "tasks",
|
||
"sourceId": str(task.id),
|
||
"confidence": self._jsonable(task.quality_score) if task.quality_score is not None else None,
|
||
"tags": ["task", task.priority],
|
||
"evidenceRefs": [],
|
||
"impactRefs": [{"type": "truth_node", "id": str(task.truth_node_id), "label": "关联真相节点"}],
|
||
"createdAt": task.created_at.isoformat() if task.created_at else None,
|
||
"updatedAt": task.updated_at.isoformat() if task.updated_at else None,
|
||
"metadata": {
|
||
"assignee": user.name if user is not None else "未分配",
|
||
"priority": task.priority,
|
||
"dueDate": task.due_date.isoformat() if task.due_date else None,
|
||
"completedAt": task.completed_at.isoformat() if task.completed_at else None,
|
||
},
|
||
}
|
||
|
||
def _memory_item_from_semantic_fact(self, fact: SemanticFact) -> dict[str, Any]:
|
||
title = f"{fact.fact_namespace}.{fact.fact_key}" if fact.fact_namespace else fact.fact_key
|
||
return {
|
||
"id": f"semantic-fact-{fact.id}",
|
||
"objectId": str(fact.id),
|
||
"objectType": "semantic_fact",
|
||
"category": "fact",
|
||
"title": title,
|
||
"summary": fact.fact_value_text,
|
||
"status": "confirmed" if fact.confidence >= 0.7 else "draft",
|
||
"stage": "planning",
|
||
"riskLevel": None,
|
||
"sourceType": "semantic_facts",
|
||
"sourceId": str(fact.id),
|
||
"confidence": fact.confidence,
|
||
"tags": [fact.fact_namespace, fact.fact_key, fact.entity_type],
|
||
"evidenceRefs": [
|
||
{
|
||
"id": f"{fact.evidence_source_type or 'source'}-{fact.evidence_source_id}",
|
||
"sourceType": fact.evidence_source_type,
|
||
"sourceId": None if fact.evidence_source_id is None else str(fact.evidence_source_id),
|
||
"title": fact.evidence_source_type or "语义证据",
|
||
"summary": fact.evidence_excerpt or "",
|
||
"displayLabel": fact.evidence_source_type or "语义证据",
|
||
}
|
||
] if fact.evidence_source_type or fact.evidence_excerpt else [],
|
||
"impactRefs": [{"type": fact.entity_type, "id": str(fact.entity_id), "label": "语义绑定对象"}],
|
||
"createdAt": fact.created_at.isoformat() if fact.created_at else None,
|
||
"updatedAt": fact.updated_at.isoformat() if fact.updated_at else None,
|
||
"metadata": {
|
||
"entityType": fact.entity_type,
|
||
"entityId": str(fact.entity_id),
|
||
"valueType": fact.value_type,
|
||
"extractVersion": fact.extract_version,
|
||
"freshnessAt": fact.freshness_at,
|
||
},
|
||
}
|
||
|
||
def _memory_asset_from_evidence(self, evidence: PMFlowEvidenceLink) -> dict[str, Any]:
|
||
metadata = self._payload(evidence.evidence_metadata)
|
||
asset_type = self._memory_asset_type(evidence.source_type)
|
||
return {
|
||
"id": f"evidence-{evidence.id}",
|
||
"assetType": asset_type,
|
||
"sourceType": evidence.source_type,
|
||
"sourceId": evidence.source_id,
|
||
"title": evidence.title,
|
||
"summary": evidence.excerpt,
|
||
"status": "indexed",
|
||
"displayLabel": evidence.display_label,
|
||
"linkedItemId": f"change-item-{evidence.change_item_id}" if evidence.change_item_id is not None else None,
|
||
"changeSetId": str(evidence.change_set_id),
|
||
"createdAt": evidence.created_at.isoformat() if evidence.created_at else None,
|
||
"updatedAt": evidence.updated_at.isoformat() if evidence.updated_at else None,
|
||
"metadata": self._jsonable(metadata),
|
||
}
|
||
|
||
def _memory_asset_from_thread_message(self, message: PMFlowThreadMessage) -> dict[str, Any]:
|
||
return {
|
||
"id": f"thread-message-{message.id}",
|
||
"assetType": "conversation",
|
||
"sourceType": message.source,
|
||
"sourceId": str(message.id),
|
||
"title": "项目线程消息",
|
||
"summary": self._short_text(message.content),
|
||
"status": message.status,
|
||
"displayLabel": "对话",
|
||
"linkedItemId": None,
|
||
"changeSetId": None,
|
||
"createdAt": message.created_at.isoformat() if message.created_at else None,
|
||
"updatedAt": message.updated_at.isoformat() if message.updated_at else None,
|
||
"metadata": {
|
||
"threadId": str(message.thread_id),
|
||
"role": message.role,
|
||
"attachments": self._jsonable(message.attachments),
|
||
"messageMetadata": self._jsonable(message.message_metadata),
|
||
},
|
||
}
|
||
|
||
def _memory_context_pack(
|
||
self,
|
||
*,
|
||
key: str,
|
||
title: str,
|
||
summary: str,
|
||
usage: str,
|
||
item_ids: list[str],
|
||
asset_ids: list[str],
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"id": f"context-pack-{key}",
|
||
"key": key,
|
||
"title": title,
|
||
"summary": summary,
|
||
"usage": usage,
|
||
"status": "ready" if item_ids or asset_ids else "empty",
|
||
"itemCount": len(item_ids),
|
||
"assetCount": len(asset_ids),
|
||
"sourceItemIds": item_ids[:80],
|
||
"sourceAssetIds": asset_ids[:80],
|
||
"updatedAt": now_cn_naive().isoformat() if item_ids or asset_ids else None,
|
||
"warnings": [] if item_ids or asset_ids else ["context_pack_empty"],
|
||
}
|
||
|
||
def _memory_writeback_from_change_set(
|
||
self,
|
||
change_set: PMFlowChangeSet,
|
||
*,
|
||
items: list[PMFlowChangeItem],
|
||
committed_by: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
committed = [item for item in items if item.status == "committed"]
|
||
skipped = [item for item in items if item.status in {"deferred", "skipped"}]
|
||
pending = [item for item in items if item.status in {"pending_review", "accepted", "edited"}]
|
||
return {
|
||
"id": f"writeback-{change_set.id}",
|
||
"changeSetId": str(change_set.id),
|
||
"title": change_set.headline,
|
||
"summary": change_set.summary,
|
||
"status": change_set.status,
|
||
"committedAt": change_set.committed_at.isoformat() if change_set.committed_at else None,
|
||
"committedBy": committed_by,
|
||
"committedById": str(change_set.committed_by) if change_set.committed_by else None,
|
||
"createdAt": change_set.created_at.isoformat() if change_set.created_at else None,
|
||
"updatedAt": change_set.updated_at.isoformat() if change_set.updated_at else None,
|
||
"itemCounts": {
|
||
"total": len(items),
|
||
"committed": len(committed),
|
||
"skipped": len(skipped),
|
||
"pending": len(pending),
|
||
},
|
||
"items": [
|
||
{
|
||
"id": f"change-item-{item.id}",
|
||
"objectType": item.object_type,
|
||
"title": item.title,
|
||
"summary": item.summary,
|
||
"status": item.status,
|
||
"riskLevel": item.risk_level,
|
||
}
|
||
for item in items[:20]
|
||
],
|
||
"metadata": {
|
||
"extractionScope": change_set.extraction_scope,
|
||
"routingMeta": self._jsonable(change_set.routing_meta),
|
||
},
|
||
}
|
||
|
||
def _memory_writeback_from_execution_event(
|
||
self,
|
||
*,
|
||
event: ProgressEvent,
|
||
task: Task,
|
||
user: Optional[User],
|
||
) -> dict[str, Any]:
|
||
payload = self._payload(event.payload)
|
||
evidence_event_ids = [str(item) for item in payload.get("evidence_event_ids") or []]
|
||
committed_at = event.occurred_at.isoformat() if event.occurred_at else None
|
||
result = str(payload.get("result") or "completed")
|
||
return {
|
||
"id": f"execution-writeback-{event.id}",
|
||
"changeSetId": None,
|
||
"sourceType": "execution_memory_writeback",
|
||
"progressEventId": str(event.id),
|
||
"taskId": str(task.id),
|
||
"title": f"执行结果写回:{task.title}",
|
||
"summary": event.summary or "执行结果已写回项目记忆。",
|
||
"status": "committed" if result == "completed" else result,
|
||
"committedAt": committed_at,
|
||
"committedBy": user.name if user is not None else None,
|
||
"committedById": str(event.user_id) if event.user_id else None,
|
||
"createdAt": committed_at,
|
||
"updatedAt": committed_at,
|
||
"itemCounts": {
|
||
"total": 1,
|
||
"committed": 1 if result == "completed" else 0,
|
||
"skipped": 0,
|
||
"pending": 0,
|
||
},
|
||
"items": [
|
||
{
|
||
"id": f"task-{task.id}",
|
||
"objectType": "Task",
|
||
"title": task.title,
|
||
"summary": task.description,
|
||
"status": task.status,
|
||
"riskLevel": None,
|
||
}
|
||
],
|
||
"metadata": {
|
||
"protocolVersion": payload.get("protocol_version"),
|
||
"result": result,
|
||
"note": payload.get("note"),
|
||
"taskStatus": payload.get("task_status"),
|
||
"evidenceEventIds": evidence_event_ids,
|
||
"deliveryArtifacts": payload.get("delivery_artifacts") or {},
|
||
"artifactCompleteness": payload.get("artifact_completeness") or {},
|
||
"artifactReview": payload.get("artifact_review") or {},
|
||
"releaseGateStatus": payload.get("release_gate_status"),
|
||
"releaseReadinessProof": payload.get("release_readiness_proof") or {},
|
||
"writebackProof": payload.get("writeback_proof") or {},
|
||
"qualityContractId": payload.get("quality_contract_id"),
|
||
},
|
||
}
|
||
|
||
def _filter_memory_rows(
|
||
self,
|
||
rows: list[dict[str, Any]],
|
||
*,
|
||
category: Optional[str],
|
||
stage: Optional[str],
|
||
source_type: Optional[str],
|
||
status: Optional[str],
|
||
query: Optional[str],
|
||
) -> list[dict[str, Any]]:
|
||
normalized_category = str(category or "").strip()
|
||
normalized_stage = str(stage or "").strip()
|
||
normalized_source_type = str(source_type or "").strip()
|
||
normalized_status = str(status or "").strip()
|
||
normalized_query = str(query or "").strip().lower()
|
||
filtered = [
|
||
row
|
||
for row in rows
|
||
if not normalized_category or str(row.get("category") or row.get("assetType") or "") == normalized_category
|
||
]
|
||
if normalized_stage:
|
||
filtered = [row for row in filtered if str(row.get("stage") or "") == normalized_stage]
|
||
if normalized_status:
|
||
filtered = [row for row in filtered if str(row.get("status") or "") == normalized_status]
|
||
if normalized_source_type:
|
||
filtered = [row for row in filtered if self._memory_row_matches_source(row, normalized_source_type)]
|
||
if not normalized_query:
|
||
return filtered
|
||
return [
|
||
row
|
||
for row in filtered
|
||
if normalized_query in str(row.get("title") or "").lower()
|
||
or normalized_query in str(row.get("summary") or "").lower()
|
||
or normalized_query in " ".join(str(tag) for tag in row.get("tags") or []).lower()
|
||
]
|
||
|
||
def _memory_row_matches_source(self, row: dict[str, Any], source_type: str) -> bool:
|
||
expected = self._memory_source_bucket(source_type)
|
||
source_values = {str(row.get("sourceType") or "").strip().lower()}
|
||
for evidence in row.get("evidenceRefs") or []:
|
||
if isinstance(evidence, dict):
|
||
source_values.add(str(evidence.get("sourceType") or "").strip().lower())
|
||
source_values.add(str(evidence.get("displayLabel") or "").strip().lower())
|
||
return bool(source_values.intersection(expected))
|
||
|
||
def _memory_source_bucket(self, source_type: str) -> set[str]:
|
||
value = str(source_type or "").strip().lower()
|
||
buckets = {
|
||
"conversation": {"conversation", "thread", "message", "web", "feishu", "对话"},
|
||
"document": {"document", "doc", "file", "upload", "文档"},
|
||
"pr": {"pr", "pull_request", "commit", "github", "code", "代码"},
|
||
"test": {"test", "test_log", "validation", "测试"},
|
||
"external": {"external", "research", "web_search", "url", "外部资料"},
|
||
"ai_extraction": {"ai_extraction", "change_review", "semantic_facts", "truth_nodes", "ai 提取", "ai提取"},
|
||
}
|
||
return buckets.get(value, {value})
|
||
|
||
def _memory_metric_counts(
|
||
self,
|
||
*,
|
||
items: list[dict[str, Any]],
|
||
assets: list[dict[str, Any]],
|
||
context_packs: list[dict[str, Any]],
|
||
) -> dict[str, int]:
|
||
return {
|
||
"confirmed_facts": len([item for item in items if item.get("category") == "fact" and item.get("status") == "confirmed"]),
|
||
"requirements": len([item for item in items if item.get("category") == "requirement"]),
|
||
"decisions": len([item for item in items if item.get("category") == "decision"]),
|
||
"constraints": len([item for item in items if item.get("category") in {"constraint", "risk"}]),
|
||
"evidence": len(assets),
|
||
"context_packs": len([pack for pack in context_packs if pack.get("status") == "ready"]),
|
||
"assets": len(assets),
|
||
}
|
||
|
||
def _memory_ai_suggestions(
|
||
self,
|
||
*,
|
||
items: list[dict[str, Any]],
|
||
assets: list[dict[str, Any]],
|
||
context_packs: list[dict[str, Any]],
|
||
writebacks: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
suggestions: list[dict[str, Any]] = []
|
||
if any(item.get("category") == "requirement" for item in items):
|
||
suggestions.append({"key": "generate_tasks", "title": "生成开发任务与验收标准", "target": "execution-bridge"})
|
||
if any(item.get("category") in {"risk", "constraint"} for item in items):
|
||
suggestions.append({"key": "generate_test_plan", "title": "生成测试计划和风险检查", "target": "release-growth"})
|
||
if assets and context_packs:
|
||
suggestions.append({"key": "refresh_context_pack", "title": "组织下一轮项目上下文包", "target": "context-packs"})
|
||
if writebacks:
|
||
suggestions.append({"key": "inspect_writebacks", "title": "查看最近写入影响", "target": "writebacks"})
|
||
return suggestions[:4]
|
||
|
||
def _memory_category_from_truth_type(self, node_type: str) -> str:
|
||
if node_type in {"requirement", "charter", "scope"}:
|
||
return "requirement"
|
||
if node_type in {"decision", "model_decision"}:
|
||
return "decision"
|
||
if node_type == "risk":
|
||
return "risk"
|
||
if node_type in {"constraint", "compliance", "policy"}:
|
||
return "constraint"
|
||
if node_type in {"release"}:
|
||
return "release"
|
||
if node_type in {"metric", "signal"}:
|
||
return "metric"
|
||
if node_type in {"test", "test_run"}:
|
||
return "test"
|
||
return "fact"
|
||
|
||
def _memory_asset_type(self, source_type: str) -> str:
|
||
value = str(source_type or "").strip().lower()
|
||
if value in {"conversation", "thread", "message", "feishu"}:
|
||
return "conversation"
|
||
if value in {"pr", "pull_request", "commit", "github", "code"}:
|
||
return "code"
|
||
if value in {"test", "test_log", "validation"}:
|
||
return "test"
|
||
if value in {"feedback", "user_feedback"}:
|
||
return "feedback"
|
||
if value in {"metric", "analytics", "revenue", "roi"}:
|
||
return "metric"
|
||
if value in {"release", "delivery"}:
|
||
return "release"
|
||
if value in {"document", "doc", "file", "upload"}:
|
||
return "document"
|
||
return "evidence"
|
||
|
||
def _memory_impact_refs(self, *, category: str) -> list[dict[str, str]]:
|
||
mapping: dict[str, list[dict[str, str]]] = {
|
||
"requirement": [
|
||
{"type": "task", "id": "derived", "label": "任务拆解"},
|
||
{"type": "test", "id": "derived", "label": "测试计划"},
|
||
],
|
||
"decision": [{"type": "execution", "id": "derived", "label": "执行判断"}],
|
||
"risk": [{"type": "release_gate", "id": "derived", "label": "发布门禁"}],
|
||
"constraint": [{"type": "release_gate", "id": "derived", "label": "约束检查"}],
|
||
"fact": [{"type": "query_answer", "id": "derived", "label": "项目问答"}],
|
||
}
|
||
return mapping.get(category, [])
|
||
|
||
async def _project_memory(
|
||
self,
|
||
*,
|
||
project: Project,
|
||
truth_nodes: list[TruthNode],
|
||
task_ids: list[int],
|
||
) -> PMFlowProjectMemory:
|
||
truth_ids = [int(node.id) for node in truth_nodes]
|
||
clauses = [
|
||
and_(SemanticFact.entity_type == "project", SemanticFact.entity_id == int(project.id)),
|
||
]
|
||
if truth_ids:
|
||
clauses.append(and_(SemanticFact.entity_type.in_(["truth_node", "truth_nodes"]), SemanticFact.entity_id.in_(truth_ids)))
|
||
if task_ids:
|
||
clauses.append(and_(SemanticFact.entity_type == "task", SemanticFact.entity_id.in_(task_ids)))
|
||
result = await self.db.execute(
|
||
select(SemanticFact)
|
||
.where(and_(SemanticFact.tenant_id == self.tenant_id, SemanticFact.is_active.is_(True), or_(*clauses)))
|
||
.order_by(SemanticFact.updated_at.desc(), SemanticFact.created_at.desc())
|
||
.limit(100)
|
||
)
|
||
semantic_facts = [
|
||
{
|
||
"id": str(fact.id),
|
||
"source": "semantic_facts",
|
||
"entityType": fact.entity_type,
|
||
"entityId": str(fact.entity_id),
|
||
"namespace": fact.fact_namespace,
|
||
"key": fact.fact_key,
|
||
"value": fact.fact_value_text,
|
||
"confidence": fact.confidence,
|
||
"evidenceSourceType": fact.evidence_source_type,
|
||
"evidenceSourceId": None if fact.evidence_source_id is None else str(fact.evidence_source_id),
|
||
"evidenceExcerpt": fact.evidence_excerpt,
|
||
}
|
||
for fact in result.scalars().all()
|
||
]
|
||
truth_facts = [
|
||
{
|
||
"id": str(node.id),
|
||
"source": "truth_nodes",
|
||
"nodeType": node.node_type,
|
||
"title": node.title,
|
||
"summary": self._node_summary(node),
|
||
"status": node.status,
|
||
"updatedAt": node.updated_at.isoformat() if node.updated_at else None,
|
||
}
|
||
for node in truth_nodes
|
||
]
|
||
return PMFlowProjectMemory(
|
||
truthNodeFacts=truth_facts,
|
||
semanticFacts=semantic_facts,
|
||
sourceSummary={
|
||
"truth_nodes": len(truth_facts),
|
||
"semantic_facts": len(semantic_facts),
|
||
},
|
||
)
|
||
|
||
def _project_feed(
|
||
self,
|
||
*,
|
||
review_projection: dict[str, list[dict[str, Any]]],
|
||
reviewer_routing: dict[str, Any],
|
||
execution_summary: ExecutionSummaryProjection,
|
||
test_runs: list[PMFlowTestRun],
|
||
releases: list[PMFlowRelease],
|
||
metric_signals: list[PMFlowMetricSignal],
|
||
) -> list[PMFlowFeedEvent]:
|
||
feed: list[PMFlowFeedEvent] = []
|
||
feed.extend(self._review_activity_feed(review_projection=review_projection, reviewer_routing=reviewer_routing))
|
||
for event in execution_summary.recentProgressEvents[:5]:
|
||
feed.append(
|
||
PMFlowFeedEvent(
|
||
id=f"progress-{event.get('id')}",
|
||
kind="execution",
|
||
title=self._progress_event_title(event),
|
||
summary=event.get("summary") or event.get("workflow_state") or "执行事件已回写",
|
||
timestamp=str(event.get("occurred_at") or ""),
|
||
)
|
||
)
|
||
release_gate_event = self._release_gate_feed_event(execution_summary.releaseGate)
|
||
if release_gate_event is not None:
|
||
feed.append(release_gate_event)
|
||
for run in test_runs[:3]:
|
||
feed.append(PMFlowFeedEvent(id=f"feed-{run.id}", kind="test", title=run.title, summary=run.summary, timestamp=""))
|
||
for release in releases[:3]:
|
||
feed.append(PMFlowFeedEvent(id=f"feed-{release.id}", kind="release", title=release.title, summary=release.summary, timestamp=""))
|
||
for signal in metric_signals[:3]:
|
||
feed.append(PMFlowFeedEvent(id=f"feed-{signal.id}", kind="growth", title=signal.title, summary=signal.summary, timestamp=""))
|
||
return sorted(feed, key=lambda item: item.timestamp or "", reverse=True)[:18]
|
||
|
||
def _review_activity_feed(
|
||
self,
|
||
*,
|
||
review_projection: dict[str, list[dict[str, Any]]],
|
||
reviewer_routing: dict[str, Any],
|
||
) -> list[PMFlowFeedEvent]:
|
||
feed: list[PMFlowFeedEvent] = []
|
||
routing_items = list(reviewer_routing.get("items") or [])
|
||
change_sets = list(review_projection.get("change_sets") or [])
|
||
change_items = list(review_projection.get("change_items") or [])
|
||
evidence = list(review_projection.get("evidence") or [])
|
||
pending_by_set: dict[str, int] = {}
|
||
evidence_by_set: dict[str, int] = {}
|
||
for item in change_items:
|
||
if item.get("status") == "pending_review":
|
||
pending_by_set[str(item.get("changeSetId") or "")] = pending_by_set.get(str(item.get("changeSetId") or ""), 0) + 1
|
||
for row in evidence:
|
||
change_set_id = str(row.get("changeSetId") or "")
|
||
evidence_by_set[change_set_id] = evidence_by_set.get(change_set_id, 0) + 1
|
||
|
||
for item in routing_items:
|
||
change_set_id = str(item.get("changeSetId") or "")
|
||
approval_state = str(item.get("approvalState") or "not_requested")
|
||
title = str(item.get("changeSetTitle") or "待审 ChangeSet")
|
||
reviewer = self._reviewer_display(item)
|
||
if approval_state == "requested":
|
||
feed.append(
|
||
PMFlowFeedEvent(
|
||
id=f"review-request-{change_set_id}",
|
||
kind="review",
|
||
title="已请求 ChangeSet 审查",
|
||
summary=f"{title} 已请求 {reviewer} 审查;{item.get('reviewReason') or '等待 reviewer 给出意见。'}",
|
||
timestamp=str(item.get("requestedAt") or item.get("updatedAt") or item.get("createdAt") or ""),
|
||
)
|
||
)
|
||
elif approval_state in {"approved", "changes_requested", "rejected"}:
|
||
feed.append(
|
||
PMFlowFeedEvent(
|
||
id=f"review-decision-{change_set_id}-{approval_state}",
|
||
kind="review",
|
||
title=self._review_decision_title(approval_state),
|
||
summary=f"{title}:{item.get('decisionReason') or self._review_decision_summary(approval_state)}",
|
||
timestamp=str(item.get("decidedAt") or item.get("updatedAt") or item.get("createdAt") or ""),
|
||
)
|
||
)
|
||
|
||
for change_set in change_sets[:10]:
|
||
change_set_id = str(change_set.get("id") or "")
|
||
if not change_set_id:
|
||
continue
|
||
status = str(change_set.get("rawStatus") or change_set.get("status") or "open")
|
||
feed.append(
|
||
PMFlowFeedEvent(
|
||
id=f"changeset-{change_set_id}-{status}",
|
||
kind="review",
|
||
title=self._change_set_status_title(status),
|
||
summary=self._change_set_status_summary(
|
||
change_set=change_set,
|
||
pending_count=pending_by_set.get(change_set_id, 0),
|
||
evidence_count=evidence_by_set.get(change_set_id, 0),
|
||
),
|
||
timestamp=str(change_set.get("committedAt") or change_set.get("createdAt") or ""),
|
||
)
|
||
)
|
||
return feed
|
||
|
||
@staticmethod
|
||
def _reviewer_display(item: dict[str, Any]) -> str:
|
||
reviewer = item.get("suggestedReviewer")
|
||
if isinstance(reviewer, dict):
|
||
display = str(reviewer.get("displayName") or reviewer.get("role") or "").strip()
|
||
if display:
|
||
return display
|
||
return str(item.get("fallbackReviewerRole") or "OWNER")
|
||
|
||
@staticmethod
|
||
def _review_decision_title(approval_state: str) -> str:
|
||
return {
|
||
"approved": "审查已通过",
|
||
"changes_requested": "审查要求修改",
|
||
"rejected": "审查已拒绝",
|
||
}.get(approval_state, "审查决策已记录")
|
||
|
||
@staticmethod
|
||
def _review_decision_summary(approval_state: str) -> str:
|
||
return {
|
||
"approved": "reviewer 已通过,仍需人工决定是否 commit。",
|
||
"changes_requested": "reviewer 要求修改,不能直接写入 Project Memory。",
|
||
"rejected": "reviewer 已拒绝,本轮不得写入 Project Memory。",
|
||
}.get(approval_state, "审查状态已更新。")
|
||
|
||
@staticmethod
|
||
def _change_set_status_title(status: str) -> str:
|
||
return {
|
||
"open": "ChangeSet 已创建",
|
||
"reviewed": "ChangeSet 已完成审查",
|
||
"committed": "ChangeSet 已写入项目真相层",
|
||
"partially_committed": "ChangeSet 已部分写入",
|
||
}.get(status, "ChangeSet 状态已更新")
|
||
|
||
@staticmethod
|
||
def _change_set_status_summary(*, change_set: dict[str, Any], pending_count: int, evidence_count: int) -> str:
|
||
title = str(change_set.get("headline") or change_set.get("summary") or "ChangeSet")
|
||
status = str(change_set.get("rawStatus") or change_set.get("status") or "open")
|
||
if status == "open":
|
||
return f"{title} 进入待确认审查;待审 {pending_count} 项,证据 {evidence_count} 条。"
|
||
if status == "reviewed":
|
||
return f"{title} 已完成审查;仍需人工决定是否进入 commit。"
|
||
if status == "committed":
|
||
return f"{title} 已写入 Project Memory;动态流仅展示结果,不触发写入。"
|
||
if status == "partially_committed":
|
||
return f"{title} 已部分写入 Project Memory;未写入项仍需回到 Change Review。"
|
||
return f"{title} 状态更新为 {status}。"
|
||
|
||
@staticmethod
|
||
def _progress_event_title(event: dict[str, Any]) -> str:
|
||
event_type = str(event.get("event_type") or "")
|
||
return {
|
||
"handoff_created": "执行交接已创建",
|
||
"handoff_consumed": "执行交接已领取",
|
||
"branch_created": "执行分支已创建",
|
||
"commit": "执行 commit 已回写",
|
||
"pr_opened": "PR 已打开",
|
||
"pr_synced": "PR 状态已同步",
|
||
"test_passed": "测试通过",
|
||
"test_failed": "测试失败",
|
||
"checks_passed": "检查通过",
|
||
"failed": "执行失败",
|
||
"blocked": "执行阻塞",
|
||
"merged": "PR 已合并",
|
||
"accepted": "执行结果已接受",
|
||
"execution_memory_writeback": "执行结果已写回项目记忆",
|
||
"manual_progress": "执行进展已记录",
|
||
}.get(event_type, event_type or "执行进展")
|
||
|
||
@staticmethod
|
||
def _release_gate_feed_event(release_gate: dict[str, Any]) -> Optional[PMFlowFeedEvent]:
|
||
status = str((release_gate or {}).get("status") or "empty")
|
||
if status == "empty":
|
||
return None
|
||
return PMFlowFeedEvent(
|
||
id=f"release-gate-{status}",
|
||
kind="release",
|
||
title={
|
||
"ready": "发布门禁已放行",
|
||
"blocked": "发布门禁已阻塞",
|
||
"needs_confirmation": "发布门禁需要确认",
|
||
}.get(status, "发布门禁已更新"),
|
||
summary=str((release_gate or {}).get("summary") or (release_gate or {}).get("title") or "发布门禁状态已更新。"),
|
||
timestamp=str((release_gate or {}).get("updatedAt") or ""),
|
||
)
|
||
|
||
def _project_goal(self, *, project: Project, truth_nodes: list[TruthNode]) -> str:
|
||
charter = next((node for node in truth_nodes if self._node_type(node) == "charter"), None)
|
||
if charter is not None:
|
||
content = self._payload(charter.content)
|
||
for key in ("business_goal", "goal", "objective", "summary"):
|
||
if content.get(key):
|
||
return self._short_text(content.get(key))
|
||
return project.description or project.title or project.name
|
||
|
||
def _infer_project_stage(
|
||
self,
|
||
*,
|
||
truth_nodes: list[TruthNode],
|
||
tasks: list[tuple[Task, Optional[User]]],
|
||
execution: PMFlowExecutionContext,
|
||
) -> str:
|
||
if any(self._node_type(node) == "release" for node in truth_nodes):
|
||
return "release"
|
||
if execution.status in {"coding", "pr_open", "testing", "synced"}:
|
||
return "development"
|
||
if any(str(task.status).lower() in {"in_progress", "review", "blocked"} for task, _ in tasks):
|
||
return "development"
|
||
if truth_nodes:
|
||
return "planning"
|
||
return "idea"
|
||
|
||
def _task_acceptance_criteria(self, task: Task) -> list[str]:
|
||
payloads = [self._payload(task.manual_overrides), self._payload(task.ai_breakdown_payload)]
|
||
for payload in payloads:
|
||
for key in ("acceptance_criteria", "acceptanceCriteria", "criteria"):
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return [str(item) for item in value if str(item).strip()]
|
||
return []
|
||
|
||
def _node_summary(self, node: TruthNode) -> str:
|
||
return self._short_text(node.content) or node.title
|
||
|
||
def _list_from_payload(self, payload: dict[str, Any], *keys: str) -> list[str]:
|
||
for key in keys:
|
||
value = payload.get(key)
|
||
if isinstance(value, list):
|
||
return [str(item) for item in value if str(item).strip()]
|
||
if isinstance(value, str) and value.strip():
|
||
return [value.strip()]
|
||
return []
|
||
|
||
def _node_type(self, node: TruthNode) -> str:
|
||
return str(node.node_type or "").strip().lower()
|
||
|
||
def _payload(self, value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
def _short_text(self, value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, str):
|
||
return value.strip()
|
||
if isinstance(value, (int, float, Decimal)):
|
||
return str(self._jsonable(value))
|
||
if isinstance(value, list):
|
||
return ";".join(str(item) for item in value[:5])
|
||
if isinstance(value, dict):
|
||
for key in ("summary", "description", "business_goal", "goal", "rationale", "reason", "conclusion", "content"):
|
||
if value.get(key):
|
||
return self._short_text(value.get(key))
|
||
pairs = []
|
||
for key, item in list(value.items())[:5]:
|
||
if isinstance(item, (dict, list)):
|
||
continue
|
||
pairs.append(f"{key}: {item}")
|
||
return ";".join(pairs)
|
||
return str(value)
|
||
|
||
def _stage_from_payload(self, payload: Any, *, default: str) -> str:
|
||
stage = str(self._payload(payload).get("stage") or default).strip().lower()
|
||
if stage in {"idea", "validation", "planning", "development", "testing", "release", "growth"}:
|
||
return stage
|
||
return default
|
||
|
||
def _truth_status(self, status: str) -> str:
|
||
value = str(status or "").strip().lower()
|
||
if value in {"aligned", "confirmed", "active", "verified", "approved"}:
|
||
return "confirmed"
|
||
if value in {"in_progress", "review", "aligning", "running"}:
|
||
return "in_progress"
|
||
if value in {"blocked", "failed"}:
|
||
return "blocked"
|
||
if value in {"done", "completed"}:
|
||
return "done"
|
||
if value in {"released", "live", "online"}:
|
||
return "released"
|
||
return "draft"
|
||
|
||
def _object_status(self, status: str) -> str:
|
||
value = str(status or "").strip().lower()
|
||
if value in {"done", "completed", "closed"}:
|
||
return "done"
|
||
if value in {"in_progress", "review", "running"}:
|
||
return "in_progress"
|
||
if value in {"blocked", "failed"}:
|
||
return "blocked"
|
||
if value in {"released", "live", "online"}:
|
||
return "released"
|
||
if value in {"confirmed", "aligned", "active"}:
|
||
return "confirmed"
|
||
return "draft"
|
||
|
||
def _task_stage(self, status: str) -> str:
|
||
value = str(status or "").strip().lower()
|
||
if value in {"done", "completed", "review"}:
|
||
return "testing"
|
||
return "development"
|
||
|
||
def _risk_level(self, value: Any) -> str:
|
||
text = str(value or "").strip().lower()
|
||
if text in {"high", "高", "critical", "urgent"}:
|
||
return "high"
|
||
if text in {"low", "低"}:
|
||
return "low"
|
||
return "medium"
|
||
|
||
def _release_status(self, node_status: str, validation_status: Optional[str]) -> str:
|
||
value = str(validation_status or node_status or "").strip().lower()
|
||
if value in {"live", "online", "released", "verified"}:
|
||
return "live"
|
||
if value in {"releasing", "shipping"}:
|
||
return "shipping"
|
||
if value in {"ready", "passed", "success", "done", "completed"}:
|
||
return "ready"
|
||
if value in {"watch", "observing"}:
|
||
return "watch"
|
||
return "check_pending"
|
||
|
||
def _release_gate_items(self, content: dict[str, Any], validation: Optional[ReleaseValidationRun]) -> list[dict[str, Any]]:
|
||
source = []
|
||
if validation is not None:
|
||
source = list(validation.risk_items or [])
|
||
summary = self._payload(validation.validation_summary)
|
||
hits = summary.get("success_criteria_hits")
|
||
if isinstance(hits, list):
|
||
source = hits
|
||
if not source:
|
||
source = list(content.get("gate_items") or content.get("verification_items") or [])
|
||
items = []
|
||
for item in source:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
label = str(item.get("label") or item.get("title") or item.get("name") or item.get("criterion") or "发布检查项")
|
||
passed = bool(item.get("passed") or item.get("hit") or item.get("status") in {"done", "passed", "success"})
|
||
items.append({"label": label, "passed": passed})
|
||
return items
|
||
|
||
def _test_result(self, status: Any, payload: Any) -> Optional[str]:
|
||
text = f"{status} {self._short_text(payload)}".lower()
|
||
if any(token in text for token in ("passed", "pass", "success", "done", "completed", "通过")):
|
||
return "passed"
|
||
if any(token in text for token in ("failed", "fail", "blocked", "error", "失败", "阻塞")):
|
||
return "failed"
|
||
return None
|
||
|
||
def _map_execution_status(self, *, task_status: Optional[str], event: Optional[dict[str, Any]]) -> str:
|
||
event_type = str((event or {}).get("event_type") or "").lower()
|
||
workflow_state = str((event or {}).get("workflow_state") or "").lower()
|
||
derived = str((event or {}).get("derived_task_status") or task_status or "").lower()
|
||
combined = " ".join([event_type, workflow_state, derived])
|
||
if "failed" in combined or "blocked" in combined:
|
||
return "failed"
|
||
if "merged" in combined or "accepted" in combined or "done" in combined or "completed" in combined or "synced" in combined:
|
||
return "synced"
|
||
if "test" in combined:
|
||
return "testing"
|
||
if "pull_request" in combined or "pr" in combined or "review" in combined:
|
||
return "pr_open"
|
||
if "commit" in combined or "coding" in combined or "in_progress" in combined:
|
||
return "coding"
|
||
if "branch" in combined or "pushed" in combined or "queued" in combined:
|
||
return "pushed"
|
||
return "queued"
|
||
|
||
def _pick_execution_task_id(
|
||
self,
|
||
*,
|
||
active_tasks: list[dict[str, Any]],
|
||
latest_package: Optional[PromptPackage],
|
||
events: list[dict[str, Any]],
|
||
) -> Optional[str]:
|
||
if latest_package is not None and latest_package.task_id is not None:
|
||
return str(latest_package.task_id)
|
||
if active_tasks:
|
||
return str(active_tasks[0].get("id"))
|
||
if events:
|
||
task = events[0].get("task")
|
||
if isinstance(task, dict) and task.get("id"):
|
||
return str(task.get("id"))
|
||
return None
|
||
|
||
def _pick_task_status(self, active_tasks: list[dict[str, Any]], task_id: Optional[str]) -> Optional[str]:
|
||
if task_id is None:
|
||
return None
|
||
for task in active_tasks:
|
||
if str(task.get("id")) == str(task_id):
|
||
return str(task.get("status") or "")
|
||
return None
|
||
|
||
def _event_commit_log(self, events: list[dict[str, Any]]) -> list[str]:
|
||
logs = []
|
||
for event in events[:8]:
|
||
commit = str(event.get("commit_sha") or "").strip()
|
||
summary = str(event.get("summary") or event.get("event_type") or "").strip()
|
||
if commit:
|
||
logs.append(f"{summary} · {commit[:12]}")
|
||
elif summary:
|
||
logs.append(summary)
|
||
return logs
|
||
|
||
def _project_pr_status(self, *, active_tasks: list[dict[str, Any]], task_id: Optional[str], events: list[dict[str, Any]]) -> str:
|
||
for task in active_tasks:
|
||
if task_id is not None and str(task.get("id")) != str(task_id):
|
||
continue
|
||
code_link = task.get("code_link")
|
||
if isinstance(code_link, dict) and code_link.get("pr_number"):
|
||
return f"PR #{code_link.get('pr_number')} · {code_link.get('status') or 'linked'}"
|
||
for event in events:
|
||
pr_number = event.get("pr_number")
|
||
if pr_number:
|
||
return f"PR #{pr_number}"
|
||
return "pending"
|
||
|
||
def _project_test_summary(self, events: list[dict[str, Any]]) -> str:
|
||
for event in events:
|
||
event_type = str(event.get("event_type") or "").lower()
|
||
workflow_state = str(event.get("workflow_state") or "").lower()
|
||
if "test" in event_type or "check" in event_type or workflow_state == "testing":
|
||
return str(event.get("summary") or event.get("workflow_state") or "test event recorded")
|
||
return "pending"
|
||
|
||
def _project_writeback_status(self, event: Optional[dict[str, Any]]) -> str:
|
||
if event is None:
|
||
return "pending"
|
||
event_type = self._event_type(event)
|
||
if event_type == "execution_memory_writeback":
|
||
return "project_memory_written"
|
||
if event_type in {"accepted", "technical_review_approved", "merged"}:
|
||
return "pending_memory_writeback"
|
||
return str(event.get("workflow_state") or event.get("summary") or event.get("event_type") or "progress event recorded")
|
||
|
||
def _build_execution_acceptance(
|
||
self,
|
||
*,
|
||
events: list[dict[str, Any]],
|
||
quality_contract: dict[str, Any],
|
||
artifact_completeness: Optional[dict[str, Any]] = None,
|
||
artifact_review: Optional[dict[str, Any]] = None,
|
||
) -> dict[str, Any]:
|
||
failed_events = self._unresolved_failed_evidence_events(
|
||
events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
has_test_passed = self._acceptance_has_required_test_evidence(events)
|
||
has_accept = any(self._event_type(event) == "accepted" for event in events)
|
||
has_changes_requested = any(self._event_type(event) == "changes_requested" for event in events)
|
||
has_rejected = any(self._event_type(event) == "acceptance_rejected" for event in events)
|
||
has_technical_requested = any(self._event_type(event) == "technical_review_requested" for event in events)
|
||
has_technical_approved = any(self._event_type(event) == "technical_review_approved" for event in events)
|
||
technical_required = bool((quality_contract.get("acceptanceGate") or {}).get("technicalReviewRequired"))
|
||
blocking_reasons: list[str] = []
|
||
status = "waiting_execution"
|
||
required_action = "wait_for_execution_result"
|
||
if failed_events:
|
||
status = "execution_blocked"
|
||
required_action = "fix_and_retest"
|
||
blocking_reasons = [self._event_reason(event) for event in failed_events[:5]]
|
||
elif has_rejected:
|
||
status = "rejected"
|
||
required_action = "replan_or_create_fix_handoff"
|
||
elif has_changes_requested:
|
||
status = "changes_requested"
|
||
required_action = "create_fix_handoff"
|
||
elif has_technical_approved and not has_accept:
|
||
status = "technical_review_approved"
|
||
required_action = "accept_or_release_decision"
|
||
elif has_accept:
|
||
status = "accepted"
|
||
required_action = "enter_release_gate"
|
||
elif technical_required and has_test_passed and not has_technical_approved:
|
||
status = "pending_technical_review"
|
||
required_action = "approve_technical_review_or_request_changes"
|
||
if not has_technical_requested:
|
||
required_action = "request_technical_review"
|
||
elif has_test_passed:
|
||
status = "pending_user_acceptance"
|
||
required_action = "accept_or_request_changes"
|
||
elif events:
|
||
status = "waiting_test_evidence"
|
||
required_action = "run_required_tests"
|
||
blocking_reasons = ["缺少 test_passed 或 checks_passed 证据。"]
|
||
quality_summary = quality_contract.get("summary") if isinstance(quality_contract.get("summary"), dict) else {}
|
||
if int(quality_summary.get("releaseBlockingMissing") or 0) > 0 and status not in {"execution_blocked", "accepted"}:
|
||
blocking_reasons.extend(list(quality_contract.get("blockingReasons") or [])[:5])
|
||
return {
|
||
"status": status,
|
||
"requiredAction": required_action,
|
||
"reviewMode": "technical_review" if technical_required else "user_acceptance",
|
||
"reviewerRequired": technical_required,
|
||
"technicalReviewRequested": has_technical_requested,
|
||
"technicalReviewApproved": has_technical_approved,
|
||
"userAccepted": has_accept,
|
||
"canEnterReleaseGate": status == "accepted" or (status == "technical_review_approved" and not technical_required),
|
||
"blockingReasons": self._unique_strings(blocking_reasons),
|
||
"latestDecision": self._latest_acceptance_decision(events),
|
||
"evidenceProof": self._execution_evidence_proof(
|
||
events=events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
),
|
||
}
|
||
|
||
@staticmethod
|
||
def _acceptance_has_required_test_evidence(events: list[dict[str, Any]]) -> bool:
|
||
return any(PMFlowOSProjectionService._event_type(event) in {"test_passed", "checks_passed"} for event in events)
|
||
|
||
def _latest_acceptance_decision(self, events: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||
decision_types = {"accepted", "changes_requested", "acceptance_rejected", "technical_review_requested", "technical_review_approved"}
|
||
for event in events:
|
||
if self._event_type(event) in decision_types:
|
||
payload = self._payload(event.get("payload"))
|
||
return {
|
||
"eventId": str(event.get("id") or ""),
|
||
"eventType": self._event_type(event),
|
||
"decision": payload.get("decision") or self._event_type(event),
|
||
"summary": self._event_reason(event),
|
||
"occurredAt": event.get("occurred_at"),
|
||
}
|
||
return None
|
||
|
||
def _execution_acceptance_summary(self, *, decision: str, task: Task, note: Optional[str]) -> str:
|
||
label = {
|
||
"accept": "用户验收通过",
|
||
"request_changes": "要求修改",
|
||
"reject": "验收拒绝",
|
||
"request_technical_review": "请求技术审查",
|
||
"approve_technical_review": "技术审查通过",
|
||
}.get(decision, "验收决策")
|
||
suffix = f":{note}" if note else ""
|
||
return f"{label}:{task.title}{suffix}"
|
||
|
||
def _execution_memory_writeback_summary(self, *, task: Task, result: str, note: Optional[str]) -> str:
|
||
label = {
|
||
"completed": "执行结果已写回项目记忆",
|
||
"blocked": "执行阻塞已写回项目记忆",
|
||
"changes_requested": "执行返工要求已写回项目记忆",
|
||
}.get(result, "执行结果已写回项目记忆")
|
||
suffix = f":{note}" if note else ""
|
||
return f"{label}:{task.title}{suffix}"
|
||
|
||
def _execution_memory_writeback_task_payload(
|
||
self,
|
||
*,
|
||
task: Task,
|
||
existing: Optional[dict[str, Any]],
|
||
result: str,
|
||
evidence_event_ids: list[str],
|
||
delivery_artifacts: dict[str, Any],
|
||
artifact_completeness: dict[str, Any],
|
||
artifact_review: dict[str, Any],
|
||
release_gate: dict[str, Any],
|
||
writeback_proof: dict[str, Any],
|
||
note: Optional[str],
|
||
current_user_id: Optional[int],
|
||
occurred_at: datetime,
|
||
) -> dict[str, Any]:
|
||
payload = dict(existing or {})
|
||
history = list(payload.get("execution_memory_writebacks") or [])
|
||
history.append(
|
||
{
|
||
"protocol_version": "execution_memory_writeback_v1",
|
||
"result": result,
|
||
"task_id": str(task.id),
|
||
"task_status": task.status,
|
||
"evidence_event_ids": list(evidence_event_ids),
|
||
"delivery_artifacts": delivery_artifacts,
|
||
"artifact_completeness": artifact_completeness,
|
||
"artifact_review": artifact_review,
|
||
"release_gate_status": release_gate.get("status"),
|
||
"release_readiness_proof": release_gate.get("releaseReadinessProof") or {},
|
||
"writeback_proof": writeback_proof,
|
||
"note": note,
|
||
"written_by": None if current_user_id is None else int(current_user_id),
|
||
"written_at": occurred_at.isoformat(),
|
||
}
|
||
)
|
||
payload["execution_memory_writebacks"] = history[-20:]
|
||
payload["latest_execution_memory_writeback"] = history[-1]
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _latest_repo_binding_id(events: list[dict[str, Any]]) -> Optional[int]:
|
||
for event in events:
|
||
repo = event.get("repo")
|
||
if isinstance(repo, dict) and repo.get("id"):
|
||
try:
|
||
return int(str(repo.get("id")))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
def _delivery_artifacts_projection(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
||
artifact_events: list[dict[str, Any]] = []
|
||
merged = {
|
||
"changed_files": [],
|
||
"commands_run": [],
|
||
"test_outputs": [],
|
||
"screenshots": [],
|
||
"logs": [],
|
||
"risk_notes": [],
|
||
"review_checklist": [],
|
||
}
|
||
for event in events:
|
||
artifacts = self._event_delivery_artifacts(event)
|
||
if not artifacts:
|
||
continue
|
||
artifact_events.append(
|
||
{
|
||
"eventId": str(event.get("id") or ""),
|
||
"eventType": self._event_type(event),
|
||
"source": event.get("source"),
|
||
"summary": event.get("summary"),
|
||
"occurredAt": event.get("occurred_at"),
|
||
"deliveryId": event.get("delivery_id"),
|
||
"eventSourceId": event.get("event_source_id"),
|
||
}
|
||
)
|
||
for key in merged:
|
||
values = artifacts.get(key)
|
||
if isinstance(values, list):
|
||
merged[key].extend([item for item in values if isinstance(item, dict)])
|
||
capped = {key: values[:50] for key, values in merged.items()}
|
||
return {
|
||
"status": "active" if artifact_events else "empty",
|
||
"latestEvent": artifact_events[0] if artifact_events else None,
|
||
"events": artifact_events[:12],
|
||
"counts": {key: len(values) for key, values in merged.items()},
|
||
**capped,
|
||
}
|
||
|
||
def _artifact_completeness(
|
||
self,
|
||
*,
|
||
delivery_artifacts: dict[str, Any],
|
||
events: list[dict[str, Any]],
|
||
quality_contract: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if not delivery_artifacts or delivery_artifacts.get("status") == "empty":
|
||
return {
|
||
"status": "empty",
|
||
"summary": "还没有结构化交付证据包。",
|
||
"missing": ["delivery_artifacts"],
|
||
"blockingReasons": ["缺少 changed files、commands run、test outputs 等结构化交付证据。"],
|
||
"releaseBlockingMissing": 1,
|
||
}
|
||
changed_files = list(delivery_artifacts.get("changed_files") or [])
|
||
commands = list(delivery_artifacts.get("commands_run") or [])
|
||
tests = list(delivery_artifacts.get("test_outputs") or [])
|
||
screenshots = list(delivery_artifacts.get("screenshots") or [])
|
||
checklist = list(delivery_artifacts.get("review_checklist") or [])
|
||
effective_commands = self._latest_artifact_items_by_key(commands, keys=("command",))
|
||
effective_tests = self._latest_artifact_items_by_key(tests, keys=("name", "command"))
|
||
effective_checklist = self._latest_artifact_items_by_key(checklist, keys=("item",))
|
||
missing: list[str] = []
|
||
if not changed_files:
|
||
missing.append("changed_files")
|
||
if not commands:
|
||
missing.append("commands_run")
|
||
if not tests:
|
||
missing.append("test_outputs")
|
||
ui_changed = any(self._artifact_path_is_ui(str(item.get("path") or "")) for item in changed_files if isinstance(item, dict))
|
||
if ui_changed and not screenshots:
|
||
missing.append("screenshots_for_ui_change")
|
||
required_review = bool((quality_contract.get("acceptanceGate") or {}).get("technicalReviewRequired"))
|
||
if required_review and not checklist:
|
||
missing.append("review_checklist")
|
||
failed_items = [
|
||
str(item.get("command") or item.get("name") or item.get("item") or "artifact")
|
||
for item in [*effective_commands, *effective_tests, *effective_checklist]
|
||
if isinstance(item, dict) and str(item.get("status") or "").lower() in {"failed", "blocked"}
|
||
]
|
||
has_test_passed = any(self._event_type(event) in {"test_passed", "checks_passed"} for event in events)
|
||
if tests and not any(isinstance(item, dict) and str(item.get("status") or "").lower() == "passed" for item in effective_tests) and not has_test_passed:
|
||
missing.append("passed_test_output")
|
||
status = "blocked" if failed_items else ("ready" if not missing else "incomplete")
|
||
blocking = [f"交付证据失败:{item}" for item in failed_items[:5]]
|
||
if missing:
|
||
blocking.extend([self._artifact_missing_label(item) for item in missing])
|
||
return {
|
||
"status": status,
|
||
"summary": {
|
||
"ready": "结构化交付证据已满足发布前审查要求。",
|
||
"blocked": "结构化交付证据中存在失败或阻塞项。",
|
||
"incomplete": "结构化交付证据还不完整。",
|
||
}.get(status, "结构化交付证据待补齐。"),
|
||
"missing": self._unique_strings(missing),
|
||
"blockingReasons": self._unique_strings(blocking),
|
||
"releaseBlockingMissing": len(failed_items) + len(missing),
|
||
"counts": dict(delivery_artifacts.get("counts") or {}),
|
||
"uiChanged": ui_changed,
|
||
}
|
||
|
||
def _artifact_review_projection(
|
||
self,
|
||
*,
|
||
delivery_artifacts: dict[str, Any],
|
||
artifact_completeness: dict[str, Any],
|
||
events: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
status = str((artifact_completeness or {}).get("status") or "empty")
|
||
missing = [str(item) for item in list((artifact_completeness or {}).get("missing") or []) if str(item)]
|
||
items: list[dict[str, Any]] = []
|
||
for key in missing:
|
||
items.append(self._artifact_review_missing_item(key))
|
||
commands = self._latest_artifact_items_by_key(
|
||
[item for item in list((delivery_artifacts or {}).get("commands_run") or []) if isinstance(item, dict)],
|
||
keys=("command",),
|
||
)
|
||
tests = self._latest_artifact_items_by_key(
|
||
[item for item in list((delivery_artifacts or {}).get("test_outputs") or []) if isinstance(item, dict)],
|
||
keys=("name", "command"),
|
||
)
|
||
checklist = self._latest_artifact_items_by_key(
|
||
[item for item in list((delivery_artifacts or {}).get("review_checklist") or []) if isinstance(item, dict)],
|
||
keys=("item",),
|
||
)
|
||
for index, item in enumerate(commands):
|
||
item_status = str(item.get("status") or "").lower()
|
||
if item_status in {"failed", "blocked"}:
|
||
items.append(
|
||
self._artifact_review_failed_item(
|
||
item_id=f"failed_command_{index}",
|
||
kind="fix_failed_command",
|
||
title="修复失败命令",
|
||
subject=str(item.get("command") or "command"),
|
||
summary=str(item.get("summary") or "命令执行失败,需要修复后重新回写。"),
|
||
)
|
||
)
|
||
for index, item in enumerate(tests):
|
||
item_status = str(item.get("status") or "").lower()
|
||
if item_status in {"failed", "blocked"}:
|
||
items.append(
|
||
self._artifact_review_failed_item(
|
||
item_id=f"failed_test_{index}",
|
||
kind="fix_failed_test",
|
||
title="修复失败测试",
|
||
subject=str(item.get("name") or "test"),
|
||
summary=str(item.get("summary") or item.get("command") or "测试输出失败,需要修复后重新回写。"),
|
||
)
|
||
)
|
||
for index, item in enumerate(checklist):
|
||
item_status = str(item.get("status") or "").lower()
|
||
if item_status in {"failed", "blocked", "needs_review"}:
|
||
items.append(
|
||
self._artifact_review_failed_item(
|
||
item_id=f"review_checklist_{index}",
|
||
kind="resolve_review_checklist",
|
||
title="处理审查清单",
|
||
subject=str(item.get("item") or "review checklist"),
|
||
summary=str(item.get("summary") or "审查清单未通过或待复核,需要处理后重新回写。"),
|
||
severity="medium" if item_status == "needs_review" else "high",
|
||
)
|
||
)
|
||
unique_items: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for item in items:
|
||
item_id = str(item.get("id") or "")
|
||
if item_id in seen:
|
||
continue
|
||
seen.add(item_id)
|
||
unique_items.append(item)
|
||
history = self._artifact_review_action_history(events)
|
||
closure = self._artifact_review_closure(
|
||
delivery_artifacts=delivery_artifacts,
|
||
events=events,
|
||
open_items=unique_items,
|
||
)
|
||
review_status = "clear" if status == "ready" else ("blocked" if status == "blocked" else ("open" if unique_items else status))
|
||
return {
|
||
"status": review_status,
|
||
"summary": self._artifact_review_summary(status=review_status, item_count=len(unique_items)),
|
||
"items": unique_items[:30],
|
||
"openCount": len(unique_items),
|
||
"actionHistory": history,
|
||
"closure": closure,
|
||
"nextActions": self._artifact_review_next_actions(status=review_status, items=unique_items),
|
||
"boundary": "Artifact review only records review/rework intent and expected evidence. PMFlow does not execute commands or create formal tasks from these items.",
|
||
}
|
||
|
||
@staticmethod
|
||
def _latest_artifact_items_by_key(items: list[dict[str, Any]], *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
|
||
latest: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for index, item in enumerate(items):
|
||
identity = ""
|
||
for key in keys:
|
||
raw = str(item.get(key) or "").strip().lower()
|
||
if raw:
|
||
identity = f"{key}:{raw}"
|
||
break
|
||
if not identity:
|
||
identity = f"artifact_index:{index}"
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
latest.append(item)
|
||
return latest
|
||
|
||
def _artifact_review_missing_item(self, key: str) -> dict[str, Any]:
|
||
mapping = {
|
||
"delivery_artifacts": ("missing_delivery_artifacts", "补齐交付证据包", "运行端需要回写 changed files、commands run、test outputs。"),
|
||
"changed_files": ("missing_changed_files", "补 changed files", "需要列出本次交付涉及的新增、修改、删除或重命名文件。"),
|
||
"commands_run": ("missing_commands_run", "补 commands run", "需要回写实际运行过的检查、测试或构建命令。"),
|
||
"test_outputs": ("missing_test_outputs", "补 test outputs", "需要回写测试名称、通过/失败状态和摘要。"),
|
||
"screenshots_for_ui_change": ("missing_ui_screenshots", "补 UI 截图", "检测到前端/UI 改动,需要补充截图 artifact ref 或截图摘要。"),
|
||
"review_checklist": ("missing_review_checklist", "补 review checklist", "当前质量合同需要技术审查清单,必须补齐审查项状态。"),
|
||
"passed_test_output": ("missing_passed_test_output", "补通过测试输出", "已有测试输出但缺少 passed 状态,需要重新回写通过证据或保留失败并进入返工。"),
|
||
}
|
||
item_id, title, summary = mapping.get(key, (f"missing_{key}", self._artifact_missing_label(key), self._artifact_missing_label(key)))
|
||
return {
|
||
"id": item_id,
|
||
"kind": key,
|
||
"status": "open",
|
||
"severity": "medium" if key in {"review_checklist", "screenshots_for_ui_change"} else "high",
|
||
"title": title,
|
||
"summary": summary,
|
||
"recommendedAction": "重新回写结构化交付证据",
|
||
"suggestedEventType": "manual_progress",
|
||
"expectedEvidence": self._artifact_expected_evidence(key),
|
||
}
|
||
|
||
@staticmethod
|
||
def _artifact_review_failed_item(
|
||
*,
|
||
item_id: str,
|
||
kind: str,
|
||
title: str,
|
||
subject: str,
|
||
summary: str,
|
||
severity: str = "high",
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"id": item_id,
|
||
"kind": kind,
|
||
"status": "blocked",
|
||
"severity": severity,
|
||
"title": title,
|
||
"summary": f"{subject}:{summary}",
|
||
"recommendedAction": "修复后重新回写 test_passed/checks_passed 与 delivery_artifacts",
|
||
"suggestedEventType": "test_passed",
|
||
"expectedEvidence": ["commands_run.status=passed", "test_outputs.status=passed"],
|
||
}
|
||
|
||
@staticmethod
|
||
def _artifact_expected_evidence(key: str) -> list[str]:
|
||
return {
|
||
"delivery_artifacts": ["changed_files", "commands_run", "test_outputs"],
|
||
"changed_files": ["delivery_artifacts.changed_files[].path", "delivery_artifacts.changed_files[].summary"],
|
||
"commands_run": ["delivery_artifacts.commands_run[].command", "delivery_artifacts.commands_run[].status"],
|
||
"test_outputs": ["delivery_artifacts.test_outputs[].name", "delivery_artifacts.test_outputs[].status"],
|
||
"screenshots_for_ui_change": ["delivery_artifacts.screenshots[].artifact_ref 或 artifact_url"],
|
||
"review_checklist": ["delivery_artifacts.review_checklist[].item", "delivery_artifacts.review_checklist[].status"],
|
||
"passed_test_output": ["delivery_artifacts.test_outputs[].status=passed"],
|
||
}.get(key, [key])
|
||
|
||
def _artifact_review_action_history(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
history: list[dict[str, Any]] = []
|
||
for event in events:
|
||
event_type = self._event_type(event)
|
||
if event_type not in {"artifact_review_requested", "artifact_fix_recorded", "artifact_recheck_ready"}:
|
||
continue
|
||
payload = self._payload(event.get("artifact_review_action")) or self._payload(event.get("payload"))
|
||
history.append(
|
||
{
|
||
"eventId": str(event.get("id") or ""),
|
||
"eventType": event_type,
|
||
"action": payload.get("action"),
|
||
"summary": event.get("summary"),
|
||
"reviewItemIds": list(payload.get("review_item_ids") or []),
|
||
"occurredAt": event.get("occurred_at"),
|
||
}
|
||
)
|
||
return history[:12]
|
||
|
||
def _artifact_review_closure(
|
||
self,
|
||
*,
|
||
delivery_artifacts: dict[str, Any],
|
||
events: list[dict[str, Any]],
|
||
open_items: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
open_by_id = {str(item.get("id") or ""): item for item in open_items if str(item.get("id") or "")}
|
||
tracked: dict[str, dict[str, Any]] = {}
|
||
package_ids: set[str] = set()
|
||
for event in events:
|
||
if self._event_type(event) != "artifact_rework_package_created":
|
||
continue
|
||
payload = self._payload(event.get("artifact_rework_package")) or self._payload(event.get("payload"))
|
||
package_id = str(payload.get("prompt_package_id") or event.get("prompt_package_id") or "")
|
||
if package_id:
|
||
package_ids.add(package_id)
|
||
review_items = [item for item in list(payload.get("review_items") or []) if isinstance(item, dict)]
|
||
review_item_ids = [str(item) for item in list(payload.get("review_item_ids") or []) if str(item)]
|
||
for item_id in review_item_ids:
|
||
item = next((candidate for candidate in review_items if str(candidate.get("id") or "") == item_id), None)
|
||
if item is None:
|
||
item = {"id": item_id, "title": item_id, "summary": ""}
|
||
record = tracked.setdefault(
|
||
item_id,
|
||
{
|
||
"id": item_id,
|
||
"title": item.get("title") or item_id,
|
||
"kind": item.get("kind"),
|
||
"severity": item.get("severity"),
|
||
"expectedEvidence": list(item.get("expectedEvidence") or []),
|
||
"packageIds": [],
|
||
"firstRequestedAt": event.get("occurred_at"),
|
||
},
|
||
)
|
||
if package_id and package_id not in record["packageIds"]:
|
||
record["packageIds"].append(package_id)
|
||
|
||
closure_items: list[dict[str, Any]] = []
|
||
for item_id, record in tracked.items():
|
||
current_item = open_by_id.get(item_id)
|
||
resolved = current_item is None
|
||
closure_items.append(
|
||
{
|
||
**record,
|
||
"status": "resolved" if resolved else "open",
|
||
"summary": "已被最新交付证据覆盖。" if resolved else str(current_item.get("summary") or "仍未关闭。"),
|
||
"currentReviewItem": current_item,
|
||
"evidenceRefs": self._artifact_review_evidence_refs(record, delivery_artifacts=delivery_artifacts),
|
||
}
|
||
)
|
||
|
||
resolved_count = len([item for item in closure_items if item.get("status") == "resolved"])
|
||
open_count = len(closure_items) - resolved_count
|
||
if not closure_items:
|
||
status = "none"
|
||
summary = "尚未生成返工包。"
|
||
elif open_count == 0:
|
||
status = "all_resolved"
|
||
summary = f"返工包中的 {resolved_count} 个审查项已被最新证据覆盖。"
|
||
elif resolved_count:
|
||
status = "partial"
|
||
summary = f"返工审查项已关闭 {resolved_count} 个,仍有 {open_count} 个待处理。"
|
||
else:
|
||
status = "open"
|
||
summary = f"返工包中的 {open_count} 个审查项仍未关闭。"
|
||
return {
|
||
"status": status,
|
||
"summary": summary,
|
||
"resolvedCount": resolved_count,
|
||
"openCount": open_count,
|
||
"items": closure_items[:30],
|
||
"timeline": self._artifact_rework_timeline(events=events, package_ids=package_ids),
|
||
}
|
||
|
||
def _artifact_rework_timeline(self, *, events: list[dict[str, Any]], package_ids: set[str]) -> list[dict[str, Any]]:
|
||
timeline: list[dict[str, Any]] = []
|
||
for event in events:
|
||
event_type = self._event_type(event)
|
||
payload = (
|
||
self._payload(event.get("artifact_rework_package"))
|
||
or self._payload(event.get("runtime_execution_package_pull"))
|
||
or self._payload(event.get("payload"))
|
||
)
|
||
package_id = str(payload.get("prompt_package_id") or event.get("prompt_package_id") or "")
|
||
if event_type == "artifact_rework_package_created":
|
||
kind = "package_created"
|
||
elif event_type == "execution_package_pulled" and package_id in package_ids:
|
||
kind = "package_pulled"
|
||
elif package_id in package_ids and self._event_delivery_artifacts(event):
|
||
kind = "result_returned"
|
||
else:
|
||
continue
|
||
timeline.append(
|
||
{
|
||
"eventId": str(event.get("id") or ""),
|
||
"eventType": event_type,
|
||
"kind": kind,
|
||
"promptPackageId": package_id or None,
|
||
"summary": event.get("summary"),
|
||
"source": event.get("source"),
|
||
"occurredAt": event.get("occurred_at"),
|
||
}
|
||
)
|
||
return timeline[:12]
|
||
|
||
def _artifact_review_evidence_refs(
|
||
self,
|
||
item: dict[str, Any],
|
||
*,
|
||
delivery_artifacts: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
item_id = str(item.get("id") or "")
|
||
kind = str(item.get("kind") or "")
|
||
evidence: list[dict[str, Any]] = []
|
||
changed_files = [candidate for candidate in list(delivery_artifacts.get("changed_files") or []) if isinstance(candidate, dict)]
|
||
commands = self._latest_artifact_items_by_key(
|
||
[candidate for candidate in list(delivery_artifacts.get("commands_run") or []) if isinstance(candidate, dict)],
|
||
keys=("command",),
|
||
)
|
||
tests = self._latest_artifact_items_by_key(
|
||
[candidate for candidate in list(delivery_artifacts.get("test_outputs") or []) if isinstance(candidate, dict)],
|
||
keys=("name", "command"),
|
||
)
|
||
screenshots = [candidate for candidate in list(delivery_artifacts.get("screenshots") or []) if isinstance(candidate, dict)]
|
||
checklist = self._latest_artifact_items_by_key(
|
||
[candidate for candidate in list(delivery_artifacts.get("review_checklist") or []) if isinstance(candidate, dict)],
|
||
keys=("item",),
|
||
)
|
||
|
||
if item_id == "missing_changed_files" or kind == "changed_files":
|
||
evidence.extend(
|
||
{
|
||
"kind": "changed_file",
|
||
"label": str(candidate.get("path") or "changed file"),
|
||
"summary": candidate.get("summary"),
|
||
"status": candidate.get("change_type") or "changed",
|
||
}
|
||
for candidate in changed_files[:6]
|
||
)
|
||
if item_id == "missing_ui_screenshots" or kind == "screenshots_for_ui_change":
|
||
evidence.extend(
|
||
{
|
||
"kind": "screenshot",
|
||
"label": str(candidate.get("label") or candidate.get("artifact_ref") or candidate.get("artifact_url") or "UI screenshot"),
|
||
"summary": candidate.get("summary") or candidate.get("artifact_url"),
|
||
"status": "attached",
|
||
"artifactRef": candidate.get("artifact_ref"),
|
||
"artifactUrl": candidate.get("artifact_url"),
|
||
}
|
||
for candidate in screenshots[:6]
|
||
)
|
||
if item_id in {"missing_commands_run", "failed_command_0"} or "command" in item_id or kind in {"commands_run", "fix_failed_command"}:
|
||
evidence.extend(
|
||
{
|
||
"kind": "command",
|
||
"label": str(candidate.get("command") or "command"),
|
||
"summary": candidate.get("summary"),
|
||
"status": candidate.get("status") or "unknown",
|
||
}
|
||
for candidate in commands[:6]
|
||
if str(candidate.get("status") or "").lower() == "passed"
|
||
)
|
||
if item_id in {"missing_test_outputs", "missing_passed_test_output"} or "test" in item_id or kind in {"test_outputs", "fix_failed_test"}:
|
||
evidence.extend(
|
||
{
|
||
"kind": "test",
|
||
"label": str(candidate.get("name") or candidate.get("command") or "test"),
|
||
"summary": candidate.get("summary") or candidate.get("command"),
|
||
"status": candidate.get("status") or "unknown",
|
||
}
|
||
for candidate in tests[:6]
|
||
if str(candidate.get("status") or "").lower() == "passed"
|
||
)
|
||
if item_id.startswith("review_checklist") or kind in {"review_checklist", "resolve_review_checklist"}:
|
||
evidence.extend(
|
||
{
|
||
"kind": "review_checklist",
|
||
"label": str(candidate.get("item") or "review checklist"),
|
||
"summary": candidate.get("summary"),
|
||
"status": candidate.get("status") or "unknown",
|
||
}
|
||
for candidate in checklist[:6]
|
||
if str(candidate.get("status") or "").lower() == "passed"
|
||
)
|
||
return evidence[:10]
|
||
|
||
@staticmethod
|
||
def _artifact_review_summary(*, status: str, item_count: int) -> str:
|
||
if status == "clear":
|
||
return "交付证据审查已清空,当前证据满足发布前检查。"
|
||
if status == "blocked":
|
||
return f"交付证据存在 {item_count} 个阻塞项,需要修复后重新回写。"
|
||
if status == "open":
|
||
return f"交付证据还有 {item_count} 个待补齐项。"
|
||
if status == "empty":
|
||
return "尚未形成交付证据审查项。"
|
||
return "交付证据审查待处理。"
|
||
|
||
@staticmethod
|
||
def _artifact_review_next_actions(*, status: str, items: list[dict[str, Any]]) -> list[str]:
|
||
if status == "clear":
|
||
return ["继续进入验收、Project Memory 写回或发布验证。"]
|
||
if not items:
|
||
return ["等待运行端回写结构化交付证据。"]
|
||
actions = [str(item.get("recommendedAction") or "") for item in items if item.get("recommendedAction")]
|
||
return list(dict.fromkeys(actions))[:4]
|
||
|
||
@staticmethod
|
||
def _artifact_review_action_summary(*, action: str, items: list[dict[str, Any]], note: Optional[str]) -> str:
|
||
labels = {
|
||
"request_fix": "已要求补齐/修复交付证据",
|
||
"record_fix": "已记录交付证据修复说明",
|
||
"mark_recheck_ready": "交付证据已标记为待重新检查",
|
||
}
|
||
prefix = labels.get(action, "已记录交付证据审查动作")
|
||
item_label = "、".join(str(item.get("title") or item.get("id") or "") for item in items[:3] if item)
|
||
suffix = f":{item_label}" if item_label else ""
|
||
note_suffix = f";{note}" if note else ""
|
||
return f"{prefix}{suffix}{note_suffix}"
|
||
|
||
@staticmethod
|
||
def _artifact_rework_package_summary(*, task: Task, items: list[dict[str, Any]], note: Optional[str]) -> str:
|
||
titles = "、".join(str(item.get("title") or item.get("id") or "") for item in items[:3] if item)
|
||
suffix = f":{titles}" if titles else ""
|
||
note_suffix = f";{note}" if note else ""
|
||
return f"交付证据返工执行包已生成:{task.title}{suffix}{note_suffix}"
|
||
|
||
@staticmethod
|
||
def _artifact_path_is_ui(path: str) -> bool:
|
||
lowered = path.lower()
|
||
return any(token in lowered for token in ("frontend/", ".tsx", ".jsx", ".css", "tailwind", "ui/", "components/", "pages/"))
|
||
|
||
@staticmethod
|
||
def _artifact_missing_label(key: str) -> str:
|
||
return {
|
||
"delivery_artifacts": "缺少结构化交付证据包。",
|
||
"changed_files": "缺少 changed files。",
|
||
"commands_run": "缺少 commands run。",
|
||
"test_outputs": "缺少 test outputs。",
|
||
"screenshots_for_ui_change": "检测到前端/UI 改动但缺少截图证据。",
|
||
"review_checklist": "高风险执行缺少 review checklist。",
|
||
"passed_test_output": "缺少通过状态的测试输出。",
|
||
}.get(key, key)
|
||
|
||
def _unresolved_failed_evidence_events(
|
||
self,
|
||
events: list[dict[str, Any]],
|
||
*,
|
||
artifact_completeness: Optional[dict[str, Any]] = None,
|
||
artifact_review: Optional[dict[str, Any]] = None,
|
||
) -> list[dict[str, Any]]:
|
||
failed_events = [event for event in events if self._is_failed_evidence_event(event)]
|
||
if not failed_events:
|
||
return []
|
||
artifact_status = str((artifact_completeness or {}).get("status") or "")
|
||
closure = self._payload((artifact_review or {}).get("closure") if artifact_review else {})
|
||
closure_status = str(closure.get("status") or "")
|
||
if artifact_status == "ready" and closure_status in {"", "none", "all_resolved"}:
|
||
return [event for event in failed_events if self._event_type(event) in {"failed", "blocked", "test_failed"}]
|
||
return failed_events
|
||
|
||
def _execution_evidence_proof(
|
||
self,
|
||
*,
|
||
events: list[dict[str, Any]],
|
||
artifact_completeness: Optional[dict[str, Any]] = None,
|
||
artifact_review: Optional[dict[str, Any]] = None,
|
||
evidence_event_ids: Optional[list[str]] = None,
|
||
) -> dict[str, Any]:
|
||
selected_ids = {str(item) for item in list(evidence_event_ids or []) if str(item)}
|
||
scoped_events = [event for event in events if not selected_ids or str(event.get("id") or "") in selected_ids]
|
||
closure = self._payload((artifact_review or {}).get("closure") if artifact_review else {})
|
||
closure_items = [item for item in list(closure.get("items") or []) if isinstance(item, dict)]
|
||
closure_evidence_refs = [
|
||
ref
|
||
for item in closure_items
|
||
for ref in list(item.get("evidenceRefs") or [])
|
||
if isinstance(ref, dict)
|
||
]
|
||
unresolved = self._unresolved_failed_evidence_events(
|
||
scoped_events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
artifact_status = str((artifact_completeness or {}).get("status") or "empty")
|
||
closure_status = str(closure.get("status") or "none")
|
||
has_tests = any(self._event_type(event) in {"test_passed", "checks_passed"} for event in scoped_events)
|
||
status = "ready" if artifact_status == "ready" and has_tests and not unresolved else "incomplete"
|
||
if unresolved:
|
||
status = "blocked"
|
||
return {
|
||
"status": status,
|
||
"summary": {
|
||
"ready": "执行证据、交付证据和返工闭环已满足复检输入。",
|
||
"blocked": "仍存在未关闭的失败或阻塞证据。",
|
||
"incomplete": "复检证据链尚未完整。",
|
||
}.get(status, "复检证据待确认。"),
|
||
"selectedEvidenceEventIds": sorted(selected_ids),
|
||
"testEvidenceCount": len([event for event in scoped_events if self._event_type(event) in {"test_passed", "checks_passed"}]),
|
||
"artifactStatus": artifact_status,
|
||
"artifactClosureStatus": closure_status,
|
||
"resolvedReworkItems": int(closure.get("resolvedCount") or 0),
|
||
"openReworkItems": int(closure.get("openCount") or 0),
|
||
"unresolvedFailureEventIds": [str(event.get("id") or "") for event in unresolved[:10]],
|
||
"evidenceRefs": closure_evidence_refs[:20],
|
||
"timeline": list(closure.get("timeline") or [])[:12],
|
||
}
|
||
|
||
def _release_readiness_proof(
|
||
self,
|
||
*,
|
||
events: list[dict[str, Any]],
|
||
acceptance: Optional[dict[str, Any]],
|
||
artifact_completeness: Optional[dict[str, Any]],
|
||
artifact_review: Optional[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
base = self._execution_evidence_proof(
|
||
events=events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
has_memory_writeback = any(self._event_type(event) == "execution_memory_writeback" for event in events)
|
||
can_enter_release_gate = bool((acceptance or {}).get("canEnterReleaseGate"))
|
||
if base.get("status") == "blocked":
|
||
status = "blocked"
|
||
elif base.get("status") == "ready" and can_enter_release_gate and has_memory_writeback:
|
||
status = "ready"
|
||
elif base.get("status") == "ready" and can_enter_release_gate:
|
||
status = "awaiting_memory_writeback"
|
||
elif base.get("status") == "ready":
|
||
status = "awaiting_acceptance"
|
||
else:
|
||
status = str(base.get("status") or "incomplete")
|
||
return {
|
||
**base,
|
||
"status": status,
|
||
"acceptanceStatus": (acceptance or {}).get("status"),
|
||
"canEnterReleaseGate": can_enter_release_gate,
|
||
"memoryWritebackDone": has_memory_writeback,
|
||
"nextRequiredHumanAction": {
|
||
"ready": "负责人可进行最终发布复检;PMFlow 不执行发布。",
|
||
"awaiting_memory_writeback": "先在 Execution Bridge 写回项目记忆。",
|
||
"awaiting_acceptance": "先完成用户验收或技术审查。",
|
||
"blocked": "先修复未关闭失败证据。",
|
||
}.get(status, "补齐执行、测试和交付证据。"),
|
||
}
|
||
|
||
def _build_release_gate(
|
||
self,
|
||
*,
|
||
events: list[dict[str, Any]],
|
||
deliveries: list[dict[str, Any]],
|
||
acceptance: Optional[dict[str, Any]] = None,
|
||
artifact_completeness: Optional[dict[str, Any]] = None,
|
||
artifact_review: Optional[dict[str, Any]] = None,
|
||
) -> dict[str, Any]:
|
||
evidence_events = [event for event in events if self._is_release_evidence_event(event)]
|
||
release_readiness_proof = self._release_readiness_proof(
|
||
events=evidence_events,
|
||
acceptance=acceptance,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
if not evidence_events:
|
||
return {
|
||
"status": "empty",
|
||
"title": "缺少执行证据",
|
||
"summary": "还没有 branch、commit、PR、test 或 failed 回写,发布门禁不能生成判断。",
|
||
"blockingReasons": [],
|
||
"nextActions": ["先从 Execution Bridge 回写 branch / commit / PR / test 事件。"],
|
||
"checks": [
|
||
{"key": "execution_evidence", "label": "执行证据", "status": "missing", "detail": "progress_events_empty"},
|
||
],
|
||
"evidence": [],
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": None,
|
||
}
|
||
|
||
failed_events = self._unresolved_failed_evidence_events(
|
||
evidence_events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
if failed_events:
|
||
return {
|
||
"status": "blocked",
|
||
"title": "发布阻塞",
|
||
"summary": "执行回写中存在失败或阻塞事件,发布门禁已关闭。",
|
||
"blockingReasons": [self._event_reason(event) for event in failed_events[:5]],
|
||
"nextActions": [
|
||
"先修复失败事件对应的任务或测试。",
|
||
"修复后重新回写 test_passed 或 checks_passed 证据。",
|
||
],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
artifact_status = str((artifact_completeness or {}).get("status") or "empty")
|
||
artifact_reasons = list((artifact_completeness or {}).get("blockingReasons") or [])
|
||
if artifact_status == "blocked":
|
||
return {
|
||
"status": "blocked",
|
||
"title": "发布阻塞",
|
||
"summary": "结构化交付证据中存在失败或阻塞项,发布门禁已关闭。",
|
||
"blockingReasons": artifact_reasons,
|
||
"nextActions": ["先修复失败的命令、测试或审查项,再重新回写执行结果。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
has_test_passed = any(self._event_type(event) in {"test_passed", "checks_passed"} for event in evidence_events)
|
||
has_pr = any(self._event_type(event) in {"pr_opened", "pr_synced", "merged", "accepted"} or event.get("pr_number") for event in evidence_events)
|
||
has_synced = any(self._event_type(event) in {"merged", "accepted"} or "synced" in self._event_text(event) for event in evidence_events)
|
||
has_memory_writeback = any(self._event_type(event) == "execution_memory_writeback" for event in evidence_events)
|
||
acceptance_status = str((acceptance or {}).get("status") or "")
|
||
can_enter_release_gate = bool((acceptance or {}).get("canEnterReleaseGate"))
|
||
if has_test_passed and not can_enter_release_gate:
|
||
return {
|
||
"status": "needs_confirmation",
|
||
"title": "等待验收或技术审查",
|
||
"summary": "测试证据已回流,但执行结果尚未通过用户验收或必要技术审查,发布门禁保持待确认。",
|
||
"blockingReasons": list((acceptance or {}).get("blockingReasons") or []),
|
||
"nextActions": ["先在 Execution Bridge 完成用户验收或技术审查。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review)
|
||
+ [
|
||
{
|
||
"key": "acceptance",
|
||
"label": "验收/审查",
|
||
"status": "missing",
|
||
"detail": acceptance_status or "pending_acceptance",
|
||
}
|
||
],
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
if has_test_passed and can_enter_release_gate and not has_memory_writeback:
|
||
return {
|
||
"status": "needs_confirmation",
|
||
"title": "等待写回项目记忆",
|
||
"summary": "执行结果已通过验收,但尚未把最终证据写回项目记忆,发布门禁保持待确认。",
|
||
"blockingReasons": [],
|
||
"nextActions": ["先在 Execution Bridge 执行“写回项目记忆”。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review)
|
||
+ [
|
||
{
|
||
"key": "memory_writeback",
|
||
"label": "项目记忆写回",
|
||
"status": "missing",
|
||
"detail": "execution_memory_writeback_required",
|
||
}
|
||
],
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
failed_deliveries = [item for item in deliveries if str(item.get("status") or "").lower() == "failed"]
|
||
if failed_deliveries:
|
||
return {
|
||
"status": "blocked",
|
||
"title": "发布阻塞",
|
||
"summary": "GitHub delivery 存在失败记录,发布前需要先确认回写链路。",
|
||
"blockingReasons": [
|
||
str(item.get("failure_reason") or item.get("delivery_id") or "GitHub delivery failed")
|
||
for item in failed_deliveries[:5]
|
||
],
|
||
"nextActions": ["检查 GitHub webhook delivery 失败原因。", "恢复回写链路后重新生成发布判断。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
if has_test_passed and has_synced and has_memory_writeback and artifact_status != "ready":
|
||
return {
|
||
"status": "needs_confirmation",
|
||
"title": "等待补齐交付证据",
|
||
"summary": "执行结果已通过验收并写回项目记忆,但结构化交付证据仍不完整,发布门禁保持待确认。",
|
||
"blockingReasons": artifact_reasons,
|
||
"nextActions": ["补齐 changed files、commands run、test outputs;如有 UI 改动补充截图证据。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
if has_test_passed and has_synced and has_memory_writeback:
|
||
return {
|
||
"status": "ready",
|
||
"title": "可发布",
|
||
"summary": "已有 PR/合并、通过测试、用户验收与项目记忆写回证据,发布门禁给出可发布判断。",
|
||
"blockingReasons": [],
|
||
"nextActions": ["由负责人进行最终人工确认;PMFlow 不自动执行发布命令。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
if has_test_passed:
|
||
return {
|
||
"status": "needs_confirmation",
|
||
"title": "发布候选,需人工确认",
|
||
"summary": "测试已通过,但仍需要负责人确认 PR 状态、发布范围和回滚预案。",
|
||
"blockingReasons": [],
|
||
"nextActions": [
|
||
"确认 PR 是否已审查或合并。",
|
||
"确认本次发布范围与回滚方案后再进入发布。",
|
||
],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
return {
|
||
"status": "needs_confirmation",
|
||
"title": "需要人工确认",
|
||
"summary": "已有执行回写,但缺少通过测试证据,不能直接给出可发布判断。",
|
||
"blockingReasons": ["缺少 test_passed 或 checks_passed 证据。"],
|
||
"nextActions": ["补充测试回写;若确需跳过测试,需要负责人显式确认。"],
|
||
"checks": self._release_gate_checks(evidence_events, artifact_completeness=artifact_completeness, artifact_review=artifact_review),
|
||
"evidence": self._release_gate_evidence(evidence_events),
|
||
"releaseReadinessProof": release_readiness_proof,
|
||
"updatedAt": str(evidence_events[0].get("occurred_at") or "") or None,
|
||
}
|
||
|
||
def _release_gate_checks(
|
||
self,
|
||
events: list[dict[str, Any]],
|
||
*,
|
||
artifact_completeness: Optional[dict[str, Any]] = None,
|
||
artifact_review: Optional[dict[str, Any]] = None,
|
||
) -> list[dict[str, Any]]:
|
||
unresolved_failures = self._unresolved_failed_evidence_events(
|
||
events,
|
||
artifact_completeness=artifact_completeness,
|
||
artifact_review=artifact_review,
|
||
)
|
||
checks = [
|
||
{
|
||
"key": "branch",
|
||
"label": "分支证据",
|
||
"status": "passed" if any(event.get("branch_name") or self._event_type(event) == "branch_created" for event in events) else "missing",
|
||
"detail": self._first_event_detail(events, {"branch_created", "commit", "pr_opened", "pr_synced"}),
|
||
},
|
||
{
|
||
"key": "commit",
|
||
"label": "提交证据",
|
||
"status": "passed" if any(event.get("commit_sha") or self._event_type(event) == "commit" for event in events) else "missing",
|
||
"detail": self._first_event_detail(events, {"commit"}),
|
||
},
|
||
{
|
||
"key": "pr",
|
||
"label": "PR 证据",
|
||
"status": "passed" if any(event.get("pr_number") or self._event_type(event) in {"pr_opened", "pr_synced", "merged", "accepted"} for event in events) else "missing",
|
||
"detail": self._first_event_detail(events, {"pr_opened", "pr_synced", "merged", "accepted"}),
|
||
},
|
||
{
|
||
"key": "test",
|
||
"label": "测试证据",
|
||
"status": "failed" if any(self._event_type(event) == "test_failed" for event in events) else ("passed" if any(self._event_type(event) in {"test_passed", "checks_passed"} for event in events) else "missing"),
|
||
"detail": self._first_event_detail(events, {"test_passed", "checks_passed", "test_failed"}),
|
||
},
|
||
{
|
||
"key": "failure",
|
||
"label": "失败事件",
|
||
"status": "failed" if unresolved_failures else "passed",
|
||
"detail": self._first_event_detail(events, {"failed", "blocked", "test_failed"}) or "未发现失败回写",
|
||
},
|
||
{
|
||
"key": "memory_writeback",
|
||
"label": "项目记忆写回",
|
||
"status": "passed" if any(self._event_type(event) == "execution_memory_writeback" for event in events) else "missing",
|
||
"detail": self._first_event_detail(events, {"execution_memory_writeback"}) or "等待执行结果写回项目记忆",
|
||
},
|
||
]
|
||
if artifact_completeness is not None:
|
||
status = str(artifact_completeness.get("status") or "empty")
|
||
checks.append(
|
||
{
|
||
"key": "delivery_artifacts",
|
||
"label": "交付证据包",
|
||
"status": "passed" if status == "ready" else ("failed" if status == "blocked" else "missing"),
|
||
"detail": artifact_completeness.get("summary") or "等待结构化交付证据",
|
||
}
|
||
)
|
||
return checks
|
||
|
||
def _release_gate_evidence(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
"id": str(event.get("id") or ""),
|
||
"type": self._event_type(event),
|
||
"summary": self._event_reason(event),
|
||
"taskTitle": (event.get("task") or {}).get("title") if isinstance(event.get("task"), dict) else None,
|
||
"repo": (event.get("repo") or {}).get("full_name") if isinstance(event.get("repo"), dict) else None,
|
||
"branchName": event.get("branch_name"),
|
||
"commitSha": event.get("commit_sha"),
|
||
"prNumber": event.get("pr_number"),
|
||
"occurredAt": event.get("occurred_at"),
|
||
}
|
||
for event in events[:12]
|
||
]
|
||
|
||
@staticmethod
|
||
def _event_type(event: dict[str, Any]) -> str:
|
||
return str(event.get("event_type") or "").strip().lower()
|
||
|
||
def _event_delivery_artifacts(self, event: dict[str, Any]) -> dict[str, Any]:
|
||
direct = event.get("delivery_artifacts")
|
||
if isinstance(direct, dict):
|
||
return direct
|
||
payload = self._payload(event.get("payload"))
|
||
payload_artifacts = payload.get("delivery_artifacts")
|
||
if isinstance(payload_artifacts, dict):
|
||
return payload_artifacts
|
||
execution_result = self._payload(payload.get("execution_result"))
|
||
result_artifacts = execution_result.get("delivery_artifacts")
|
||
return result_artifacts if isinstance(result_artifacts, dict) else {}
|
||
|
||
def _event_text(self, event: dict[str, Any]) -> str:
|
||
payload = self._payload(event.get("payload"))
|
||
execution_result = self._payload(payload.get("execution_result"))
|
||
artifacts = self._event_delivery_artifacts(event)
|
||
return " ".join(
|
||
[
|
||
self._event_type(event),
|
||
str(event.get("summary") or ""),
|
||
str(event.get("workflow_state") or ""),
|
||
str(event.get("derived_task_status") or ""),
|
||
str(payload.get("test_summary") or ""),
|
||
str(payload.get("blocker_summary") or ""),
|
||
str(execution_result.get("testSummary") or ""),
|
||
str(execution_result.get("blockerSummary") or ""),
|
||
self._short_text(artifacts),
|
||
]
|
||
).lower()
|
||
|
||
def _is_release_evidence_event(self, event: dict[str, Any]) -> bool:
|
||
event_type = self._event_type(event)
|
||
return event_type in {
|
||
"branch_created",
|
||
"commit",
|
||
"pr_opened",
|
||
"pr_synced",
|
||
"test_passed",
|
||
"test_failed",
|
||
"checks_passed",
|
||
"failed",
|
||
"blocked",
|
||
"merged",
|
||
"accepted",
|
||
"execution_memory_writeback",
|
||
} or bool(event.get("branch_name") or event.get("commit_sha") or event.get("pr_number"))
|
||
|
||
def _is_execution_result_evidence_event(self, event: dict[str, Any]) -> bool:
|
||
event_type = self._event_type(event)
|
||
return event_type in {
|
||
"branch_created",
|
||
"commit",
|
||
"pr_opened",
|
||
"pr_synced",
|
||
"test_passed",
|
||
"test_failed",
|
||
"checks_passed",
|
||
"failed",
|
||
"blocked",
|
||
"merged",
|
||
} or bool(event.get("branch_name") or event.get("commit_sha") or event.get("pr_number"))
|
||
|
||
def _is_failed_evidence_event(self, event: dict[str, Any]) -> bool:
|
||
text = self._event_text(event)
|
||
return any(token in text for token in ("test_failed", "failed", "blocked", "失败", "阻塞", "error"))
|
||
|
||
def _event_reason(self, event: dict[str, Any]) -> str:
|
||
return str(event.get("summary") or event.get("workflow_state") or event.get("event_type") or "执行证据").strip()
|
||
|
||
def _first_event_detail(self, events: list[dict[str, Any]], event_types: set[str]) -> str:
|
||
for event in events:
|
||
if self._event_type(event) in event_types:
|
||
return self._event_reason(event)
|
||
return ""
|
||
|
||
def _release_validation_status(self, *, gate_status: str, decision: str) -> str:
|
||
if decision == "keep_blocked" or gate_status == "blocked":
|
||
return "blocked"
|
||
if gate_status == "ready":
|
||
return "ready"
|
||
return "needs_confirmation"
|
||
|
||
def _release_validation_score(self, status: str) -> float:
|
||
if status == "ready":
|
||
return 1.0
|
||
if status == "needs_confirmation":
|
||
return 0.6
|
||
return 0.0
|
||
|
||
def _release_validation_metrics(self, release_gate: dict[str, Any]) -> dict[str, Any]:
|
||
checks = list(release_gate.get("checks") or [])
|
||
evidence = list(release_gate.get("evidence") or [])
|
||
return {
|
||
"releaseGateStatus": release_gate.get("status"),
|
||
"evidenceCount": len(evidence),
|
||
"passedCheckCount": len([item for item in checks if isinstance(item, dict) and item.get("status") == "passed"]),
|
||
"failedCheckCount": len([item for item in checks if isinstance(item, dict) and item.get("status") == "failed"]),
|
||
"missingCheckCount": len([item for item in checks if isinstance(item, dict) and item.get("status") == "missing"]),
|
||
}
|
||
|
||
def _release_validation_risks(self, release_gate: dict[str, Any], *, decision: str) -> list[dict[str, Any]]:
|
||
risks = [
|
||
{"label": reason, "status": "blocked", "source": "release_gate_blocking_reason"}
|
||
for reason in list(release_gate.get("blockingReasons") or [])
|
||
]
|
||
for check in list(release_gate.get("checks") or []):
|
||
if not isinstance(check, dict) or check.get("status") == "passed":
|
||
continue
|
||
risks.append(
|
||
{
|
||
"label": check.get("label") or check.get("key") or "发布检查项",
|
||
"status": check.get("status"),
|
||
"detail": check.get("detail"),
|
||
"source": "release_gate_check",
|
||
}
|
||
)
|
||
if decision == "keep_blocked" and not risks:
|
||
risks.append({"label": "负责人选择保持阻塞,等待补充发布前确认。", "status": "blocked", "source": "human_decision"})
|
||
return risks
|
||
|
||
def _release_validation_rollback_plan(self, release_gate: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"deploymentExecuted": False,
|
||
"requiredBeforeDeploy": [
|
||
"完成人工发布确认。",
|
||
"确认回滚方案和发布范围。",
|
||
],
|
||
"nextActions": list(release_gate.get("nextActions") or []),
|
||
}
|
||
|
||
def _serialize_release_validation_run(self, run: ReleaseValidationRun) -> dict[str, Any]:
|
||
summary = self._payload(run.validation_summary)
|
||
return {
|
||
"id": str(run.id),
|
||
"projectId": str(run.project_id),
|
||
"charterId": None if run.charter_id is None else str(run.charter_id),
|
||
"releaseNodeId": None if run.release_node_id is None else str(run.release_node_id),
|
||
"status": run.status,
|
||
"score": self._jsonable(run.score),
|
||
"keyMetrics": run.key_metrics or {},
|
||
"riskItems": run.risk_items or [],
|
||
"rollbackPlan": run.rollback_plan or {},
|
||
"validationSummary": summary,
|
||
"createdBy": summary.get("createdBy"),
|
||
"createdAt": run.created_at.isoformat() if run.created_at else summary.get("createdAt"),
|
||
"updatedAt": run.updated_at.isoformat() if run.updated_at else None,
|
||
}
|
||
|
||
def _serialize_feedback_ingest(self, ingest: FeedbackIngest) -> dict[str, Any]:
|
||
return {
|
||
"id": str(ingest.id),
|
||
"projectId": str(ingest.project_id),
|
||
"charterId": None if ingest.charter_id is None else str(ingest.charter_id),
|
||
"sourceType": ingest.source_type,
|
||
"title": ingest.title,
|
||
"content": ingest.content,
|
||
"metrics": dict(ingest.metrics or {}),
|
||
"samples": list(ingest.samples or []),
|
||
"status": ingest.status,
|
||
"createdAt": ingest.created_at.isoformat() if ingest.created_at else None,
|
||
"updatedAt": ingest.updated_at.isoformat() if ingest.updated_at else None,
|
||
}
|
||
|
||
def _serialize_iteration_recommendation(
|
||
self,
|
||
recommendation: IterationRecommendation,
|
||
*,
|
||
quality: Optional[dict[str, Any]] = None,
|
||
) -> dict[str, Any]:
|
||
summary = dict(recommendation.recommendation_summary or {})
|
||
change_set_id = summary.get("changeSetId")
|
||
normalized_change_set_id = None if change_set_id is None or str(change_set_id).strip() == "" else str(change_set_id)
|
||
quality_payload = dict(quality or {})
|
||
return {
|
||
"id": str(recommendation.id),
|
||
"projectId": str(recommendation.project_id),
|
||
"charterId": None if recommendation.charter_id is None else str(recommendation.charter_id),
|
||
"feedbackIngestId": None if recommendation.feedback_ingest_id is None else str(recommendation.feedback_ingest_id),
|
||
"status": recommendation.status,
|
||
"priority": recommendation.priority,
|
||
"recommendationSummary": summary,
|
||
"proposedCharterPatch": dict(recommendation.proposed_charter_patch or {}),
|
||
"proposedTasks": list(recommendation.proposed_tasks or []),
|
||
"changeSetCreated": bool(summary.get("changeSetCreated") or normalized_change_set_id),
|
||
"changeSetId": normalized_change_set_id,
|
||
"groupKey": quality_payload.get("groupKey") or summary.get("groupKey"),
|
||
"duplicateOf": quality_payload.get("duplicateOf"),
|
||
"mergedCount": int(quality_payload.get("mergedCount") or 1),
|
||
"impactLevel": quality_payload.get("impactLevel") or "medium",
|
||
"riskLevel": quality_payload.get("riskLevel") or "medium",
|
||
"suggestedChangeTypes": list(quality_payload.get("suggestedChangeTypes") or ["Requirement", "Decision"]),
|
||
"reviewPriority": quality_payload.get("reviewPriority") or "p2",
|
||
"quality": quality_payload,
|
||
"createdAt": recommendation.created_at.isoformat() if recommendation.created_at else None,
|
||
"updatedAt": recommendation.updated_at.isoformat() if recommendation.updated_at else None,
|
||
}
|
||
|
||
def _recommendation_quality_profile(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
) -> dict[str, Any]:
|
||
summary = dict(recommendation.recommendation_summary or {})
|
||
patch = dict(recommendation.proposed_charter_patch or {})
|
||
focus_areas = patch.get("focus_areas") or patch.get("focusAreas") or summary.get("focus_areas") or summary.get("focusAreas") or []
|
||
if not isinstance(focus_areas, list):
|
||
focus_areas = [str(focus_areas)]
|
||
focus_areas = [str(item).strip() for item in focus_areas if str(item).strip()]
|
||
group_seed = focus_areas[0] if focus_areas else (feedback.title if feedback is not None and feedback.title else f"recommendation-{recommendation.id}")
|
||
group_key = self._normalize_group_key(group_seed)
|
||
priority = str(recommendation.priority or "medium").lower()
|
||
proposed_tasks = [item for item in list(recommendation.proposed_tasks or []) if isinstance(item, dict)]
|
||
metrics = dict(feedback.metrics or {}) if feedback is not None else {}
|
||
negative_feedback = self._safe_number(metrics.get("negative_feedback_count") or metrics.get("negativeFeedbackCount"))
|
||
suggested_change_types = ["Requirement", "Decision"]
|
||
if proposed_tasks:
|
||
suggested_change_types.append("Task")
|
||
impact_level = "high" if priority in {"high", "critical"} or negative_feedback >= 3 or len(proposed_tasks) >= 3 else "medium"
|
||
if negative_feedback <= 0 and not proposed_tasks and priority == "low":
|
||
impact_level = "low"
|
||
risk_level = "high" if priority in {"high", "critical"} else ("medium" if proposed_tasks or impact_level == "medium" else "low")
|
||
if risk_level == "high":
|
||
suggested_change_types.append("Risk")
|
||
review_priority = "p0" if risk_level == "high" and impact_level == "high" else ("p1" if impact_level == "high" or risk_level == "medium" else "p2")
|
||
return {
|
||
"groupKey": group_key,
|
||
"groupLabel": group_seed,
|
||
"duplicateOf": None,
|
||
"mergedCount": 1,
|
||
"impactLevel": impact_level,
|
||
"riskLevel": risk_level,
|
||
"suggestedChangeTypes": self._unique_strings(suggested_change_types),
|
||
"reviewPriority": review_priority,
|
||
"rationale": self._recommendation_quality_rationale(
|
||
focus_areas=focus_areas,
|
||
negative_feedback=negative_feedback,
|
||
task_count=len(proposed_tasks),
|
||
priority=priority,
|
||
),
|
||
}
|
||
|
||
def _finalize_recommendation_quality(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
quality_by_id: dict[int, dict[str, Any]],
|
||
) -> dict[int, dict[str, Any]]:
|
||
groups: dict[str, list[int]] = {}
|
||
for recommendation in recommendations:
|
||
quality = quality_by_id.get(int(recommendation.id)) or {}
|
||
groups.setdefault(str(quality.get("groupKey") or f"recommendation-{recommendation.id}"), []).append(int(recommendation.id))
|
||
finalized: dict[int, dict[str, Any]] = {}
|
||
for group_key, ids in groups.items():
|
||
first_id = str(ids[0])
|
||
merged_count = len(ids)
|
||
for rec_id in ids:
|
||
quality = dict(quality_by_id.get(rec_id) or {})
|
||
quality["groupKey"] = group_key
|
||
quality["mergedCount"] = merged_count
|
||
quality["duplicateOf"] = None if str(rec_id) == first_id else first_id
|
||
finalized[rec_id] = quality
|
||
return finalized
|
||
|
||
def _serialize_recommendation_groups(self, recommendations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
groups: dict[str, list[dict[str, Any]]] = {}
|
||
for recommendation in recommendations:
|
||
group_key = str(recommendation.get("groupKey") or f"recommendation-{recommendation.get('id')}")
|
||
groups.setdefault(group_key, []).append(recommendation)
|
||
serialized: list[dict[str, Any]] = []
|
||
priority_order = {"p0": 0, "p1": 1, "p2": 2}
|
||
level_order = {"high": 0, "medium": 1, "low": 2}
|
||
for group_key, rows in groups.items():
|
||
quality = dict(rows[0].get("quality") or {})
|
||
change_set_ids = self._unique_strings([row.get("changeSetId") for row in rows if row.get("changeSetId")])
|
||
suggested_types = self._unique_strings(
|
||
item for row in rows for item in list(row.get("suggestedChangeTypes") or [])
|
||
)
|
||
serialized.append(
|
||
{
|
||
"groupKey": group_key,
|
||
"title": f"{quality.get('groupLabel') or group_key} 反馈组",
|
||
"recommendationIds": [str(row.get("id")) for row in rows],
|
||
"mergedCount": len(rows),
|
||
"impactLevel": quality.get("impactLevel") or "medium",
|
||
"riskLevel": quality.get("riskLevel") or "medium",
|
||
"suggestedChangeTypes": suggested_types,
|
||
"reviewPriority": quality.get("reviewPriority") or "p2",
|
||
"changeSetCreated": bool(change_set_ids),
|
||
"changeSetId": change_set_ids[0] if change_set_ids else None,
|
||
"rationale": quality.get("rationale") or "基于同组发布后反馈聚合生成。",
|
||
}
|
||
)
|
||
return sorted(
|
||
serialized,
|
||
key=lambda row: (
|
||
priority_order.get(str(row.get("reviewPriority") or "p2"), 9),
|
||
level_order.get(str(row.get("riskLevel") or "medium"), 9),
|
||
str(row.get("groupKey") or ""),
|
||
),
|
||
)
|
||
|
||
def _recommendation_quality_rationale(
|
||
self,
|
||
*,
|
||
focus_areas: list[str],
|
||
negative_feedback: float,
|
||
task_count: int,
|
||
priority: str,
|
||
) -> str:
|
||
parts = []
|
||
if focus_areas:
|
||
parts.append("聚焦 " + "、".join(focus_areas[:4]))
|
||
if negative_feedback >= 1:
|
||
parts.append(f"负向反馈 {int(negative_feedback)} 条")
|
||
if task_count:
|
||
parts.append(f"建议任务 {task_count} 个")
|
||
parts.append(f"原始优先级 {priority or 'medium'}")
|
||
return ";".join(parts) + "。"
|
||
|
||
def _iteration_recommendation_message(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
note: Optional[str],
|
||
) -> str:
|
||
summary = self._recommendation_summary_text(recommendation)
|
||
task_titles = [
|
||
str(item.get("title") or item.get("summary") or "").strip()
|
||
for item in list(recommendation.proposed_tasks or [])
|
||
if isinstance(item, dict) and str(item.get("title") or item.get("summary") or "").strip()
|
||
]
|
||
lines = [
|
||
"Release/Growth 反馈已生成下一轮待确认变更草稿。",
|
||
f"反馈:{feedback.title or feedback.content[:80] if feedback is not None else '未绑定 feedback ingest'}",
|
||
f"建议:{summary}",
|
||
]
|
||
if task_titles:
|
||
lines.append("建议任务:" + ";".join(task_titles[:5]))
|
||
if note:
|
||
lines.append(f"人工备注:{note.strip()}")
|
||
lines.append("此消息只作为 Change Review 证据,不自动写入 Project Memory。")
|
||
return "\n".join(lines)
|
||
|
||
def _iteration_change_set_headline(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
) -> str:
|
||
source = feedback.title if feedback is not None and feedback.title else f"Recommendation {recommendation.id}"
|
||
return f"下一轮反馈变更草稿:{source}"[:255]
|
||
|
||
def _iteration_change_set_summary(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
) -> str:
|
||
source = feedback.title or feedback.content[:80] if feedback is not None else "发布后反馈"
|
||
return f"基于 {source} 生成下一轮 ChangeSet draft,等待 Change Review accept/edit/defer。"
|
||
|
||
def _iteration_recommendation_excerpt(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
) -> str:
|
||
feedback_text = "" if feedback is None else feedback.content.strip()
|
||
return (feedback_text or self._recommendation_summary_text(recommendation))[:1200]
|
||
|
||
def _recommendation_summary_text(self, recommendation: IterationRecommendation) -> str:
|
||
summary = dict(recommendation.recommendation_summary or {})
|
||
value = summary.get("summary") or summary.get("recommendation") or summary.get("title")
|
||
if value:
|
||
return str(value).strip()
|
||
focus = summary.get("focus_areas") or summary.get("focusAreas")
|
||
if isinstance(focus, list) and focus:
|
||
return "围绕 " + "、".join(str(item) for item in focus[:4]) + " 推进下一轮迭代。"
|
||
return "基于发布后反馈生成下一轮迭代建议。"
|
||
|
||
def _iteration_recommendation_change_items(
|
||
self,
|
||
*,
|
||
recommendation: IterationRecommendation,
|
||
feedback: Optional[FeedbackIngest],
|
||
project_id: int,
|
||
) -> list[PMFlowChangeItem]:
|
||
patch = dict(recommendation.proposed_charter_patch or {})
|
||
summary_text = self._recommendation_summary_text(recommendation)
|
||
focus_areas = patch.get("focus_areas") or patch.get("focusAreas") or dict(recommendation.recommendation_summary or {}).get("focus_areas") or []
|
||
success_criteria = patch.get("updated_success_criteria") or patch.get("successCriteria") or []
|
||
risk_level = "high" if str(recommendation.priority or "").lower() in {"high", "critical"} else "medium"
|
||
items: list[PMFlowChangeItem] = [
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="new_object",
|
||
object_type="Requirement",
|
||
object_id=None,
|
||
title="下一轮迭代方向",
|
||
summary=summary_text,
|
||
diff="根据发布后反馈补充下一轮需求方向与成功标准。",
|
||
payload={
|
||
"title": "下一轮迭代方向",
|
||
"summary": summary_text,
|
||
"stage": "planning",
|
||
"status": "draft",
|
||
"source": "iteration_recommendation",
|
||
"recommendationId": str(recommendation.id),
|
||
"feedbackIngestId": None if feedback is None else str(feedback.id),
|
||
"focusAreas": list(focus_areas) if isinstance(focus_areas, list) else [str(focus_areas)],
|
||
"acceptanceCriteria": list(success_criteria) if isinstance(success_criteria, list) else [str(success_criteria)],
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.76,
|
||
risk_level=risk_level,
|
||
status="pending_review",
|
||
),
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="decision",
|
||
object_type="Decision",
|
||
object_id=None,
|
||
title="确认下一轮反馈驱动迭代方向",
|
||
summary=f"确认是否将 recommendation {recommendation.id} 纳入下一轮 Change Review。",
|
||
diff="将发布后反馈从 Release/Growth 回流到 Change Review,而不是直接写入项目记忆。",
|
||
payload={
|
||
"title": "确认下一轮反馈驱动迭代方向",
|
||
"summary": summary_text,
|
||
"stage": "growth",
|
||
"owner": "PMFlow",
|
||
"source": "iteration_recommendation",
|
||
"recommendationId": str(recommendation.id),
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.72,
|
||
risk_level="medium",
|
||
status="pending_review",
|
||
),
|
||
]
|
||
|
||
for raw_task in list(recommendation.proposed_tasks or [])[:4]:
|
||
if not isinstance(raw_task, dict):
|
||
continue
|
||
title = str(raw_task.get("title") or raw_task.get("summary") or "").strip()
|
||
if not title:
|
||
continue
|
||
task_summary = str(raw_task.get("summary") or raw_task.get("description") or title).strip()
|
||
items.append(
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="new_object",
|
||
object_type="Task",
|
||
object_id=None,
|
||
title=title[:255],
|
||
summary=task_summary,
|
||
diff="新增下一轮可审查任务草稿,待 Change Review 决定是否接受。",
|
||
payload={
|
||
"title": title,
|
||
"description": task_summary,
|
||
"summary": task_summary,
|
||
"stage": "development",
|
||
"status": "draft",
|
||
"priority": raw_task.get("priority") or recommendation.priority,
|
||
"source": "iteration_recommendation",
|
||
"recommendationId": str(recommendation.id),
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.7,
|
||
risk_level="medium",
|
||
status="pending_review",
|
||
)
|
||
)
|
||
|
||
if risk_level == "high":
|
||
items.append(
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="risk",
|
||
object_type="Risk",
|
||
object_id=None,
|
||
title="发布后反馈高优先级风险",
|
||
summary=summary_text,
|
||
diff="将高优先级反馈沉淀为风险项,避免直接跳过人工审查。",
|
||
payload={
|
||
"title": "发布后反馈高优先级风险",
|
||
"summary": summary_text,
|
||
"stage": "planning",
|
||
"level": "high",
|
||
"mitigation": "在 Change Review 中确认处理策略后再进入 Project Memory。",
|
||
"source": "iteration_recommendation",
|
||
"recommendationId": str(recommendation.id),
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.74,
|
||
risk_level="high",
|
||
status="pending_review",
|
||
)
|
||
)
|
||
return items
|
||
|
||
def _iteration_group_message(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
feedback_by_id: dict[int, FeedbackIngest],
|
||
quality_by_id: dict[int, dict[str, Any]],
|
||
note: Optional[str],
|
||
) -> str:
|
||
first_quality = next(iter(quality_by_id.values()), {})
|
||
summaries = [self._recommendation_summary_text(item) for item in recommendations]
|
||
feedback_titles = [
|
||
feedback_by_id[int(item.feedback_ingest_id)].title or feedback_by_id[int(item.feedback_ingest_id)].content[:60]
|
||
for item in recommendations
|
||
if item.feedback_ingest_id is not None and int(item.feedback_ingest_id) in feedback_by_id
|
||
]
|
||
lines = [
|
||
"Release/Growth 同组反馈已生成下一轮待确认变更草稿。",
|
||
f"建议组:{first_quality.get('groupLabel') or first_quality.get('groupKey') or 'feedback'}",
|
||
f"合并建议:{len(recommendations)} 条;风险:{first_quality.get('riskLevel') or 'medium'};影响:{first_quality.get('impactLevel') or 'medium'}。",
|
||
]
|
||
if feedback_titles:
|
||
lines.append("反馈来源:" + ";".join(feedback_titles[:5]))
|
||
lines.append("建议摘要:" + ";".join(summaries[:4]))
|
||
if note:
|
||
lines.append(f"人工备注:{note.strip()}")
|
||
lines.append("此消息只作为 Change Review 证据,不自动写入 Project Memory。")
|
||
return "\n".join(lines)
|
||
|
||
def _iteration_group_change_set_headline(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
quality_by_id: dict[int, dict[str, Any]],
|
||
) -> str:
|
||
quality = next(iter(quality_by_id.values()), {})
|
||
label = quality.get("groupLabel") or quality.get("groupKey") or "feedback"
|
||
return f"下一轮反馈变更草稿组:{label}({len(recommendations)} 条建议)"[:255]
|
||
|
||
def _iteration_group_change_set_summary(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
quality_by_id: dict[int, dict[str, Any]],
|
||
) -> str:
|
||
quality = next(iter(quality_by_id.values()), {})
|
||
return (
|
||
f"基于 {len(recommendations)} 条同组 iteration recommendation 生成下一轮 ChangeSet draft;"
|
||
f"reviewPriority={quality.get('reviewPriority') or 'p2'},risk={quality.get('riskLevel') or 'medium'},"
|
||
"等待 Change Review accept/edit/defer。"
|
||
)
|
||
|
||
def _iteration_group_excerpt(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
feedback_by_id: dict[int, FeedbackIngest],
|
||
) -> str:
|
||
excerpts: list[str] = []
|
||
for recommendation in recommendations:
|
||
if recommendation.feedback_ingest_id is not None and int(recommendation.feedback_ingest_id) in feedback_by_id:
|
||
excerpts.append(feedback_by_id[int(recommendation.feedback_ingest_id)].content.strip())
|
||
else:
|
||
excerpts.append(self._recommendation_summary_text(recommendation))
|
||
return "\n".join(excerpts)[:1200]
|
||
|
||
def _iteration_recommendation_group_change_items(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
feedback_by_id: dict[int, FeedbackIngest],
|
||
quality_by_id: dict[int, dict[str, Any]],
|
||
project_id: int,
|
||
) -> list[PMFlowChangeItem]:
|
||
first_quality = next(iter(quality_by_id.values()), {})
|
||
group_key = str(first_quality.get("groupKey") or "general-feedback")
|
||
recommendation_ids = [str(item.id) for item in recommendations]
|
||
feedback_ids = [str(item.feedback_ingest_id) for item in recommendations if item.feedback_ingest_id is not None]
|
||
focus_areas = self._unique_strings(
|
||
area
|
||
for recommendation in recommendations
|
||
for area in list(dict(recommendation.proposed_charter_patch or {}).get("focus_areas") or dict(recommendation.recommendation_summary or {}).get("focus_areas") or [])
|
||
)
|
||
summaries = [self._recommendation_summary_text(item) for item in recommendations]
|
||
summary_text = ";".join(summaries[:4])
|
||
risk_level = "high" if any(str(quality.get("riskLevel")) == "high" for quality in quality_by_id.values()) else "medium"
|
||
impact_level = "high" if any(str(quality.get("impactLevel")) == "high" for quality in quality_by_id.values()) else "medium"
|
||
base_payload = {
|
||
"source": "iteration_recommendation_group",
|
||
"groupKey": group_key,
|
||
"recommendationIds": recommendation_ids,
|
||
"feedbackIngestIds": feedback_ids,
|
||
"quality": first_quality,
|
||
}
|
||
items: list[PMFlowChangeItem] = [
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="new_object",
|
||
object_type="Requirement",
|
||
object_id=None,
|
||
title=f"下一轮迭代方向:{first_quality.get('groupLabel') or group_key}"[:255],
|
||
summary=summary_text or "基于发布后反馈建议组生成下一轮迭代方向。",
|
||
diff="合并同组发布后反馈,形成下一轮需求方向与成功标准草稿。",
|
||
payload={
|
||
**base_payload,
|
||
"title": f"下一轮迭代方向:{first_quality.get('groupLabel') or group_key}",
|
||
"summary": summary_text,
|
||
"stage": "planning",
|
||
"status": "draft",
|
||
"focusAreas": focus_areas,
|
||
"acceptanceCriteria": [
|
||
f"本组 {len(recommendations)} 条 recommendation 在 Change Review 中完成 accept/edit/defer。",
|
||
"接受后再进入 Project Memory,不自动写入。",
|
||
],
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.78,
|
||
risk_level=risk_level,
|
||
status="pending_review",
|
||
),
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="decision",
|
||
object_type="Decision",
|
||
object_id=None,
|
||
title="确认同组反馈进入下一轮迭代",
|
||
summary=f"确认 {group_key} 反馈组是否作为下一轮迭代输入;影响级别 {impact_level},风险级别 {risk_level}。",
|
||
diff="批量生成只进入待确认中心,不直接提交项目记忆。",
|
||
payload={
|
||
**base_payload,
|
||
"title": "确认同组反馈进入下一轮迭代",
|
||
"summary": summary_text,
|
||
"stage": "growth",
|
||
"owner": "PMFlow",
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.74,
|
||
risk_level=risk_level,
|
||
status="pending_review",
|
||
),
|
||
]
|
||
|
||
seen_task_titles: set[str] = set()
|
||
for recommendation in recommendations:
|
||
for raw_task in list(recommendation.proposed_tasks or []):
|
||
if not isinstance(raw_task, dict):
|
||
continue
|
||
title = str(raw_task.get("title") or raw_task.get("summary") or "").strip()
|
||
if not title or title in seen_task_titles:
|
||
continue
|
||
seen_task_titles.add(title)
|
||
task_summary = str(raw_task.get("summary") or raw_task.get("description") or title).strip()
|
||
items.append(
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="new_object",
|
||
object_type="Task",
|
||
object_id=None,
|
||
title=title[:255],
|
||
summary=task_summary,
|
||
diff="从同组发布后反馈批量生成下一轮任务草稿,待 Change Review 决定是否接受。",
|
||
payload={
|
||
**base_payload,
|
||
"title": title,
|
||
"description": task_summary,
|
||
"summary": task_summary,
|
||
"stage": "development",
|
||
"status": "draft",
|
||
"priority": raw_task.get("priority") or first_quality.get("reviewPriority") or "p2",
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.72,
|
||
risk_level="medium",
|
||
status="pending_review",
|
||
)
|
||
)
|
||
|
||
if risk_level == "high":
|
||
items.append(
|
||
PMFlowChangeItem(
|
||
tenant_id=self.tenant_id,
|
||
change_set_id=0,
|
||
project_id=int(project_id),
|
||
group="risk",
|
||
object_type="Risk",
|
||
object_id=None,
|
||
title="同组发布后反馈高风险",
|
||
summary=f"{group_key} 反馈组存在高风险或高影响建议,需先完成人工审查。",
|
||
diff="风险项只进入 Change Review,不直接写入项目记忆。",
|
||
payload={
|
||
**base_payload,
|
||
"title": "同组发布后反馈高风险",
|
||
"summary": summary_text,
|
||
"stage": "planning",
|
||
"level": "high",
|
||
"mitigation": "在 Change Review 中确认分组范围、owner 和处理顺序后再提交。",
|
||
},
|
||
edited_payload=None,
|
||
confidence=0.76,
|
||
risk_level="high",
|
||
status="pending_review",
|
||
)
|
||
)
|
||
return items
|
||
|
||
async def _mark_recommendations_for_group_change_set(
|
||
self,
|
||
*,
|
||
recommendations: list[IterationRecommendation],
|
||
change_set_id: int,
|
||
group_key: str,
|
||
current_user_id: Optional[int],
|
||
reused: bool,
|
||
) -> None:
|
||
stamped_at = now_cn_naive().isoformat()
|
||
for recommendation in recommendations:
|
||
summary_payload = dict(recommendation.recommendation_summary or {})
|
||
summary_payload.update(
|
||
{
|
||
"changeSetId": str(change_set_id),
|
||
"changeSetCreated": True,
|
||
"changeSetCreatedAt": summary_payload.get("changeSetCreatedAt") or stamped_at,
|
||
"changeSetCreatedBy": summary_payload.get("changeSetCreatedBy")
|
||
or (None if current_user_id is None else str(current_user_id)),
|
||
"groupKey": group_key,
|
||
"groupChangeSetId": str(change_set_id),
|
||
"groupDraftReused": bool(reused),
|
||
}
|
||
)
|
||
recommendation.recommendation_summary = summary_payload
|
||
recommendation.status = "changeset_drafted"
|
||
flag_modified(recommendation, "recommendation_summary")
|
||
|
||
def _serialize_revenue_event(self, event: RevenueEvent) -> dict[str, Any]:
|
||
return {
|
||
"id": str(event.id),
|
||
"projectId": str(event.project_id),
|
||
"sourceType": event.source_type,
|
||
"category": event.category,
|
||
"currency": event.currency,
|
||
"amountUsd": self._jsonable(event.amount_usd),
|
||
"externalRef": event.external_ref,
|
||
"eventPayload": dict(event.event_payload or {}),
|
||
"occurredAt": event.occurred_at.isoformat() if event.occurred_at else None,
|
||
"createdAt": event.created_at.isoformat() if event.created_at else None,
|
||
"updatedAt": event.updated_at.isoformat() if event.updated_at else None,
|
||
}
|
||
|
||
def _serialize_roi_snapshot(self, snapshot: ROISnapshot) -> dict[str, Any]:
|
||
roi_ratio = self._safe_number(snapshot.roi_ratio)
|
||
total_cost = self._safe_number(snapshot.total_cost_usd)
|
||
risk_level = "high" if roi_ratio < 0 else ("medium" if roi_ratio == 0 and total_cost > 0 else "low")
|
||
return {
|
||
"id": str(snapshot.id),
|
||
"projectId": str(snapshot.project_id),
|
||
"rangeKey": snapshot.range_key,
|
||
"windowStart": snapshot.window_start.isoformat() if snapshot.window_start else None,
|
||
"windowEnd": snapshot.window_end.isoformat() if snapshot.window_end else None,
|
||
"totalRevenueUsd": self._jsonable(snapshot.total_revenue_usd),
|
||
"totalCostUsd": self._jsonable(snapshot.total_cost_usd),
|
||
"roiRatio": self._jsonable(snapshot.roi_ratio),
|
||
"snapshotPayload": dict(snapshot.snapshot_payload or {}),
|
||
"riskLevel": risk_level,
|
||
"status": "negative" if risk_level == "high" else ("watch" if risk_level == "medium" else "healthy"),
|
||
"createdAt": snapshot.created_at.isoformat() if snapshot.created_at else None,
|
||
"updatedAt": snapshot.updated_at.isoformat() if snapshot.updated_at else None,
|
||
}
|
||
|
||
def _serialize_compliance_check(self, run: ComplianceCheckRun) -> dict[str, Any]:
|
||
issues = list(run.issues or [])
|
||
status_text = str(run.status or "pending").lower()
|
||
risk_level = (
|
||
"high"
|
||
if status_text in {"blocked", "failed", "rejected"} or issues
|
||
else ("medium" if status_text in {"pending", "review_required"} else "low")
|
||
)
|
||
return {
|
||
"id": str(run.id),
|
||
"projectId": str(run.project_id),
|
||
"charterId": None if run.charter_id is None else str(run.charter_id),
|
||
"releaseNodeId": None if run.release_node_id is None else str(run.release_node_id),
|
||
"projectModelBindingId": None if run.project_model_binding_id is None else str(run.project_model_binding_id),
|
||
"status": run.status,
|
||
"score": self._jsonable(run.score),
|
||
"checklist": list(run.checklist or []),
|
||
"issues": issues,
|
||
"summary": dict(run.summary or {}),
|
||
"riskLevel": risk_level,
|
||
"createdAt": run.created_at.isoformat() if run.created_at else None,
|
||
"updatedAt": run.updated_at.isoformat() if run.updated_at else None,
|
||
}
|
||
|
||
def _release_signal_next_actions(
|
||
self,
|
||
*,
|
||
feedback_items: list[dict[str, Any]],
|
||
revenue_events: list[dict[str, Any]],
|
||
roi_snapshots: list[dict[str, Any]],
|
||
compliance_checks: list[dict[str, Any]],
|
||
recommendations: list[dict[str, Any]],
|
||
release_gate: dict[str, Any],
|
||
) -> list[str]:
|
||
actions: list[str] = []
|
||
if not feedback_items:
|
||
actions.append("发布后先摄取用户反馈、运营记录或人工观察,避免用 mock 增长数据判断下一轮。")
|
||
if not revenue_events:
|
||
actions.append("缺少真实 revenue event,暂不能用收入增长判断发布效果。")
|
||
if not roi_snapshots:
|
||
actions.append("缺少 ROI snapshot,暂不能判断投入产出是否健康。")
|
||
if not compliance_checks:
|
||
actions.append("缺少 compliance check,发布增长判断不能替代合规审查。")
|
||
if any(item.get("riskLevel") == "high" for item in roi_snapshots):
|
||
actions.append("ROI 信号处于风险态,下一轮建议应优先解释成本、收入或转化缺口。")
|
||
if any(item.get("riskLevel") == "high" for item in compliance_checks):
|
||
actions.append("存在合规风险信号,先进入 Change Review 明确缓解动作,不自动推进发布。")
|
||
if feedback_items and recommendations:
|
||
if any(bool(item.get("changeSetCreated")) for item in recommendations):
|
||
actions.append("下一轮 ChangeSet draft 已进入 Change Review,继续 accept / edit / defer 后再决定是否写入 Project Memory。")
|
||
else:
|
||
actions.append("基于真实反馈选择 iteration recommendation,生成下一轮待确认 ChangeSet draft。")
|
||
if str(release_gate.get("status") or "") == "blocked":
|
||
actions.append("发布门禁仍阻塞时,先处理 release gate,再把反馈作为下一轮规划输入。")
|
||
if not actions:
|
||
actions.append("继续沉淀 feedback / revenue / ROI / compliance 信号,为下一轮 iteration recommendation 做准备。")
|
||
return actions
|
||
|
||
def _normalize_group_key(self, value: Any) -> str:
|
||
text = str(value or "").strip().lower()
|
||
chars: list[str] = []
|
||
previous_dash = False
|
||
for char in text:
|
||
if char.isascii() and char.isalnum():
|
||
chars.append(char)
|
||
previous_dash = False
|
||
elif not previous_dash:
|
||
chars.append("-")
|
||
previous_dash = True
|
||
normalized = "".join(chars).strip("-")
|
||
return normalized[:64] or "general-feedback"
|
||
|
||
def _safe_number(self, value: Any) -> float:
|
||
if value is None or value == "":
|
||
return 0.0
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
def _unique_strings(self, values: Any) -> list[str]:
|
||
result: list[str] = []
|
||
for value in values or []:
|
||
text = str(value or "").strip()
|
||
if text and text not in result:
|
||
result.append(text)
|
||
return result
|
||
|
||
def _parse_recommendation_ids(self, raw_ids: list[str]) -> set[int]:
|
||
parsed: set[int] = set()
|
||
for raw in raw_ids or []:
|
||
text = str(raw or "").strip()
|
||
if not text.isdigit():
|
||
raise ValueError("iteration_recommendation_not_found")
|
||
parsed.add(int(text))
|
||
return parsed
|
||
|
||
def _optional_int(self, value: Optional[Any], *, error_code: str) -> Optional[int]:
|
||
if value is None or str(value).strip() == "":
|
||
return None
|
||
try:
|
||
return int(str(value).strip())
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(error_code) from exc
|
||
|
||
def _serialize_prompt_package(self, package: PromptPackage) -> dict[str, Any]:
|
||
return {
|
||
"id": str(package.id),
|
||
"project_id": str(package.project_id),
|
||
"charter_id": str(package.charter_id),
|
||
"task_id": None if package.task_id is None else str(package.task_id),
|
||
"status": package.status,
|
||
"package_kind": package.package_kind,
|
||
"package_text": package.package_text,
|
||
"package_payload": package.package_payload,
|
||
"created_at": package.created_at.isoformat() if package.created_at else None,
|
||
}
|
||
|
||
def _build_execution_quality_contract(
|
||
self,
|
||
*,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
acceptance: list[str],
|
||
memory: PMFlowProjectMemory,
|
||
) -> dict[str, Any]:
|
||
task_payload = self._payload(task.ai_breakdown_payload)
|
||
title_text = f"{task.title} {task.description or ''}".lower()
|
||
high_risk = str(task.priority or "").lower() in {"high", "urgent"} or any(
|
||
token in title_text for token in ("auth", "permission", "tenant", "权限", "租户", "鉴权", "路由", "发布", "迁移")
|
||
)
|
||
required_levels = ["unit", "contract", "smoke"]
|
||
if repo is not None:
|
||
required_levels.append("integration")
|
||
if high_risk:
|
||
required_levels.append("review")
|
||
test_cases = [
|
||
{
|
||
"id": f"tc-unit-{task.id}",
|
||
"level": "unit",
|
||
"title": "核心服务/状态推导单元测试",
|
||
"why": "验证本次任务的业务规则不会只停留在 UI 或 prompt 文案。",
|
||
"command": "PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q",
|
||
"expected": "相关 service / projection / helper 断言通过",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
{
|
||
"id": f"tc-contract-{task.id}",
|
||
"level": "contract",
|
||
"title": "API 合同、权限与租户边界测试",
|
||
"why": "新增或消费 PMFlow OS API 时必须保证统一包络、权限和跨租户 fail-closed。",
|
||
"command": "PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q",
|
||
"expected": "成功、失败、权限和租户边界返回产品语义错误",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
{
|
||
"id": f"tc-frontend-{task.id}",
|
||
"level": "unit",
|
||
"title": "前端执行桥 focused 测试",
|
||
"why": "确保执行包、测试合同、验收和发布门禁状态在页面中可见且不回退 mock。",
|
||
"command": "cd frontend && npm run test -- src/features/pmflow-os",
|
||
"expected": "PMFlow OS focused tests 通过",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
{
|
||
"id": f"tc-smoke-{task.id}",
|
||
"level": "smoke",
|
||
"title": "Execution Bridge 到 Release/Growth 冒烟路径",
|
||
"why": "验证正式任务、handoff、回写、验收和 release gate 可以形成用户可理解的闭环。",
|
||
"command": "Playwright: /execution-bridge?projectId=<id> 与 /release-growth?projectId=<id>",
|
||
"expected": "无横向溢出;执行状态、测试证据、验收状态和 release gate 正确展示",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
]
|
||
if repo is not None:
|
||
test_cases.append(
|
||
{
|
||
"id": f"tc-integration-{task.id}",
|
||
"level": "integration",
|
||
"title": "代码仓库连接与执行回写联调",
|
||
"why": "Execution Bridge 必须证明 branch / commit / PR / test 证据能绑定到真实任务与仓库。",
|
||
"command": "回放或真实写入 execution-progress-events,覆盖 branch/commit/PR/test_passed/test_failed",
|
||
"expected": "execution-summary、releaseGate、Space next action 消费同一组证据",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
}
|
||
)
|
||
environment = {
|
||
"services": ["backend", "frontend", "postgres", "redis"],
|
||
"envVars": ["DATABASE_URL"],
|
||
"seedData": ["tenant", "project", "formal_task", "prompt_package", "progress_events"],
|
||
"externalSystems": ["github_app_optional" if repo is not None else "none"],
|
||
"canUseMock": ["webhook_payload", "browser_seed_project"],
|
||
"mustUseReal": ["tenant/project/task ownership", "Project Memory committed task"],
|
||
}
|
||
if repo is not None:
|
||
environment["envVars"].extend(["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH"])
|
||
environment["seedData"].append("repo_binding")
|
||
return {
|
||
"qualityContractId": f"qc-task-{task.id}",
|
||
"version": "execution_quality_contract_v1",
|
||
"scope": {
|
||
"projectId": str(project.id),
|
||
"taskId": str(task.id),
|
||
"requirementIds": [str(task.truth_node_id)] if task.truth_node_id is not None else [],
|
||
"riskLevel": "high" if high_risk else "medium",
|
||
"affectedSurfaces": self._unique_strings(
|
||
[
|
||
"ExecutionBridgePage",
|
||
"ReleaseGrowthPage",
|
||
*list(task_payload.get("affected_surfaces") or []),
|
||
]
|
||
),
|
||
},
|
||
"testStrategy": {
|
||
"requiredLevels": self._unique_strings(required_levels),
|
||
"optionalLevels": ["e2e", "visual", "provider_sandbox"],
|
||
"releaseBlockingLevels": ["unit", "contract", "smoke"] + (["integration"] if repo is not None else []),
|
||
},
|
||
"testCases": test_cases,
|
||
"environment": environment,
|
||
"evidenceRequired": {
|
||
"commands": True,
|
||
"testSummary": True,
|
||
"changedFiles": True,
|
||
"branchCommitPr": repo is not None,
|
||
"screenshots": ["when_ui_changed"],
|
||
"notRunReasons": True,
|
||
},
|
||
"acceptanceGate": {
|
||
"userAcceptanceRequired": True,
|
||
"technicalReviewRequired": high_risk,
|
||
"releaseGateAllowedWhen": ["required_tests_passed", "no_blocker", "accepted"],
|
||
},
|
||
"acceptanceCriteria": acceptance,
|
||
}
|
||
|
||
def _execution_quality_contract_for_summary(
|
||
self,
|
||
*,
|
||
project_id: int,
|
||
task_id: Optional[str],
|
||
active_tasks: list[dict[str, Any]],
|
||
latest_package: Optional[PromptPackage],
|
||
repo_bindings: list[dict[str, Any]],
|
||
events: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
payload = self._payload(latest_package.package_payload if latest_package is not None else {})
|
||
quality = self._payload(payload.get("quality_contract"))
|
||
task = next((item for item in active_tasks if task_id is None or str(item.get("id")) == str(task_id)), None)
|
||
if not quality and task is not None:
|
||
task_title = f"{task.get('title') or ''} {task.get('description') or ''}".lower()
|
||
risk_level = self._risk_level(task.get("priority"))
|
||
technical_review_required = risk_level == "high" or any(
|
||
token in task_title for token in ("auth", "permission", "tenant", "权限", "租户", "鉴权", "路由", "发布", "迁移")
|
||
)
|
||
required_levels = ["unit", "contract", "smoke"] + (["integration"] if repo_bindings else []) + (["review"] if technical_review_required else [])
|
||
quality = {
|
||
"qualityContractId": f"qc-task-{task.get('id')}",
|
||
"version": "execution_quality_contract_v1",
|
||
"scope": {
|
||
"projectId": str(project_id),
|
||
"taskId": str(task.get("id")),
|
||
"requirementIds": [str(task.get("charter_id"))] if task.get("charter_id") else [],
|
||
"riskLevel": risk_level,
|
||
"affectedSurfaces": ["ExecutionBridgePage", "ReleaseGrowthPage"],
|
||
},
|
||
"testStrategy": {
|
||
"requiredLevels": required_levels,
|
||
"optionalLevels": ["e2e", "visual", "provider_sandbox"],
|
||
"releaseBlockingLevels": required_levels,
|
||
},
|
||
"testCases": [
|
||
{
|
||
"id": f"tc-unit-{task.get('id')}",
|
||
"level": "unit",
|
||
"title": "核心服务/状态推导单元测试",
|
||
"command": "PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
{
|
||
"id": f"tc-contract-{task.get('id')}",
|
||
"level": "contract",
|
||
"title": "API 合同、权限与租户边界测试",
|
||
"command": "PYTHONPATH=backend /opt/homebrew/bin/python3.11 -m pytest backend/tests/test_pmflow_os_projection_api.py -q",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
{
|
||
"id": f"tc-smoke-{task.get('id')}",
|
||
"level": "smoke",
|
||
"title": "Execution Bridge 到 Release/Growth 冒烟路径",
|
||
"command": "Playwright: /execution-bridge?projectId=<id> 与 /release-growth?projectId=<id>",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
},
|
||
*(
|
||
[
|
||
{
|
||
"id": f"tc-integration-{task.get('id')}",
|
||
"level": "integration",
|
||
"title": "代码仓库联调与 PR 证据",
|
||
"command": "回写 branch / commit / PR / checks 证据",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
}
|
||
]
|
||
if repo_bindings
|
||
else []
|
||
),
|
||
*(
|
||
[
|
||
{
|
||
"id": f"tc-review-{task.get('id')}",
|
||
"level": "review",
|
||
"title": "高风险任务技术审查",
|
||
"command": "在 Execution Bridge 记录技术审查通过或要求修改",
|
||
"required": True,
|
||
"blocksRelease": True,
|
||
"status": "missing",
|
||
}
|
||
]
|
||
if technical_review_required
|
||
else []
|
||
),
|
||
],
|
||
"environment": {
|
||
"services": ["backend", "frontend", "postgres", "redis"],
|
||
"envVars": ["DATABASE_URL"],
|
||
"seedData": ["tenant", "project", "formal_task", "progress_events"],
|
||
"externalSystems": ["github_app_optional" if repo_bindings else "none"],
|
||
"canUseMock": ["webhook_payload", "browser_seed_project"],
|
||
"mustUseReal": ["tenant/project/task ownership", "Project Memory committed task"],
|
||
},
|
||
"evidenceRequired": {"commands": True, "testSummary": True, "notRunReasons": True},
|
||
"acceptanceGate": {
|
||
"userAcceptanceRequired": True,
|
||
"technicalReviewRequired": technical_review_required,
|
||
"releaseGateAllowedWhen": ["required_tests_passed", "no_blocker", "accepted"],
|
||
},
|
||
}
|
||
return self._quality_contract_with_status(quality, events)
|
||
|
||
def _quality_contract_with_status(self, quality: dict[str, Any], events: list[dict[str, Any]]) -> dict[str, Any]:
|
||
if not quality:
|
||
return {}
|
||
cases: list[dict[str, Any]] = []
|
||
for item in list(quality.get("testCases") or []):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
next_item = dict(item)
|
||
next_item["status"] = self._quality_case_status(next_item, events)
|
||
next_item["evidenceEventIds"] = [
|
||
str(event.get("id"))
|
||
for event in events
|
||
if self._quality_event_matches_case(next_item, event)
|
||
][:5]
|
||
cases.append(next_item)
|
||
required = [item for item in cases if bool(item.get("required"))]
|
||
passed = [item for item in required if item.get("status") == "passed"]
|
||
failed = [item for item in required if item.get("status") == "failed"]
|
||
blocked = [item for item in required if bool(item.get("blocksRelease")) and item.get("status") != "passed"]
|
||
quality = dict(quality)
|
||
quality["testCases"] = cases
|
||
quality["status"] = "blocked" if failed else ("ready" if required and len(passed) == len(required) else "incomplete")
|
||
quality["summary"] = {
|
||
"required": len(required),
|
||
"passed": len(passed),
|
||
"failed": len(failed),
|
||
"releaseBlockingMissing": len(blocked),
|
||
}
|
||
quality["blockingReasons"] = [str(item.get("title") or item.get("id")) for item in blocked[:6]]
|
||
return quality
|
||
|
||
def _quality_case_status(self, case: dict[str, Any], events: list[dict[str, Any]]) -> str:
|
||
matched = [event for event in events if self._quality_event_matches_case(case, event)]
|
||
if any(self._event_type(event) in {"test_failed", "failed", "blocked"} for event in matched):
|
||
return "failed"
|
||
if any(self._event_type(event) in {"test_passed", "checks_passed"} for event in matched):
|
||
return "passed"
|
||
return "missing"
|
||
|
||
def _quality_event_matches_case(self, case: dict[str, Any], event: dict[str, Any]) -> bool:
|
||
event_type = self._event_type(event)
|
||
event_text = self._event_text(event)
|
||
level = str(case.get("level") or "").lower()
|
||
if event_type == "checks_passed":
|
||
return True
|
||
if level == "unit":
|
||
return event_type in {"test_passed", "test_failed"} and any(token in event_text for token in ("unit", "pytest", "单元", "单测", "test"))
|
||
if level == "contract":
|
||
return event_type in {"test_passed", "test_failed"} and any(token in event_text for token in ("contract", "api", "合同", "权限", "tenant"))
|
||
if level == "integration":
|
||
return event_type in {"test_passed", "test_failed"} and any(token in event_text for token in ("integration", "联调", "repo", "pr", "github"))
|
||
if level == "smoke":
|
||
return event_type in {"test_passed", "test_failed", "checks_passed"} and any(token in event_text for token in ("smoke", "playwright", "冒烟", "browser", "浏览器"))
|
||
return event_type in {"test_passed", "test_failed", "checks_passed"}
|
||
|
||
def _execution_prompt_payload(
|
||
self,
|
||
*,
|
||
project: Project,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
runtime_target: str,
|
||
note: Optional[str],
|
||
memory: PMFlowProjectMemory,
|
||
) -> dict[str, Any]:
|
||
acceptance = self._task_acceptance_criteria(task)
|
||
truth_facts = memory.truthNodeFacts[:12]
|
||
semantic_facts = memory.semanticFacts[:12]
|
||
quality_contract = self._build_execution_quality_contract(
|
||
project=project,
|
||
task=task,
|
||
repo=repo,
|
||
acceptance=acceptance,
|
||
memory=memory,
|
||
)
|
||
return {
|
||
"project": {
|
||
"id": str(project.id),
|
||
"name": project.title or project.name,
|
||
"goal": project.description or project.title or project.name,
|
||
},
|
||
"task": {
|
||
"id": str(task.id),
|
||
"title": task.title,
|
||
"description": task.description or "",
|
||
"status": task.status,
|
||
"priority": task.priority,
|
||
"charter_id": str(task.truth_node_id),
|
||
"acceptance_criteria": acceptance,
|
||
},
|
||
"repo": None
|
||
if repo is None
|
||
else {
|
||
"id": str(repo.id),
|
||
"provider": repo.provider,
|
||
"full_name": f"{repo.owner}/{repo.repo_name}",
|
||
"default_branch": repo.default_branch,
|
||
"status": repo.status,
|
||
},
|
||
"runtime_target": runtime_target,
|
||
"note": note,
|
||
"project_memory": {
|
||
"truth_node_facts": truth_facts,
|
||
"semantic_facts": semantic_facts,
|
||
"source_summary": memory.sourceSummary,
|
||
},
|
||
"quality_contract": quality_contract,
|
||
"boundary": {
|
||
"execution": "PMFlow only creates the handoff contract and progress event. Local commands must run through Local Bridge or a compatible MCP host.",
|
||
"host_config": "PMFlow backend must not write host ~/.codex/config.toml or execute local stdio commands directly.",
|
||
},
|
||
}
|
||
|
||
def _execution_prompt_text(self, payload: dict[str, Any]) -> str:
|
||
project = self._payload(payload.get("project"))
|
||
task = self._payload(payload.get("task"))
|
||
repo = payload.get("repo") if isinstance(payload.get("repo"), dict) else None
|
||
memory = self._payload(payload.get("project_memory"))
|
||
truth_facts = list(memory.get("truth_node_facts") or [])[:6]
|
||
semantic_facts = list(memory.get("semantic_facts") or [])[:6]
|
||
acceptance = list(task.get("acceptance_criteria") or [])
|
||
quality_contract = self._payload(payload.get("quality_contract"))
|
||
test_strategy = self._payload(quality_contract.get("testStrategy"))
|
||
environment = self._payload(quality_contract.get("environment"))
|
||
test_cases = [item for item in list(quality_contract.get("testCases") or []) if isinstance(item, dict)]
|
||
lines = [
|
||
"# PMFlow Execution Handoff",
|
||
"",
|
||
f"Project: {project.get('name') or project.get('id')}",
|
||
f"Goal: {project.get('goal') or '未填写项目目标'}",
|
||
"",
|
||
"## Task",
|
||
f"- ID: {task.get('id')}",
|
||
f"- Title: {task.get('title')}",
|
||
f"- Description: {task.get('description') or '未填写任务描述'}",
|
||
f"- Status: {task.get('status')}",
|
||
f"- Priority: {task.get('priority')}",
|
||
"",
|
||
"## Acceptance Criteria",
|
||
]
|
||
lines.extend([f"- {item}" for item in acceptance] or ["- 未提供验收标准;执行前需要人工补齐。"])
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## Quality Contract",
|
||
f"- Required levels: {', '.join(str(item) for item in list(test_strategy.get('requiredLevels') or [])) or 'unit, smoke'}",
|
||
f"- Release blocking levels: {', '.join(str(item) for item in list(test_strategy.get('releaseBlockingLevels') or [])) or 'unit, smoke'}",
|
||
"",
|
||
"### Required Test Cases",
|
||
]
|
||
)
|
||
if test_cases:
|
||
for item in test_cases[:8]:
|
||
command = str(item.get("command") or "按测试环境执行").strip()
|
||
lines.append(
|
||
f"- [{item.get('level')}] {item.get('title')} | required={bool(item.get('required'))} | blocks_release={bool(item.get('blocksRelease'))} | command: {command}"
|
||
)
|
||
else:
|
||
lines.append("- 未生成测试合同;执行前必须人工补齐单测、冒烟和联调要求。")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"### Test Environment",
|
||
f"- Services: {', '.join(str(item) for item in list(environment.get('services') or [])) or 'backend, frontend'}",
|
||
f"- Required env vars: {', '.join(str(item) for item in list(environment.get('envVars') or [])) or 'none'}",
|
||
f"- Mock allowed: {', '.join(str(item) for item in list(environment.get('canUseMock') or [])) or 'none'}",
|
||
f"- Must use real data: {', '.join(str(item) for item in list(environment.get('mustUseReal') or [])) or 'tenant/project/task ownership'}",
|
||
"",
|
||
"### Evidence Return Format",
|
||
"- Return changed files, commands run, pass/fail status, skipped tests with reason, branch/commit/PR, and screenshots when UI changed.",
|
||
"- Missing required test evidence must be reported as blocked; do not claim acceptance or release readiness.",
|
||
]
|
||
)
|
||
lines.extend(["", "## Repository"])
|
||
if repo is None:
|
||
lines.append("- No repo binding is available. Create a handoff draft only; do not infer repository, branch, or local path.")
|
||
else:
|
||
lines.extend(
|
||
[
|
||
f"- Provider: {repo.get('provider')}",
|
||
f"- Repository: {repo.get('full_name')}",
|
||
f"- Default branch: {repo.get('default_branch')}",
|
||
f"- Binding status: {repo.get('status')}",
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## Runtime Target",
|
||
f"- {payload.get('runtime_target')}",
|
||
]
|
||
)
|
||
if payload.get("note"):
|
||
lines.extend(["", "## Operator Note", str(payload.get("note"))])
|
||
rework_contract = self._payload(payload.get("rework_contract"))
|
||
rework_items = [item for item in list(rework_contract.get("reviewItems") or []) if isinstance(item, dict)]
|
||
if rework_contract:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## Artifact Rework Contract",
|
||
"- This is a rework handoff package. Resolve the listed delivery evidence gaps before returning results.",
|
||
f"- Required callback: {self._payload(rework_contract.get('requiredCallback')).get('endpoint') or '/api/pmflow-os/runtime/execution-results'}",
|
||
"",
|
||
"### Review Items",
|
||
]
|
||
)
|
||
if rework_items:
|
||
for item in rework_items[:12]:
|
||
expected = ", ".join(str(value) for value in list(item.get("expectedEvidence") or [])) or "updated delivery_artifacts"
|
||
lines.append(
|
||
f"- {item.get('id')}: {item.get('title')} | severity={item.get('severity')} | expected: {expected} | action: {item.get('recommendedAction')}"
|
||
)
|
||
else:
|
||
lines.append("- No review items were selected; stop and ask PMFlow for a refreshed package.")
|
||
expected_evidence = list(rework_contract.get("expectedEvidence") or [])
|
||
if expected_evidence:
|
||
lines.extend(["", "### Expected Evidence"])
|
||
lines.extend(f"- {item}" for item in expected_evidence[:20])
|
||
lines.extend(["", "## Project Memory"])
|
||
if truth_facts:
|
||
lines.append("Truth Nodes:")
|
||
lines.extend(
|
||
f"- {str(item.get('title') or item.get('id'))}: {str(item.get('summary') or item.get('status') or '')}"
|
||
for item in truth_facts
|
||
if isinstance(item, dict)
|
||
)
|
||
if semantic_facts:
|
||
lines.append("Semantic Facts:")
|
||
lines.extend(
|
||
f"- {str(item.get('namespace') or 'fact')}.{str(item.get('key') or item.get('id'))}: {str(item.get('value') or '')}"
|
||
for item in semantic_facts
|
||
if isinstance(item, dict)
|
||
)
|
||
if not truth_facts and not semantic_facts:
|
||
lines.append("- Project Memory has no truth/semantic facts for this task yet.")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"## Boundary",
|
||
"- Do not execute local commands from PMFlow backend.",
|
||
"- Use Local Bridge, Codex, GitHub agent, or manual runtime according to the selected target.",
|
||
"- Rework packages still only return execution metadata; they do not write Project Memory or publish releases.",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def _handoff_payload(
|
||
self,
|
||
*,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
runtime_target: str,
|
||
note: Optional[str],
|
||
prompt_package_id: int,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"workflow_state": "pushed",
|
||
"handoff_state": "draft" if repo is None else "ready",
|
||
"runtime_target": runtime_target,
|
||
"prompt_package_id": str(prompt_package_id),
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": None if repo is None else str(repo.id),
|
||
"repository_full_name": None if repo is None else f"{repo.owner}/{repo.repo_name}",
|
||
"note": note,
|
||
"boundary": "PMFlow backend creates metadata only; execution must be picked up by Local Bridge, Codex, GitHub agent, or manual operator.",
|
||
}
|
||
|
||
def _handoff_summary(self, *, task: Task, repo: Optional[RepoBinding], runtime_target: str) -> str:
|
||
target = f"{repo.owner}/{repo.repo_name}" if repo is not None else "未绑定仓库"
|
||
return f"执行交接已创建:{task.title} -> {runtime_target} / {target}"
|
||
|
||
def _handoff_consumed_payload(
|
||
self,
|
||
*,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
runtime_target: str,
|
||
note: Optional[str],
|
||
prompt_package_id: int,
|
||
consumer_label: str,
|
||
consumed_at: str,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"workflow_state": "coding",
|
||
"handoff_state": "consumed",
|
||
"runtime_target": runtime_target,
|
||
"consumer_label": consumer_label,
|
||
"consumed_at": consumed_at,
|
||
"prompt_package_id": str(prompt_package_id),
|
||
"task_id": str(task.id),
|
||
"repo_binding_id": None if repo is None else str(repo.id),
|
||
"repository_full_name": None if repo is None else f"{repo.owner}/{repo.repo_name}",
|
||
"note": note,
|
||
"boundary": "PMFlow records handoff pickup only; command execution remains outside backend runtime.",
|
||
}
|
||
|
||
def _handoff_consumed_summary(self, *, task: Task, runtime_target: str, consumer_label: str) -> str:
|
||
return f"执行交接已领取:{task.title} -> {runtime_target} / {consumer_label}"
|
||
|
||
def _progress_event_payload(
|
||
self,
|
||
*,
|
||
body: PMFlowExecutionProgressEventRequest,
|
||
repo: Optional[RepoBinding],
|
||
) -> dict[str, Any]:
|
||
workflow_state = body.workflow_state or body.execution_status or self._default_workflow_state(body.event_type)
|
||
repo_full_name = f"{repo.owner}/{repo.repo_name}" if repo is not None else None
|
||
execution_result = {
|
||
"protocol": "execution_result_protocol_v1",
|
||
"eventType": body.event_type,
|
||
"source": body.source,
|
||
"status": body.execution_status or workflow_state,
|
||
"branchName": body.branch_name,
|
||
"commitSha": body.commit_sha,
|
||
"prNumber": body.pr_number,
|
||
"testSummary": body.test_summary,
|
||
"blockerSummary": body.blocker_summary,
|
||
"repositoryFullName": repo_full_name,
|
||
}
|
||
artifacts = body.delivery_artifacts.model_dump(mode="json") if body.delivery_artifacts is not None else None
|
||
if artifacts is not None:
|
||
execution_result["delivery_artifacts"] = artifacts
|
||
payload: dict[str, Any] = {
|
||
"protocol_version": "execution_result_protocol_v1",
|
||
"workflow_state": workflow_state,
|
||
"source": body.source,
|
||
"execution_result": {
|
||
key: value
|
||
for key, value in execution_result.items()
|
||
if value is not None and (not isinstance(value, str) or value.strip())
|
||
},
|
||
"boundary": "PMFlow records execution result metadata only; command execution stays in Local Bridge, Codex, GitHub agent, or manual runtime.",
|
||
}
|
||
for key, value in {
|
||
"prompt_package_id": getattr(body, "prompt_package_id", None),
|
||
"branch_name": body.branch_name,
|
||
"commit_sha": body.commit_sha,
|
||
"pr_number": body.pr_number,
|
||
"test_summary": body.test_summary,
|
||
"blocker_summary": body.blocker_summary,
|
||
"execution_status": body.execution_status,
|
||
"delivery_id": body.delivery_id,
|
||
"event_source_id": body.event_source_id,
|
||
}.items():
|
||
if value is not None and str(value).strip():
|
||
payload[key] = value
|
||
if artifacts is not None:
|
||
payload["delivery_artifacts"] = artifacts
|
||
if repo is not None:
|
||
payload["repo_binding_id"] = str(repo.id)
|
||
payload["repository_full_name"] = f"{repo.owner}/{repo.repo_name}"
|
||
return payload
|
||
|
||
def _progress_event_summary(
|
||
self,
|
||
*,
|
||
body: PMFlowExecutionProgressEventRequest,
|
||
task: Task,
|
||
repo: Optional[RepoBinding],
|
||
) -> str:
|
||
if body.summary:
|
||
return body.summary
|
||
if body.blocker_summary:
|
||
return body.blocker_summary
|
||
if body.test_summary:
|
||
return body.test_summary
|
||
repo_label = f"{repo.owner}/{repo.repo_name}" if repo is not None else "未绑定仓库"
|
||
detail = body.commit_sha or (f"PR #{body.pr_number}" if body.pr_number else body.branch_name) or self._default_workflow_state(body.event_type)
|
||
return f"执行结果回写:{task.title} / {body.event_type} / {repo_label} / {detail}"
|
||
|
||
def _default_workflow_state(self, event_type: str) -> str:
|
||
value = str(event_type or "").strip().lower()
|
||
if value == "branch_created":
|
||
return "pushed"
|
||
if value == "commit":
|
||
return "coding"
|
||
if value in {"pr_opened", "pr_synced"}:
|
||
return "in_review"
|
||
if value in {"test_passed", "test_failed", "checks_passed"}:
|
||
return "testing"
|
||
if value in {"merged", "accepted"}:
|
||
return "synced"
|
||
if value in {"blocked", "failed"}:
|
||
return "failed"
|
||
return "manual_progress"
|
||
|
||
def _latest_iso(self, values: list[Optional[datetime]]) -> Optional[str]:
|
||
valid = [value for value in values if value is not None]
|
||
if not valid:
|
||
return None
|
||
return max(valid).isoformat()
|
||
|
||
def _jsonable(self, value: Any) -> Any:
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
return value
|