"""PMFlow OS read-only projection service.""" from __future__ import annotations from datetime import datetime 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 ( FeedbackIngest, IterationRecommendation, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, PMFlowApproval, PMFlowChangeItem, PMFlowChangeSet, PMFlowEvidenceLink, PMFlowThreadMessage, PMFlowThread as PMFlowThreadModel, ProgressEvent, Project, ProjectModelBinding, PromptPackage, ReleaseValidationRun, RepoBinding, RevenueEvent, ROISnapshot, SemanticFact, Task, TruthNode, User, ) from ..schemas.pmflow_os import ( ExecutionSummaryProjection, PMFlowDecision, PMFlowExecutionHandoffConsumeRequest, PMFlowExecutionHandoffRequest, PMFlowExecutionProgressEventRequest, PMFlowExecutionContext, PMFlowFeedEvent, PMFlowIterationRecommendationChangeSetDraftRequest, PMFlowMetricSignal, PMFlowModelDecision, PMFlowProjectMemory, PMFlowProjectSummary, PMFlowRelease, PMFlowReleaseFeedbackIngestRequest, PMFlowReleaseValidationRunCreateRequest, PMFlowRequirement, PMFlowRisk, PMFlowTask, PMFlowTestRun, PMFlowThread, ProjectTruthStateProjection, ) from .ai_project_control_service import AIProjectControlService 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"]) 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.") return ProjectTruthStateProjection( loopStage=loop_stage, lastWritebackAt=self._latest_iso([node.updated_at for node in truth_nodes] + [task.updated_at for task, _ in tasks]), 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, feed=self._project_feed( execution_summary=execution_summary, test_runs=test_runs, releases=releases, metric_signals=metric_signals, ), approvals=review_projection["approvals"], activeChangeSetId=active_change_set_id, selectedTaskId=execution_summary.execution.taskId, execution=execution_summary.execution, projectMemory=memory, projectionWarnings=warnings + execution_summary.warnings, ) 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) release_gate = self._build_release_gate(events=events, deliveries=deliveries) 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, ), 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, 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)) 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_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 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 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, 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 _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_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_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 _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 _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()) recommendations = [self._serialize_iteration_recommendation(item) for item in recommendation_rows] 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] 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 recommendations: warnings.append("iteration_recommendations_empty: no iteration recommendation has been generated.") serialized_times = [ str(item.get("updatedAt") or item.get("createdAt")) for item in [*feedback_items, *recommendations, *revenue_events] if item.get("updatedAt") or item.get("createdAt") ] return { "status": "active" if feedback_items or revenue_events 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), }, "summary": { "feedbackCount": len(feedback_items), "revenueEventCount": len(revenue_events), "iterationRecommendationCount": len(recommendations), "latestSignalAt": max(serialized_times) if serialized_times else None, }, "feedback": feedback_items, "revenueEvents": revenue_events, "recommendations": recommendations, "nextActions": self._release_signal_next_actions( feedback_items=feedback_items, 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 _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 _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_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()) 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": [ PMFlowChangeReviewService.serialize_change_set(row, items_by_set.get(int(row.id), [])) 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], } @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 _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, *, execution_summary: ExecutionSummaryProjection, test_runs: list[PMFlowTestRun], releases: list[PMFlowRelease], metric_signals: list[PMFlowMetricSignal], ) -> list[PMFlowFeedEvent]: feed: list[PMFlowFeedEvent] = [] for event in execution_summary.recentProgressEvents[:5]: feed.append( PMFlowFeedEvent( id=f"progress-{event.get('id')}", kind="execution", title=event.get("event_type") or "执行进展", summary=event.get("summary") or event.get("workflow_state") or "执行事件已回写", timestamp=str(event.get("occurred_at") or ""), ) ) 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 feed 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 _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" return str(event.get("workflow_state") or event.get("summary") or event.get("event_type") or "progress event recorded") def _build_release_gate(self, *, events: list[dict[str, Any]], deliveries: list[dict[str, Any]]) -> dict[str, Any]: evidence_events = [event for event in events if self._is_release_evidence_event(event)] 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": [], "updatedAt": None, } failed_events = [event for event in evidence_events if self._is_failed_evidence_event(event)] 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), "evidence": self._release_gate_evidence(evidence_events), "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) 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), "evidence": self._release_gate_evidence(evidence_events), "updatedAt": str(evidence_events[0].get("occurred_at") or "") or None, } if has_test_passed and has_synced: return { "status": "ready", "title": "可发布", "summary": "已有 PR/合并与通过测试证据,发布门禁给出可发布判断。", "blockingReasons": [], "nextActions": ["由负责人进行最终人工确认;PMFlow 不自动执行发布命令。"], "checks": self._release_gate_checks(evidence_events), "evidence": self._release_gate_evidence(evidence_events), "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), "evidence": self._release_gate_evidence(evidence_events), "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), "evidence": self._release_gate_evidence(evidence_events), "updatedAt": str(evidence_events[0].get("occurred_at") or "") or None, } def _release_gate_checks(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ { "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 any(self._is_failed_evidence_event(event) for event in events) else "passed", "detail": self._first_event_detail(events, {"failed", "blocked", "test_failed"}) or "未发现失败回写", }, ] 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_text(self, event: dict[str, Any]) -> str: return " ".join( [ self._event_type(event), str(event.get("summary") or ""), str(event.get("workflow_state") or ""), str(event.get("derived_task_status") or ""), ] ).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", } 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) -> 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) 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, "createdAt": recommendation.created_at.isoformat() if recommendation.created_at else None, "updatedAt": recommendation.updated_at.isoformat() if recommendation.updated_at else None, } 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 _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 _release_signal_next_actions( self, *, feedback_items: 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 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 / compliance 信号,为下一轮 iteration recommendation 做准备。") return actions 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 _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] 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, }, "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 []) 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(["", "## 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"))]) 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.", ] ) 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]: payload: dict[str, Any] = { "workflow_state": body.workflow_state or self._default_workflow_state(body.event_type), "source": body.source, "boundary": "PMFlow records execution result metadata only; command execution stays in Local Bridge, Codex, GitHub agent, or manual runtime.", } for key, value in { "branch_name": body.branch_name, "commit_sha": body.commit_sha, "pr_number": body.pr_number, "test_summary": body.test_summary, "event_source_id": body.event_source_id, }.items(): if value is not None and str(value).strip(): payload[key] = value 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.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