"""AI 应用项目控制面服务。""" from __future__ import annotations import hashlib import hmac import json import os from dataclasses import dataclass from datetime import datetime from typing import Any, Optional from urllib import request as urllib_request from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..core.config import settings from ..models import ( Budget, ComplianceCheckRun, EvidencePack, FeedbackIngest, GitHubAppInstallation, GitHubAppRepository, GitHubWebhookDelivery, IterationRecommendation, ModelCatalogItem, ModelEvalRun, ModelRecommendationSnapshot, ModelRequirementBinding, ModelUsageEvent, ModelVersion, ProgressEvent, Project, ProjectModelBinding, PromptPackage, ReleaseValidationRun, RevenueEvent, RepoBinding, ROISnapshot, Task, TaskCodeLink, TruthNode, User, UserProject, ) from ..schemas import TaskBreakdownBusinessObject, TaskBreakdownPayload from ..utils.timezone import now_cn_naive _MODEL_TASK_BLUEPRINTS = ( { "task_key": "model-integration", "title": "模型接入与调用封装", "description": "完成绑定模型的调用封装、降级策略和异常处理。", "type": "model_integration", "estimated_hours": 6, "required_skill_names": ["AI 集成"], "risk_level": "medium", "priority": "high", }, { "task_key": "prompt-optimization", "title": "Prompt 优化与边界约束", "description": "结合宪章约束、模型边界和验收标准整理 Prompt Package。", "type": "prompt_optimization", "estimated_hours": 4, "required_skill_names": ["提示词工程"], "risk_level": "low", "priority": "medium", }, { "task_key": "model-eval", "title": "模型效果验证与回归用例", "description": "补齐模型效果验证、失败样本和回归用例,形成发布前验证基线。", "type": "model_validation", "estimated_hours": 5, "required_skill_names": ["质量保障"], "risk_level": "medium", "priority": "high", }, ) @dataclass(slots=True) class RecommendationContext: project: Project charter: Optional[TruthNode] budget_amount_usd: Optional[float] estimated_daily_tokens: int required_modalities: list[str] desired_tags: list[str] charter_summary: str class AIProjectControlService: def __init__(self, db: AsyncSession, *, tenant_id: int): self.db = db self.tenant_id = int(tenant_id) async def ensure_catalog_seeded(self) -> None: result = await self.db.execute( select(func.count(ModelCatalogItem.id)).where(ModelCatalogItem.tenant_id == self.tenant_id) ) if int(result.scalar() or 0) > 0: return seed_rows = [ ModelCatalogItem( tenant_id=self.tenant_id, provider="阿里云", model_key="qwen-plus", display_name="通义千问 Plus", version="latest", modalities=["text", "image"], capability_tags=["general", "rag", "reasoning", "agent", "code"], strengths=["中文通用能力强", "RAG 适配稳定", "工具调用成熟"], limitations=["不适合视频生成"], compliance_status="ready", filing_status="filed", input_cost_per_mtok=0.8, output_cost_per_mtok=2.0, official_docs_url="https://help.aliyun.com/zh/model-studio/", is_active=True, ), ModelCatalogItem( tenant_id=self.tenant_id, provider="火山引擎", model_key="doubao-pro", display_name="豆包 Pro", version="latest", modalities=["text", "image"], capability_tags=["general", "rag", "reasoning", "multimodal"], strengths=["长文本稳", "中文体验好", "成本适中"], limitations=["代码生成弱于专用模型"], compliance_status="ready", filing_status="filed", input_cost_per_mtok=0.8, output_cost_per_mtok=2.0, official_docs_url="https://www.volcengine.com/product/doubao", is_active=True, ), ModelCatalogItem( tenant_id=self.tenant_id, provider="百度智能云", model_key="ernie-4.0-turbo", display_name="文心一言 4.0 Turbo", version="latest", modalities=["text", "image"], capability_tags=["general", "rag", "reasoning", "multimodal"], strengths=["企业落地常见", "多模态均衡"], limitations=["综合成本偏高"], compliance_status="ready", filing_status="filed", input_cost_per_mtok=1.2, output_cost_per_mtok=3.2, official_docs_url="https://cloud.baidu.com/product/wenxinworkshop", is_active=True, ), ModelCatalogItem( tenant_id=self.tenant_id, provider="DeepSeek", model_key="deepseek-chat", display_name="DeepSeek Chat", version="latest", modalities=["text"], capability_tags=["general", "reasoning", "code", "agent"], strengths=["代码与推理性价比高", "适合技术团队"], limitations=["需要留意合规和交付边界"], compliance_status="review_required", filing_status="unknown", input_cost_per_mtok=0.27, output_cost_per_mtok=1.1, official_docs_url="https://platform.deepseek.com/", is_active=True, ), ModelCatalogItem( tenant_id=self.tenant_id, provider="Moonshot AI", model_key="kimi-k2", display_name="Kimi K2", version="latest", modalities=["text"], capability_tags=["general", "long_context", "rag", "reasoning"], strengths=["长上下文强", "资料理解稳定"], limitations=["对多模态场景支持有限"], compliance_status="ready", filing_status="filed", input_cost_per_mtok=1.0, output_cost_per_mtok=3.0, official_docs_url="https://platform.moonshot.cn/", is_active=True, ), ] self.db.add_all(seed_rows) await self.db.commit() async def build_recommendation_context( self, *, project_id: int, charter_id: Optional[int] = None, ) -> RecommendationContext: await self.ensure_catalog_seeded() project_result = await self.db.execute( select(Project).where(and_(Project.id == project_id, Project.tenant_id == self.tenant_id)) ) project = project_result.scalar_one_or_none() if not project: raise ValueError("项目不存在") charter = None if charter_id is not None: charter_result = await self.db.execute( select(TruthNode).where( and_( TruthNode.id == int(charter_id), TruthNode.project_id == int(project_id), TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "charter", ) ) ) charter = charter_result.scalar_one_or_none() budget_amount_usd = await self._load_budget_amount(project_id=project_id) charter_summary = self._flatten_charter(project=project, charter=charter) required_modalities = self._detect_required_modalities(charter_summary) desired_tags = self._detect_desired_tags(charter_summary) estimated_daily_tokens = self._estimate_daily_tokens(charter_summary) return RecommendationContext( project=project, charter=charter, budget_amount_usd=budget_amount_usd, estimated_daily_tokens=estimated_daily_tokens, required_modalities=required_modalities, desired_tags=desired_tags, charter_summary=charter_summary, ) async def recommend_models( self, *, project_id: int, charter_id: Optional[int] = None, ) -> dict[str, Any]: context = await self.build_recommendation_context(project_id=project_id, charter_id=charter_id) catalog_result = await self.db.execute( select(ModelCatalogItem) .where(and_(ModelCatalogItem.tenant_id == self.tenant_id, ModelCatalogItem.is_active.is_(True))) .order_by(ModelCatalogItem.provider.asc(), ModelCatalogItem.display_name.asc()) ) items = list(catalog_result.scalars().all()) recommendations = [self._score_model(item, context) for item in items] recommendations.sort(key=lambda item: (item["total_score"], item["capability_score"]), reverse=True) top_recommendations = recommendations[:3] top_item_id = top_recommendations[0]["catalog_item_id"] if top_recommendations else None snapshot = ModelRecommendationSnapshot( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter_id) if charter_id is not None else None, recommendation_scope="charter" if charter_id is not None else "project", snapshot_input={ "budget_amount_usd": context.budget_amount_usd, "estimated_daily_tokens": context.estimated_daily_tokens, "required_modalities": context.required_modalities, "desired_tags": context.desired_tags, "charter_summary": context.charter_summary[:1000], }, recommendations=top_recommendations, top_model_catalog_item_id=top_item_id, generated_by="rule_engine", ) self.db.add(snapshot) await self.db.commit() await self.db.refresh(snapshot) active_binding = await self.get_active_binding_summary(project_id=project_id) return { "project_id": str(project_id), "charter_id": str(charter_id) if charter_id is not None else None, "snapshot_id": str(snapshot.id), "active_binding": active_binding, "inferred_requirements": { "required_modalities": context.required_modalities, "desired_tags": context.desired_tags, "estimated_daily_tokens": context.estimated_daily_tokens, "budget_amount_usd": context.budget_amount_usd, }, "recommendations": top_recommendations, } async def bind_model( self, *, project_id: int, catalog_item_id: int, selected_by_user_id: Optional[int], charter_id: Optional[int] = None, estimated_daily_tokens: int = 0, selection_reason: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: item = await self._load_catalog_item(catalog_item_id) existing_result = await self.db.execute( select(ProjectModelBinding).where( and_( ProjectModelBinding.tenant_id == self.tenant_id, ProjectModelBinding.project_id == int(project_id), ProjectModelBinding.status == "active", ) ) ) now = now_cn_naive() for binding in existing_result.scalars().all(): binding.status = "archived" binding.archived_at = now estimated_monthly_cost_usd = self._estimate_monthly_cost( estimated_daily_tokens=estimated_daily_tokens, input_cost_per_mtok=float(item.input_cost_per_mtok or 0.0), output_cost_per_mtok=float(item.output_cost_per_mtok or 0.0), ) binding = ProjectModelBinding( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter_id) if charter_id is not None else None, model_catalog_item_id=int(catalog_item_id), selected_by_user_id=int(selected_by_user_id) if selected_by_user_id is not None else None, status="active", selection_reason=dict(selection_reason or {}), estimated_daily_tokens=max(int(estimated_daily_tokens or 0), 0), estimated_monthly_cost_usd=estimated_monthly_cost_usd, active_from=now, ) self.db.add(binding) await self.db.flush() if charter_id is not None: await self._sync_legacy_requirement_binding( charter_id=int(charter_id), catalog_item=item, estimated_cost=estimated_monthly_cost_usd, selection_reason=dict(selection_reason or {}), ) await self.db.commit() await self.db.refresh(binding) return await self.get_active_binding_summary(project_id=project_id) async def get_active_binding_summary(self, *, project_id: int) -> dict[str, Any] | None: result = 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), ProjectModelBinding.status == "active", ) ) .order_by(ProjectModelBinding.created_at.desc()) ) row = result.first() if not row: return None binding, item = row return self._serialize_binding(binding, item) async def get_model_usage(self, *, project_id: int) -> dict[str, Any]: active_binding = await self.get_active_binding_summary(project_id=project_id) binding_id = int(active_binding["id"]) if active_binding else None today = now_cn_naive().replace(hour=0, minute=0, second=0, microsecond=0) month_start = today.replace(day=1) def _summary_row(from_dt): base = select( func.coalesce(func.sum(ModelUsageEvent.input_tokens), 0), func.coalesce(func.sum(ModelUsageEvent.output_tokens), 0), func.coalesce(func.sum(ModelUsageEvent.cost_usd), 0.0), ).where( and_( ModelUsageEvent.tenant_id == self.tenant_id, ModelUsageEvent.project_id == int(project_id), ModelUsageEvent.occurred_at.is_not(None), ModelUsageEvent.occurred_at >= from_dt, ) ) if binding_id is not None: base = base.where(ModelUsageEvent.project_model_binding_id == binding_id) return base today_row = (await self.db.execute(_summary_row(today))).one() month_row = (await self.db.execute(_summary_row(month_start))).one() package_result = await self.db.execute( select(func.count(PromptPackage.id)).where( and_( PromptPackage.tenant_id == self.tenant_id, PromptPackage.project_id == int(project_id), ) ) ) latest_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.created_at.desc()) .limit(1) ) latest_eval = latest_eval_result.scalar_one_or_none() return { "active_binding": active_binding, "summary": { "today": self._serialize_usage_row(today_row), "month": self._serialize_usage_row(month_row), "prompt_package_count": int(package_result.scalar() or 0), "estimated_monthly_cost_usd": active_binding.get("estimated_monthly_cost_usd") if active_binding else None, }, "latest_eval": None if latest_eval is None else { "id": str(latest_eval.id), "status": latest_eval.status, "score": float(latest_eval.score or 0.0) if latest_eval.score is not None else None, "summary": dict(latest_eval.summary or {}), "created_at": latest_eval.created_at.isoformat() if latest_eval.created_at else None, }, } async def create_prompt_package( self, *, project_id: int, charter_id: int, generated_by_user_id: Optional[int], task_id: Optional[int] = None, ) -> dict[str, Any]: context = await self.build_recommendation_context(project_id=project_id, charter_id=charter_id) task = None if task_id is not None: task_result = await self.db.execute( select(Task).where( and_( Task.id == int(task_id), Task.project_id == int(project_id), Task.tenant_id == self.tenant_id, ) ) ) task = task_result.scalar_one_or_none() if task is None: raise ValueError("任务不存在") binding = await self.get_active_binding_summary(project_id=project_id) package_payload = { "project": { "id": str(context.project.id), "title": context.project.title or context.project.name, }, "charter": { "id": str(charter_id), "summary": context.charter_summary[:1500], "required_modalities": context.required_modalities, "desired_tags": context.desired_tags, }, "task": None if task is None else { "id": str(task.id), "title": task.title, "description": task.description, "status": task.status, "priority": task.priority, }, "model_binding": binding, "acceptance": self._extract_acceptance_criteria(context.charter), "code_constraints": [ "遵循现有组件、目录和命名规范", "先做最小可用实现,再补验证和边界处理", "任何额外功能必须先确认是否在宪章范围内", ], } package_text = self._render_prompt_package(package_payload) binding_id = int(binding["id"]) if binding else None prompt_package = PromptPackage( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter_id), task_id=int(task_id) if task_id is not None else None, project_model_binding_id=binding_id, package_kind="development", status="active", package_text=package_text, package_payload=package_payload, generated_from={ "generated_by_user_id": int(generated_by_user_id) if generated_by_user_id is not None else None, "source": "pmflow", }, ) self.db.add(prompt_package) await self.db.commit() await self.db.refresh(prompt_package) return { "id": str(prompt_package.id), "project_id": str(project_id), "charter_id": str(charter_id), "task_id": str(task_id) if task_id is not None else None, "status": prompt_package.status, "package_kind": prompt_package.package_kind, "package_text": prompt_package.package_text, "package_payload": prompt_package.package_payload, "created_at": prompt_package.created_at.isoformat() if prompt_package.created_at else None, } async def create_charter_prompt_package( self, *, project_id: int, charter_id: int, generated_by_user_id: Optional[int], ) -> dict[str, Any]: return await self.create_prompt_package( project_id=project_id, charter_id=charter_id, generated_by_user_id=generated_by_user_id, task_id=None, ) async def run_release_validation( self, *, project_id: int, actor_user_id: Optional[int], charter_id: Optional[int] = None, release_node_id: Optional[int] = None, ) -> dict[str, Any]: charter = await self._load_charter_for_project(project_id=project_id, charter_id=charter_id) context = await self.build_recommendation_context( project_id=project_id, charter_id=int(charter.id) if charter is not None else None, ) usage = await self.get_model_usage(project_id=project_id) binding = usage.get("active_binding") binding_id = self._safe_int((binding or {}).get("id")) release_node = await self._load_release_for_project(project_id=project_id, release_node_id=release_node_id) success_criteria = self._extract_acceptance_criteria(charter) or [ "本轮发布必须满足宪章核心目标,并具备可回滚方案。" ] risk_items: list[dict[str, Any]] = [] if not binding: risk_items.append( { "key": "model_binding_missing", "severity": "high", "title": "项目未绑定主模型", "detail": "当前无法形成稳定的发布验证基线,请先完成模型绑定。", } ) if int(usage.get("summary", {}).get("prompt_package_count") or 0) <= 0: risk_items.append( { "key": "prompt_package_missing", "severity": "medium", "title": "尚未生成 Prompt Package", "detail": "开发约束没有固化,代码与宪章容易继续偏离。", } ) estimated_month_cost = float((usage.get("summary", {}) or {}).get("estimated_monthly_cost_usd") or 0.0) current_month_cost = float(((usage.get("summary", {}) or {}).get("month") or {}).get("cost_usd") or 0.0) if estimated_month_cost > 0 and current_month_cost >= estimated_month_cost * 0.8: risk_items.append( { "key": "cost_near_budget", "severity": "medium", "title": "模型成本接近预估阈值", "detail": f"本月模型成本已到 ${current_month_cost:.2f},接近预估 ${estimated_month_cost:.2f}。", } ) if binding and str(binding.get("compliance_status") or "").lower() not in {"ready", "registered"}: risk_items.append( { "key": "model_compliance_review", "severity": "medium", "title": "模型合规状态需要人工复核", "detail": f"当前模型备案/合规状态为 {binding.get('filing_status') or 'unknown'}。", } ) if not success_criteria: risk_items.append( { "key": "success_criteria_missing", "severity": "medium", "title": "宪章缺少明确成功指标", "detail": "无法对发布结果做可复用的结构化验证。", } ) severity_penalty = {"high": 30, "medium": 15, "low": 8} score = max(0, 100 - sum(severity_penalty.get(str(item.get("severity") or "low"), 8) for item in risk_items)) if any(str(item.get("severity")) == "high" for item in risk_items): status = "blocked" elif risk_items: status = "needs_attention" else: status = "passed" key_metrics = { "prompt_package_count": int(usage.get("summary", {}).get("prompt_package_count") or 0), "today_cost_usd": float(((usage.get("summary", {}) or {}).get("today") or {}).get("cost_usd") or 0.0), "month_cost_usd": current_month_cost, "estimated_monthly_cost_usd": estimated_month_cost, "active_model": None if not binding else binding.get("display_name"), } rollback_plan = { "ready": True, "steps": [ "保留上一稳定版本的模型与 Prompt 配置。", "发布异常时先回退到上一版本分支或发布节点。", "回退后补采样本,更新 Prompt Package 与验证基线。", ], } success_criteria_hits = self._score_success_criteria_hits( success_criteria=success_criteria, usage=usage, prompt_package_count=int(usage.get("summary", {}).get("prompt_package_count") or 0), has_binding=bool(binding), ) allow_release = status == "passed" blocking_reason = None if not allow_release: blocking_reason = "存在高风险或关键基线缺失,当前发布不允许放行。" validation_run = ReleaseValidationRun( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter.id) if charter is not None else None, release_node_id=int(release_node.id) if release_node is not None else None, project_model_binding_id=binding_id, status=status, score=score, key_metrics=key_metrics, risk_items=risk_items, rollback_plan=rollback_plan, validation_summary={ "success_criteria": success_criteria, "success_criteria_hits": success_criteria_hits, "release_title": getattr(release_node, "title", None), "decision": status, "allow_release": allow_release, "blocking_reason": blocking_reason, "generated_by_user_id": int(actor_user_id) if actor_user_id is not None else None, }, ) self.db.add(validation_run) await self.db.flush() remediation_task = None remediation_prompt = None if not allow_release and charter is not None: remediation_task = await self._create_validation_fix_task( project_id=project_id, charter=charter, validation_run=validation_run, ) await self.db.commit() await self.db.refresh(validation_run) if remediation_task is not None and charter is not None: remediation_prompt = await self.create_prompt_package( project_id=project_id, charter_id=int(charter.id), generated_by_user_id=actor_user_id, task_id=int(remediation_task.id), ) payload = self._serialize_release_validation_run(validation_run) payload["allow_release"] = allow_release payload["blocking_reason"] = blocking_reason payload["remediation_task"] = None if remediation_task is None else { "id": str(remediation_task.id), "title": remediation_task.title, "status": remediation_task.status, "priority": remediation_task.priority, } payload["remediation_prompt_package"] = remediation_prompt return payload async def ingest_feedback( self, *, project_id: int, actor_user_id: Optional[int], source_type: str, content: str, title: Optional[str] = None, charter_id: Optional[int] = None, metrics: Optional[dict[str, Any]] = None, samples: Optional[list[dict[str, Any]]] = None, ) -> dict[str, Any]: normalized_content = str(content or "").strip() if not normalized_content: raise ValueError("反馈内容不能为空") charter = await self._load_charter_for_project(project_id=project_id, charter_id=charter_id) ingest = FeedbackIngest( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter.id) if charter is not None else None, ingested_by_user_id=int(actor_user_id) if actor_user_id is not None else None, source_type=str(source_type or "manual").strip() or "manual", title=str(title or "").strip() or None, content=normalized_content, metrics=dict(metrics or {}), samples=list(samples or []), status="active", ) self.db.add(ingest) await self.db.flush() recommendation = self._build_iteration_recommendation( project_id=project_id, charter=charter, ingest=ingest, ) self.db.add(recommendation) await self.db.commit() await self.db.refresh(ingest) await self.db.refresh(recommendation) return { "ingest": self._serialize_feedback_ingest(ingest), "recommendation": self._serialize_iteration_recommendation(recommendation), } async def record_revenue_event( self, *, project_id: int, amount_usd: float, source_type: str, category: str = "other", currency: str = "USD", external_ref: Optional[str] = None, event_payload: Optional[dict[str, Any]] = None, occurred_at: Optional[Any] = None, ) -> dict[str, Any]: event = RevenueEvent( tenant_id=self.tenant_id, project_id=int(project_id), source_type=str(source_type or "manual").strip() or "manual", category=str(category or "other").strip().lower() or "other", currency=str(currency or "USD").upper(), amount_usd=max(float(amount_usd or 0.0), 0.0), external_ref=str(external_ref or "").strip() or None, event_payload=dict(event_payload or {}), occurred_at=self._coerce_time(occurred_at) or now_cn_naive(), ) self.db.add(event) await self.db.commit() await self.db.refresh(event) return { "id": str(event.id), "project_id": str(project_id), "source_type": event.source_type, "category": event.category, "currency": event.currency, "amount_usd": float(event.amount_usd or 0.0), "external_ref": event.external_ref, "occurred_at": event.occurred_at.isoformat() if event.occurred_at else None, "event_payload": dict(event.event_payload or {}), } async def get_roi_snapshots(self, *, project_id: int) -> dict[str, Any]: latest = await self._build_roi_snapshot(project_id=project_id, persist=True) result = await self.db.execute( select(ROISnapshot) .where( and_( ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id), ) ) .order_by(ROISnapshot.created_at.desc()) .limit(6) ) items = [self._serialize_roi_snapshot(item) for item in result.scalars().all()] return {"latest": latest, "items": items} async def run_compliance_check( self, *, project_id: int, actor_user_id: Optional[int], charter_id: Optional[int] = None, release_node_id: Optional[int] = None, ) -> dict[str, Any]: charter = await self._load_charter_for_project(project_id=project_id, charter_id=charter_id) release_node = await self._load_release_for_project(project_id=project_id, release_node_id=release_node_id) binding = await self.get_active_binding_summary(project_id=project_id) latest_validation = await self._load_latest_release_validation(project_id=project_id) content = self._charter_content_dict(charter) checklist = [ self._build_check_item( key="model_bound", label="已绑定主模型", status="passed" if binding else "failed", detail="项目必须先绑定主模型,发布与成本主链才有依据。", ), self._build_check_item( key="model_filing", label="模型备案/登记状态可说明", status="passed" if binding and str(binding.get("filing_status") or "").lower() in {"filed", "registered"} else ("warning" if binding else "failed"), detail=None if not binding else f"当前模型备案状态:{binding.get('filing_status') or 'unknown'}。", ), self._build_check_item( key="data_source_declared", label="数据来源已声明", status="passed" if self._has_any_keys(content, {"data_sources", "data_source", "knowledge_sources"}) else "warning", detail="建议在宪章中补齐知识源、训练数据或输入数据的来源说明。", ), self._build_check_item( key="content_labeling_ready", label="生成内容标识方案已说明", status="passed" if self._has_any_keys(content, {"content_labeling", "ai_content_labeling", "content_notice"}) else "warning", detail="如果产品会向用户展示生成内容,建议在宪章中说明标识策略。", ), self._build_check_item( key="rollback_ready", label="回滚方案已形成", status="passed" if latest_validation and bool((latest_validation.rollback_plan or {}).get("steps")) else "warning", detail="建议先跑一次发布验证,固定回滚步骤和恢复路径。", ), ] issues = [item for item in checklist if item["status"] != "passed"] score = max( 0, 100 - sum( 35 if item["status"] == "failed" else 12 for item in issues ), ) if any(item["status"] == "failed" for item in issues): status = "blocked" elif issues: status = "needs_attention" else: status = "passed" run = ComplianceCheckRun( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter.id) if charter is not None else None, release_node_id=int(release_node.id) if release_node is not None else None, project_model_binding_id=self._safe_int((binding or {}).get("id")), status=status, score=score, checklist=checklist, issues=issues, summary={ "checked_by_user_id": int(actor_user_id) if actor_user_id is not None else None, "issue_count": len(issues), "decision": status, }, ) self.db.add(run) await self.db.flush() pack = EvidencePack( tenant_id=self.tenant_id, project_id=int(project_id), compliance_check_run_id=int(run.id), package_kind="compliance_release", status="draft", content={ "project_id": str(project_id), "charter_id": str(charter.id) if charter is not None else None, "release_node_id": str(release_node.id) if release_node is not None else None, "model_binding": binding, "checklist": checklist, "issues": issues, }, export_payload={ "format": "json", "generated_at": now_cn_naive().isoformat(), }, ) self.db.add(pack) await self.db.commit() await self.db.refresh(run) await self.db.refresh(pack) payload = self._serialize_compliance_check_run(run) payload["evidence_pack"] = self._serialize_evidence_pack(pack) payload["release_gate"] = "blocked" if status == "blocked" else "passable" payload["export_formats"] = ["json", "md"] return payload async def export_latest_compliance_evidence(self, *, project_id: int, export_format: str) -> dict[str, Any]: normalized_format = str(export_format or "json").strip().lower() or "json" latest_check = await self._load_latest_compliance_check(project_id=project_id) if latest_check is None: raise ValueError("当前项目还没有合规检查结果") pack_result = await self.db.execute( select(EvidencePack) .where( and_( EvidencePack.tenant_id == self.tenant_id, EvidencePack.project_id == int(project_id), EvidencePack.compliance_check_run_id == int(latest_check.id), ) ) .order_by(EvidencePack.created_at.desc()) .limit(1) ) pack = pack_result.scalar_one_or_none() if pack is None: raise ValueError("当前项目缺少合规证据包") payload = dict(pack.content or {}) if normalized_format == "md": rendered = self._render_evidence_pack_markdown(payload) pack.export_payload = { **dict(pack.export_payload or {}), "format": "md", "rendered": rendered, "generated_at": now_cn_naive().isoformat(), } await self.db.commit() await self.db.refresh(pack) return { "format": "md", "filename": f"project-{project_id}-compliance-evidence.md", "content": rendered, "metadata": dict(pack.export_payload or {}), } payload_out = { "format": "json", "filename": f"project-{project_id}-compliance-evidence.json", "content": payload, "metadata": dict(pack.export_payload or {}), } return payload_out async def get_dev_tasks_for_user(self, *, user_id: int) -> list[dict[str, Any]]: result = await self.db.execute( select(Task, TruthNode, Project) .join(TruthNode, TruthNode.id == Task.truth_node_id) .join(Project, Project.id == Task.project_id) .where( and_( Task.tenant_id == self.tenant_id, Task.assignee_id == int(user_id), ~Task.status.in_(["done", "closed"]), ) ) .order_by(Task.priority.asc(), Task.created_at.desc()) ) rows = list(result.all()) project_ids = {int(project.id) for _, _, project in rows} binding_map = {} for project_id in project_ids: binding = await self.get_active_binding_summary(project_id=project_id) if binding: binding_map[project_id] = binding prompt_counts = {} if rows: task_ids = [int(task.id) for task, _, _ in rows] prompt_result = await self.db.execute( select(PromptPackage.task_id, func.count(PromptPackage.id)) .where( and_( PromptPackage.tenant_id == self.tenant_id, PromptPackage.task_id.in_(task_ids), ) ) .group_by(PromptPackage.task_id) ) prompt_counts = {int(task_id): int(count) for task_id, count in prompt_result.all()} payload = [] for task, charter, project in rows: payload.append( { "id": str(task.id), "title": task.title, "description": task.description, "status": task.status, "priority": task.priority, "project_id": str(project.id), "project_title": project.title or project.name, "charter_id": str(charter.id), "charter_title": charter.title, "model_binding": binding_map.get(int(project.id)), "prompt_package_count": prompt_counts.get(int(task.id), 0), } ) return payload async def get_project_dev_sync_summary( self, *, project_id: int, current_user_id: Optional[int] = None, limit: int = 12, ) -> dict[str, Any]: repo_result = await self.db.execute( select(RepoBinding) .where( and_( RepoBinding.tenant_id == self.tenant_id, RepoBinding.project_id == int(project_id), RepoBinding.status == "active", ) ) .order_by(RepoBinding.updated_at.desc()) ) repo_bindings = list(repo_result.scalars().all()) task_rows_result = await self.db.execute( select(Task, TruthNode, User) .join(TruthNode, TruthNode.id == Task.truth_node_id) .outerjoin(User, User.id == Task.assignee_id) .where( and_( Task.tenant_id == self.tenant_id, Task.project_id == int(project_id), ~Task.status.in_(["done", "closed"]), ) ) .order_by(Task.updated_at.desc(), Task.created_at.desc()) .limit(20) ) task_rows = list(task_rows_result.all()) task_ids = [int(task.id) for task, _, _ in task_rows] code_link_rows: list[TaskCodeLink] = [] prompt_counts: dict[int, int] = {} if task_ids: code_link_result = await self.db.execute( select(TaskCodeLink) .where( and_( TaskCodeLink.tenant_id == self.tenant_id, TaskCodeLink.task_id.in_(task_ids), ) ) .order_by(TaskCodeLink.updated_at.desc()) ) code_link_rows = list(code_link_result.scalars().all()) prompt_result = await self.db.execute( select(PromptPackage.task_id, func.count(PromptPackage.id)) .where( and_( PromptPackage.tenant_id == self.tenant_id, PromptPackage.task_id.in_(task_ids), ) ) .group_by(PromptPackage.task_id) ) prompt_counts = {int(task_id): int(count) for task_id, count in prompt_result.all()} repo_binding_map = {int(item.id): item for item in repo_bindings} latest_code_link_by_task: dict[int, TaskCodeLink] = {} for link in code_link_rows: task_id = int(link.task_id) if task_id not in latest_code_link_by_task: latest_code_link_by_task[task_id] = link active_binding = await self.get_active_binding_summary(project_id=project_id) active_dev_tasks = [ self._serialize_project_dev_task( task=task, charter=charter, assignee=assignee, prompt_package_count=prompt_counts.get(int(task.id), 0), code_link=latest_code_link_by_task.get(int(task.id)), repo_binding_map=repo_binding_map, active_model_binding=active_binding, ) for task, charter, assignee in task_rows ] progress_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), ) ) .order_by(ProgressEvent.occurred_at.desc(), ProgressEvent.created_at.desc()) .limit(max(1, int(limit))) ) recent_progress_events = [] for event, task, user in progress_result.all(): repo_binding = None if event.repo_binding_id is not None: repo_binding = repo_binding_map.get(int(event.repo_binding_id)) if repo_binding is None: repo_lookup = await self.db.execute( select(RepoBinding).where( and_( RepoBinding.tenant_id == self.tenant_id, RepoBinding.id == int(event.repo_binding_id), ) ) ) repo_binding = repo_lookup.scalar_one_or_none() if repo_binding is not None: repo_binding_map[int(repo_binding.id)] = repo_binding recent_progress_events.append( self._serialize_progress_event_item( event=event, task=task, user=user, repo_binding=repo_binding, ) ) delivery_result = await self.db.execute( select(GitHubWebhookDelivery) .where( and_( GitHubWebhookDelivery.tenant_id == self.tenant_id, GitHubWebhookDelivery.project_id == int(project_id), ) ) .order_by(GitHubWebhookDelivery.received_at.desc(), GitHubWebhookDelivery.created_at.desc()) .limit(10) ) deliveries = [self._serialize_github_delivery(item) for item in delivery_result.scalars().all()] unmatched_event_count = sum(1 for item in deliveries if item["status"] == "unmatched") last_failure = next((item for item in deliveries if item["status"] == "failed"), None) repo_link_counts: dict[int, int] = {} for link in code_link_rows: repo_id = int(link.repo_binding_id) repo_link_counts[repo_id] = repo_link_counts.get(repo_id, 0) + 1 repo_event_counts: dict[int, int] = {} for event in recent_progress_events: repo = event.get("repo") repo_id = int(repo.get("id")) if isinstance(repo, dict) and str(repo.get("id") or "").isdigit() else None if repo_id is not None: repo_event_counts[repo_id] = repo_event_counts.get(repo_id, 0) + 1 serialized_repo_bindings = [] for binding in repo_bindings: payload = self._serialize_repo_binding(binding) payload["task_link_count"] = repo_link_counts.get(int(binding.id), 0) payload["recent_event_count"] = repo_event_counts.get(int(binding.id), 0) serialized_repo_bindings.append(payload) my_active_task_count = 0 if current_user_id is not None: my_active_task_count = sum( 1 for task, _, _ in task_rows if int(task.assignee_id or 0) == int(current_user_id) ) return { "project_id": str(project_id), "summary": { "repo_binding_count": len(serialized_repo_bindings), "active_task_count": len(active_dev_tasks), "in_review_count": sum(1 for item in active_dev_tasks if str(item.get("status") or "") == "review"), "recent_event_count": len(recent_progress_events), "my_active_task_count": my_active_task_count, "unmatched_event_count": unmatched_event_count, "last_failure_reason": None if last_failure is None else last_failure.get("failure_reason"), }, "active_model_binding": active_binding, "repo_bindings": serialized_repo_bindings, "active_dev_tasks": active_dev_tasks, "recent_progress_events": recent_progress_events, "recent_deliveries": deliveries, } async def create_or_apply_progress_event( self, *, task_id: int, project_id: int, event_type: str, source: str, payload: Optional[dict[str, Any]] = None, summary: Optional[str] = None, user_id: Optional[int] = None, repo_binding_id: Optional[int] = None, workspace_binding_id: Optional[int] = None, delivery_id: Optional[str] = None, event_source_id: Optional[str] = None, occurred_at: Optional[Any] = None, ) -> dict[str, Any]: task_result = await self.db.execute( select(Task).where( and_( Task.id == int(task_id), Task.project_id == int(project_id), Task.tenant_id == self.tenant_id, ) ) ) task = task_result.scalar_one_or_none() if task is None: raise ValueError("任务不存在") normalized_event = str(event_type or "").strip().lower() payload = dict(payload or {}) payload.setdefault("workflow_state", self._derive_workflow_state(normalized_event, payload)) next_status = self._derive_task_status_from_event(task.status, normalized_event, payload) event = ProgressEvent( tenant_id=self.tenant_id, task_id=int(task_id), project_id=int(project_id), user_id=int(user_id) if user_id is not None else None, repo_binding_id=int(repo_binding_id) if repo_binding_id is not None else None, workspace_binding_id=int(workspace_binding_id) if workspace_binding_id is not None else None, event_type=normalized_event, source=str(source or "manual").strip() or "manual", derived_task_status=next_status, delivery_id=str(delivery_id or "").strip() or None, event_source_id=str(event_source_id or "").strip() or None, summary=summary, payload=payload, occurred_at=self._coerce_time(occurred_at) or now_cn_naive(), ) self.db.add(event) if next_status and next_status != task.status: task.status = next_status if next_status == "done": task.completed_at = now_cn_naive() if repo_binding_id is not None and payload.get("branch_name"): await self._upsert_task_code_link( task_id=int(task_id), repo_binding_id=int(repo_binding_id), branch_name=str(payload.get("branch_name") or "").strip() or None, pr_number=self._safe_int(payload.get("pr_number")), commit_sha=str(payload.get("commit_sha") or "").strip() or None, ) await self.db.commit() await self.db.refresh(event) return { "id": str(event.id), "task_id": str(task.id), "project_id": str(project_id), "event_type": event.event_type, "source": event.source, "workflow_state": str(event.payload.get("workflow_state") or ""), "derived_task_status": event.derived_task_status, "task_status": task.status, "delivery_id": event.delivery_id, "event_source_id": event.event_source_id, "occurred_at": event.occurred_at.isoformat() if event.occurred_at else None, "payload": event.payload, } async def register_repo_binding( self, *, project_id: int, provider: str, owner: str, repo_name: str, default_branch: str, external_repo_id: Optional[str] = None, installation_id: Optional[str] = None, installation_account_login: Optional[str] = None, webhook_secret_hint: Optional[str] = None, ) -> dict[str, Any]: provider = (provider or "github").strip().lower() or "github" result = await self.db.execute( select(RepoBinding).where( and_( RepoBinding.tenant_id == self.tenant_id, RepoBinding.provider == provider, RepoBinding.owner == owner, RepoBinding.repo_name == repo_name, ) ) ) binding = result.scalar_one_or_none() if binding is None: binding = RepoBinding( tenant_id=self.tenant_id, project_id=int(project_id), provider=provider, owner=owner, repo_name=repo_name, default_branch=default_branch or "main", external_repo_id=external_repo_id, installation_id=installation_id, installation_account_login=installation_account_login, webhook_secret_hint=webhook_secret_hint, status="active", ) self.db.add(binding) else: binding.project_id = int(project_id) binding.default_branch = default_branch or binding.default_branch binding.external_repo_id = external_repo_id or binding.external_repo_id binding.installation_id = installation_id or binding.installation_id binding.installation_account_login = installation_account_login or binding.installation_account_login binding.webhook_secret_hint = webhook_secret_hint or binding.webhook_secret_hint binding.status = "active" await self.db.commit() await self.db.refresh(binding) return self._serialize_repo_binding(binding) async def get_github_app_install_center(self) -> dict[str, Any]: installations_result = await self.db.execute( select(GitHubAppInstallation) .where(GitHubAppInstallation.tenant_id == self.tenant_id) .order_by(GitHubAppInstallation.updated_at.desc()) ) installations = [self._serialize_github_installation(item) for item in installations_result.scalars().all()] deliveries_result = await self.db.execute( select(GitHubWebhookDelivery) .where(GitHubWebhookDelivery.tenant_id == self.tenant_id) .order_by(GitHubWebhookDelivery.received_at.desc(), GitHubWebhookDelivery.created_at.desc()) .limit(10) ) deliveries = [self._serialize_github_delivery(item) for item in deliveries_result.scalars().all()] return { "configured": bool(self._github_install_url()), "install_url": self._github_install_url(), "callback_url": settings.github_app_callback_url, "webhook_signature_configured": bool(settings.github_app_webhook_secret), "app_slug": settings.github_app_slug, "installations": installations, "recent_deliveries": deliveries, } async def sync_github_installations(self) -> dict[str, Any]: rows = await self._load_github_installation_payloads() synced_installations = [] synced_repo_count = 0 for row in rows: installation = await self._upsert_github_installation(row) synced_installations.append(self._serialize_github_installation(installation)) repositories = row.get("repositories") or [] synced_repo_count += await self._upsert_github_installation_repositories( installation_id=str(installation.installation_id), repositories=list(repositories), ) await self.db.commit() return { "installations": synced_installations, "repository_count": synced_repo_count, "configured": bool(self._github_install_url()), } async def list_available_github_repositories(self) -> list[dict[str, Any]]: result = await self.db.execute( select(GitHubAppRepository, GitHubAppInstallation) .join( GitHubAppInstallation, and_( GitHubAppInstallation.tenant_id == GitHubAppRepository.tenant_id, GitHubAppInstallation.installation_id == GitHubAppRepository.installation_id, ), ) .where( and_( GitHubAppRepository.tenant_id == self.tenant_id, GitHubAppRepository.status == "active", GitHubAppInstallation.status == "active", ) ) .order_by(GitHubAppRepository.full_name.asc()) ) payload = [] for repo, installation in result.all(): payload.append( { "id": str(repo.id), "installation_id": repo.installation_id, "installation_account_login": installation.account_login, "external_repo_id": repo.external_repo_id, "owner": repo.owner, "repo_name": repo.repo_name, "full_name": repo.full_name, "default_branch": repo.default_branch or "main", "permissions": dict(repo.permissions or {}), "is_private": bool(repo.is_private), "status": repo.status, } ) return payload async def bind_github_repository( self, *, project_id: int, github_repository_id: int, ) -> dict[str, Any]: result = await self.db.execute( select(GitHubAppRepository, GitHubAppInstallation) .join( GitHubAppInstallation, and_( GitHubAppInstallation.tenant_id == GitHubAppRepository.tenant_id, GitHubAppInstallation.installation_id == GitHubAppRepository.installation_id, ), ) .where( and_( GitHubAppRepository.tenant_id == self.tenant_id, GitHubAppRepository.id == int(github_repository_id), GitHubAppRepository.status == "active", ) ) ) row = result.first() if row is None: raise ValueError("当前仓库不在 GitHub App 已授权仓库列表中") repo, installation = row return await self.register_repo_binding( project_id=project_id, provider="github", owner=repo.owner, repo_name=repo.repo_name, default_branch=repo.default_branch or "main", external_repo_id=repo.external_repo_id, installation_id=repo.installation_id, installation_account_login=installation.account_login, webhook_secret_hint="github_app", ) async def list_github_deliveries(self, *, project_id: Optional[int] = None, limit: int = 20) -> list[dict[str, Any]]: query = select(GitHubWebhookDelivery).where(GitHubWebhookDelivery.tenant_id == self.tenant_id) if project_id is not None: query = query.where(GitHubWebhookDelivery.project_id == int(project_id)) result = await self.db.execute( query.order_by(GitHubWebhookDelivery.received_at.desc(), GitHubWebhookDelivery.created_at.desc()).limit(limit) ) return [self._serialize_github_delivery(item) for item in result.scalars().all()] async def ingest_github_webhook( self, *, event_type: str, delivery_id: str, signature_256: Optional[str], payload: dict[str, Any], raw_body: bytes, ) -> dict[str, Any]: self._verify_github_signature(raw_body=raw_body, signature_256=signature_256) existing_result = await self.db.execute( select(GitHubWebhookDelivery).where( and_( GitHubWebhookDelivery.tenant_id == self.tenant_id, GitHubWebhookDelivery.delivery_id == str(delivery_id), ) ) ) existing = existing_result.scalar_one_or_none() if existing is not None: return { "delivery": self._serialize_github_delivery(existing), "deduplicated": True, } delivery = GitHubWebhookDelivery( tenant_id=self.tenant_id, delivery_id=str(delivery_id), event_type=str(event_type or "").strip() or "unknown", installation_id=str((payload.get("installation") or {}).get("id") or "").strip() or None, status="received", payload=payload, received_at=now_cn_naive(), ) self.db.add(delivery) await self.db.flush() try: result = await self.ingest_github_event( event_type=event_type, payload=payload, delivery_id=str(delivery_id), ) delivery.status = "processed" if int(result.get("matched_links") or 0) > 0 else "unmatched" repo_binding = result.get("repo_binding") or {} delivery.repo_binding_id = self._safe_int(repo_binding.get("id")) delivery.project_id = self._safe_int(repo_binding.get("project_id")) await self.db.commit() await self.db.refresh(delivery) return { "delivery": self._serialize_github_delivery(delivery), "result": result, "deduplicated": False, } except Exception as exc: delivery.status = "failed" delivery.failure_reason = str(exc) await self.db.commit() await self.db.refresh(delivery) raise async def ingest_github_event( self, *, event_type: str, payload: dict[str, Any], delivery_id: Optional[str] = None, ) -> dict[str, Any]: repository = dict(payload.get("repository") or {}) owner = str((repository.get("owner") or {}).get("login") or repository.get("owner") or "").strip() repo_name = str(repository.get("name") or "").strip() branch_name = self._branch_from_github_payload(event_type, payload) pr_number = self._safe_int((payload.get("pull_request") or {}).get("number") or payload.get("number")) commit_sha = str(payload.get("after") or (payload.get("pull_request") or {}).get("merge_commit_sha") or "").strip() or None if not owner or not repo_name: raise ValueError("GitHub 事件缺少仓库信息") repo_result = await self.db.execute( select(RepoBinding).where( and_( RepoBinding.tenant_id == self.tenant_id, RepoBinding.provider == "github", RepoBinding.owner == owner, RepoBinding.repo_name == repo_name, RepoBinding.status == "active", ) ) ) repo_binding = repo_result.scalar_one_or_none() if repo_binding is None: raise ValueError("未找到对应的仓库绑定") if pr_number is None and not branch_name: return { "repo_binding": self._serialize_repo_binding(repo_binding), "matched_links": 0, "events_created": [], "ignored_reason": "missing_branch_or_pr", } links_query = select(TaskCodeLink).where( and_( TaskCodeLink.tenant_id == self.tenant_id, TaskCodeLink.repo_binding_id == int(repo_binding.id), ) ) if pr_number is not None: links_query = links_query.where( or_(TaskCodeLink.pr_number == pr_number, TaskCodeLink.branch_name == branch_name) ) elif branch_name: links_query = links_query.where(TaskCodeLink.branch_name == branch_name) links_result = await self.db.execute(links_query) links = list(links_result.scalars().all()) events_created = [] for link in links: task_result = await self.db.execute( select(Task).where(and_(Task.id == int(link.task_id), Task.tenant_id == self.tenant_id)) ) task = task_result.scalar_one_or_none() if task is None: continue normalized_event_type = self._map_github_event_type(event_type=event_type, payload=payload) event = await self.create_or_apply_progress_event( task_id=int(task.id), project_id=int(task.project_id), event_type=normalized_event_type, source="github", payload={ "branch_name": branch_name, "pr_number": pr_number, "commit_sha": commit_sha, "github_event": event_type, "action": payload.get("action"), "workflow_state": self._derive_workflow_state(normalized_event_type, payload), }, summary=f"GitHub {event_type} 事件已同步", repo_binding_id=int(repo_binding.id), delivery_id=delivery_id, event_source_id=f"{event_type}:{payload.get('action') or ''}".strip(":"), ) events_created.append(event) return { "repo_binding": self._serialize_repo_binding(repo_binding), "matched_links": len(links), "events_created": events_created, } async def build_project_control_summary(self, *, project_id: int) -> dict[str, Any]: usage = await self.get_model_usage(project_id=project_id) latest_release_validation = await self._load_latest_release_validation(project_id=project_id) roi_snapshot = await self._build_roi_snapshot(project_id=project_id, persist=False) latest_compliance = await self._load_latest_compliance_check(project_id=project_id) compliance_status = "unbound" active_binding = usage.get("active_binding") if active_binding: compliance_status = str(active_binding.get("compliance_status") or "review_required") return { "model_binding": active_binding, "prompt_package_status": { "count": int(usage.get("summary", {}).get("prompt_package_count") or 0), "latest_eval_status": latest_release_validation.status if latest_release_validation else None, }, "release_validation_status": None if latest_release_validation is None else self._serialize_release_validation_run(latest_release_validation), "roi_snapshot": roi_snapshot, "compliance_status": { "status": latest_compliance.status if latest_compliance is not None else compliance_status, }, } def inject_model_tasks( self, *, payload: TaskBreakdownPayload, active_binding: Optional[dict[str, Any]], ) -> TaskBreakdownPayload: if not active_binding: return payload existing_keys = {str(item.task_key or "").strip().lower() for item in payload.tasks} for blueprint in _MODEL_TASK_BLUEPRINTS: if blueprint["task_key"] in existing_keys: continue payload.tasks.append( TaskBreakdownBusinessObject.model_validate( { **blueprint, "confidence": 0.94, "evidence_refs": [ "charter.objectives.0", f"model_binding.{active_binding.get('model_key')}", ], } ) ) return payload async def _load_charter_for_project(self, *, project_id: int, charter_id: Optional[int]) -> Optional[TruthNode]: query = select(TruthNode).where( and_( TruthNode.project_id == int(project_id), TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "charter", ) ) if charter_id is not None: query = query.where(TruthNode.id == int(charter_id)) query = query.order_by(TruthNode.created_at.desc()).limit(1) result = await self.db.execute(query) return result.scalar_one_or_none() async def _load_release_for_project(self, *, project_id: int, release_node_id: Optional[int]) -> Optional[TruthNode]: query = select(TruthNode).where( and_( TruthNode.project_id == int(project_id), TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "release", ) ) if release_node_id is not None: query = query.where(TruthNode.id == int(release_node_id)) query = query.order_by(TruthNode.created_at.desc()).limit(1) result = await self.db.execute(query) return result.scalar_one_or_none() async def _load_latest_release_validation(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.created_at.desc()) .limit(1) ) return result.scalar_one_or_none() async def _load_latest_compliance_check(self, *, project_id: int) -> Optional[ComplianceCheckRun]: result = await self.db.execute( select(ComplianceCheckRun) .where( and_( ComplianceCheckRun.tenant_id == self.tenant_id, ComplianceCheckRun.project_id == int(project_id), ) ) .order_by(ComplianceCheckRun.created_at.desc()) .limit(1) ) return result.scalar_one_or_none() async def _build_roi_snapshot(self, *, project_id: int, persist: bool) -> dict[str, Any]: month_start = now_cn_naive().replace(day=1, hour=0, minute=0, second=0, microsecond=0) now = now_cn_naive() revenue_rows_result = await self.db.execute( select(RevenueEvent.category, func.coalesce(func.sum(RevenueEvent.amount_usd), 0.0)) .where( and_( RevenueEvent.tenant_id == self.tenant_id, RevenueEvent.project_id == int(project_id), or_(RevenueEvent.occurred_at.is_(None), RevenueEvent.occurred_at >= month_start), ) ) .group_by(RevenueEvent.category) ) revenue_breakdown = { "subscription": 0.0, "service": 0.0, "api": 0.0, "ads": 0.0, "other": 0.0, } for category, amount in revenue_rows_result.all(): normalized = str(category or "other").strip().lower() or "other" revenue_breakdown[normalized] = round(float(amount or 0.0), 2) total_revenue = round(sum(revenue_breakdown.values()), 2) usage = await self.get_model_usage(project_id=project_id) cost_breakdown = { "model": round(float(((usage.get("summary", {}) or {}).get("month") or {}).get("cost_usd") or 0.0), 2), "infra": 0.0, "labor": 0.0, "promotion": 0.0, "other": 0.0, } total_cost = round(sum(cost_breakdown.values()), 2) roi_ratio = None if total_cost <= 0 else round((total_revenue - total_cost) / total_cost, 4) if roi_ratio is None: conclusion = "暂无足够成本数据,先观察模型成本和收入录入。" elif roi_ratio >= 0.5: conclusion = "当前 ROI 已经转正,可以继续投入并扩大验证样本。" elif roi_ratio >= 0: conclusion = "当前接近盈亏平衡,建议继续观察并优先优化转化。" else: conclusion = "当前 ROI 仍为负,建议先收缩投入,优先修复成本或效果问题。" snapshot_payload = { "estimated_monthly_model_cost_usd": float((usage.get("summary", {}) or {}).get("estimated_monthly_cost_usd") or 0.0), "revenue_event_count": await self._count_revenue_events(project_id=project_id), "cost_breakdown": cost_breakdown, "revenue_breakdown": revenue_breakdown, "conclusion": conclusion, } if not persist: return { "estimated_monthly_model_cost_usd": snapshot_payload["estimated_monthly_model_cost_usd"], "month_cost_usd": total_cost, "total_revenue_usd": total_revenue, "roi_ratio": roi_ratio, "window_start": month_start.isoformat(), "window_end": now.isoformat(), "cost_breakdown": cost_breakdown, "revenue_breakdown": revenue_breakdown, "conclusion": conclusion, } existing_result = await self.db.execute( select(ROISnapshot) .where( and_( ROISnapshot.tenant_id == self.tenant_id, ROISnapshot.project_id == int(project_id), ROISnapshot.range_key == "monthly", ) ) .order_by(ROISnapshot.created_at.desc()) .limit(1) ) snapshot = existing_result.scalar_one_or_none() if snapshot is None or not snapshot.window_start or snapshot.window_start.date() != month_start.date(): snapshot = ROISnapshot( tenant_id=self.tenant_id, project_id=int(project_id), range_key="monthly", window_start=month_start, window_end=now, total_revenue_usd=total_revenue, total_cost_usd=total_cost, roi_ratio=roi_ratio, snapshot_payload=snapshot_payload, ) self.db.add(snapshot) else: snapshot.window_end = now snapshot.total_revenue_usd = total_revenue snapshot.total_cost_usd = total_cost snapshot.roi_ratio = roi_ratio snapshot.snapshot_payload = snapshot_payload await self.db.commit() await self.db.refresh(snapshot) return self._serialize_roi_snapshot(snapshot) async def _count_revenue_events(self, *, project_id: int) -> int: result = await self.db.execute( select(func.count(RevenueEvent.id)).where( and_( RevenueEvent.tenant_id == self.tenant_id, RevenueEvent.project_id == int(project_id), ) ) ) return int(result.scalar() or 0) def _build_iteration_recommendation( self, *, project_id: int, charter: Optional[TruthNode], ingest: FeedbackIngest, ) -> IterationRecommendation: text = str(ingest.content or "").lower() focus_areas = [] if any(keyword in text for keyword in ["慢", "延迟", "latency", "timeout"]): focus_areas.append("performance") if any(keyword in text for keyword in ["贵", "成本", "token", "expense"]): focus_areas.append("cost") if any(keyword in text for keyword in ["错", "幻觉", "不准", "hallucination", "bad answer"]): focus_areas.append("quality") if any(keyword in text for keyword in ["上下文", "知识库", "检索", "rag", "文档"]): focus_areas.append("context") if not focus_areas: focus_areas.append("usability") severity = "high" if any(area in {"quality", "performance"} for area in focus_areas) else "medium" proposed_tasks = [] if "quality" in focus_areas or "context" in focus_areas: proposed_tasks.append( { "title": "补齐模型失败样本并更新 Prompt Package", "type": "prompt_quality", "priority": "high", } ) if "performance" in focus_areas: proposed_tasks.append( { "title": "复核模型链路延迟与降级策略", "type": "performance_guardrail", "priority": "high", } ) if "cost" in focus_areas: proposed_tasks.append( { "title": "复核模型成本并评估替代模型", "type": "cost_optimization", "priority": "medium", } ) proposed_tasks.append( { "title": "整理本轮反馈并生成下一轮迭代宪章草案", "type": "iteration_planning", "priority": "medium", } ) return IterationRecommendation( tenant_id=self.tenant_id, project_id=int(project_id), charter_id=int(charter.id) if charter is not None else None, feedback_ingest_id=int(ingest.id), status="draft", priority=severity, recommendation_summary={ "focus_areas": focus_areas, "summary": f"本轮反馈主要集中在 {', '.join(focus_areas)},建议先处理高风险问题再进入下一轮迭代。", }, proposed_charter_patch={ "focus_areas": focus_areas, "updated_success_criteria": self._extract_acceptance_criteria(charter), "feedback_title": ingest.title, }, proposed_tasks=proposed_tasks, ) def _charter_content_dict(self, charter: Optional[TruthNode]) -> dict[str, Any]: if charter is None: return {} if isinstance(charter.content, dict): return dict(charter.content) try: parsed = json.loads(charter.content or "{}") return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _has_any_keys(self, content: dict[str, Any], keys: set[str]) -> bool: for key in keys: value = content.get(key) if value not in (None, "", [], {}, ()): return True return False def _build_check_item(self, *, key: str, label: str, status: str, detail: Optional[str]) -> dict[str, Any]: return { "key": key, "label": label, "status": status, "detail": detail, } async def _load_catalog_item(self, catalog_item_id: int) -> ModelCatalogItem: result = await self.db.execute( select(ModelCatalogItem).where( and_( ModelCatalogItem.id == int(catalog_item_id), ModelCatalogItem.tenant_id == self.tenant_id, ) ) ) item = result.scalar_one_or_none() if item is None: raise ValueError("模型目录条目不存在") return item async def _sync_legacy_requirement_binding( self, *, charter_id: int, catalog_item: ModelCatalogItem, estimated_cost: Optional[float], selection_reason: dict[str, Any], ) -> None: model_version_result = await self.db.execute( select(ModelVersion).where( and_( ModelVersion.name == catalog_item.display_name, ModelVersion.provider == catalog_item.provider, ModelVersion.version == catalog_item.version, ) ) ) model_version = model_version_result.scalar_one_or_none() if model_version is None: model_version = ModelVersion( name=catalog_item.display_name, provider=catalog_item.provider, version=catalog_item.version, capability_boundaries={ "modalities": list(catalog_item.modalities or []), "capability_tags": list(catalog_item.capability_tags or []), "limitations": list(catalog_item.limitations or []), "filing_status": catalog_item.filing_status, }, cost_per_1k_tokens=( float(catalog_item.input_cost_per_mtok or 0.0) + float(catalog_item.output_cost_per_mtok or 0.0) ) / 2000, is_active=True, ) self.db.add(model_version) await self.db.flush() compatibility_score = max( 1, min( 100, int(selection_reason.get("compatibility_score") or selection_reason.get("score") or 85), ), ) binding = ModelRequirementBinding( truth_node_id=int(charter_id), model_version_id=int(model_version.id), estimated_cost=estimated_cost, compatibility_score=compatibility_score, risk_notes=str(selection_reason.get("note") or selection_reason.get("risk_notes") or ""), tenant_id=self.tenant_id, ) self.db.add(binding) async def _load_budget_amount(self, *, project_id: int) -> Optional[float]: project_budget_result = await self.db.execute( select(Budget) .where( and_( Budget.tenant_id == self.tenant_id, Budget.budget_type == "project", Budget.project_id == int(project_id), Budget.status.in_(["active", "overridden"]), ) ) .order_by(Budget.created_at.desc()) .limit(1) ) project_budget = project_budget_result.scalar_one_or_none() if project_budget is not None: return float(project_budget.budget_amount_usd or 0.0) tenant_budget_result = await self.db.execute( select(Budget) .where( and_( Budget.tenant_id == self.tenant_id, Budget.budget_type == "tenant", Budget.status.in_(["active", "overridden"]), ) ) .order_by(Budget.created_at.desc()) .limit(1) ) tenant_budget = tenant_budget_result.scalar_one_or_none() if tenant_budget is not None: return float(tenant_budget.budget_amount_usd or 0.0) return None def _flatten_charter(self, *, project: Project, charter: Optional[TruthNode]) -> str: content = {} if charter is not None: if isinstance(charter.content, dict): content = charter.content else: try: content = json.loads(charter.content or "{}") except Exception: content = {"raw": str(charter.content or "")} parts = [ str(project.title or project.name or "").strip(), str(project.description or "").strip(), str(getattr(charter, "title", "") or "").strip(), json.dumps(content, ensure_ascii=False), ] return "\n".join(part for part in parts if part) def _detect_required_modalities(self, text: str) -> list[str]: lower = text.lower() modalities = [] keyword_map = { "video": ["视频", "video"], "audio": ["音频", "语音", "voice", "audio"], "image": ["图片", "图像", "image", "vision", "ocr"], "text": ["文本", "对话", "问答", "chat", "rag", "代码", "code"], } for modality, keywords in keyword_map.items(): if any(keyword in lower for keyword in keywords): modalities.append(modality) if not modalities: modalities.append("text") return sorted(set(modalities)) def _detect_desired_tags(self, text: str) -> list[str]: lower = text.lower() mapping = { "rag": ["rag", "知识库", "检索", "文档问答"], "agent": ["agent", "智能体", "工作流", "自动执行"], "code": ["代码", "开发", "编程", "copilot", "cursor"], "multimodal": ["多模态", "图像", "语音", "视频"], "reasoning": ["推理", "规划", "分析", "决策"], "long_context": ["长上下文", "长文本", "海量文档"], } tags = [] for tag, keywords in mapping.items(): if any(keyword in lower for keyword in keywords): tags.append(tag) if not tags: tags.append("general") return sorted(set(tags)) def _estimate_daily_tokens(self, text: str) -> int: lower = text.lower() if any(keyword in lower for keyword in ["高频", "大量", "高并发", "批量"]): return 500_000 if any(keyword in lower for keyword in ["中等", "日常", "团队内部", "运营"]): return 150_000 return 80_000 def _score_model(self, item: ModelCatalogItem, context: RecommendationContext) -> dict[str, Any]: modalities = {str(x).lower() for x in list(item.modalities or [])} capability_tags = {str(x).lower() for x in list(item.capability_tags or [])} required_modalities = {str(x).lower() for x in list(context.required_modalities or [])} desired_tags = {str(x).lower() for x in list(context.desired_tags or [])} capability_score = 4.5 if required_modalities.issubset(modalities): capability_score += 3.0 elif modalities.intersection(required_modalities): capability_score += 1.5 else: capability_score -= 2.5 if desired_tags: overlap = len(capability_tags.intersection(desired_tags)) capability_score += min(2.5, overlap * 0.9) capability_score = max(0.0, min(10.0, capability_score)) estimated_monthly_cost = self._estimate_monthly_cost( estimated_daily_tokens=context.estimated_daily_tokens, input_cost_per_mtok=float(item.input_cost_per_mtok or 0.0), output_cost_per_mtok=float(item.output_cost_per_mtok or 0.0), ) cost_score = self._score_cost( estimated_monthly_cost=estimated_monthly_cost, budget_amount_usd=context.budget_amount_usd, ) compliance_score = { "filed": 10.0, "registered": 8.0, "review_required": 6.0, "unknown": 4.0, "restricted": 1.0, }.get(str(item.filing_status or "unknown").lower(), 4.0) total_score = round(capability_score * 0.5 + cost_score * 0.3 + compliance_score * 0.2, 2) return { "catalog_item_id": str(item.id), "display_name": item.display_name, "model_key": item.model_key, "provider": item.provider, "version": item.version, "modalities": list(item.modalities or []), "capability_tags": list(item.capability_tags or []), "capability_score": round(capability_score, 2), "cost_score": round(cost_score, 2), "compliance_score": round(compliance_score, 2), "total_score": total_score, "estimated_monthly_cost_usd": estimated_monthly_cost, "filing_status": item.filing_status, "compliance_status": item.compliance_status, "strengths": list(item.strengths or []), "limitations": list(item.limitations or []), "official_docs_url": item.official_docs_url, } def _score_cost(self, *, estimated_monthly_cost: float, budget_amount_usd: Optional[float]) -> float: if budget_amount_usd is None or budget_amount_usd <= 0: if estimated_monthly_cost <= 100: return 9.0 if estimated_monthly_cost <= 500: return 7.0 if estimated_monthly_cost <= 1_500: return 5.0 return 2.0 ratio = estimated_monthly_cost / budget_amount_usd if budget_amount_usd > 0 else 1.0 if ratio <= 0.2: return 10.0 if ratio <= 0.4: return 8.0 if ratio <= 0.6: return 6.0 if ratio <= 0.85: return 4.0 if ratio <= 1.0: return 2.0 return 0.0 def _estimate_monthly_cost( self, *, estimated_daily_tokens: int, input_cost_per_mtok: float, output_cost_per_mtok: float, ) -> float: monthly_tokens = max(float(estimated_daily_tokens or 0), 0.0) * 30.0 blended_cost = max(float(input_cost_per_mtok or 0.0) + float(output_cost_per_mtok or 0.0), 0.0) return round((monthly_tokens / 1_000_000.0) * blended_cost, 2) def _extract_acceptance_criteria(self, charter: Optional[TruthNode]) -> list[str]: if charter is None: return [] content = charter.content if isinstance(charter.content, dict) else {} criteria = content.get("success_criteria") or content.get("criteria") or [] if isinstance(criteria, list): return [str(item).strip() for item in criteria if str(item).strip()] if isinstance(criteria, str) and criteria.strip(): return [criteria.strip()] return [] def _render_prompt_package(self, payload: dict[str, Any]) -> str: model_binding = payload.get("model_binding") or {} task = payload.get("task") or {} acceptance = payload.get("acceptance") or [] criteria_lines = "\n".join(f"- {item}" for item in acceptance) if acceptance else "- 需要满足宪章中的核心成功指标" task_title = task.get("title") or "当前任务" task_desc = task.get("description") or "请结合宪章完成最小可用实现。" model_desc = ( f"{model_binding.get('display_name')} ({model_binding.get('provider')})" if model_binding else "当前项目尚未绑定模型,请先确认模型选择。" ) return ( f"你现在是 PMFlow 项目的执行工程师,请按以下上下文完成开发任务。\n\n" f"【项目】{payload['project']['title']}\n" f"【任务】{task_title}\n" f"【任务说明】{task_desc}\n" f"【宪章摘要】{payload['charter']['summary']}\n" f"【模型绑定】{model_desc}\n" f"【关键验收标准】\n{criteria_lines}\n" f"【代码约束】\n" + "\n".join(f"- {item}" for item in payload.get("code_constraints") or []) + "\n【输出要求】\n" "- 先列出你要改的模块和文件范围。\n" "- 只实现与当前任务直接相关的最小闭环。\n" "- 需要补测试时,优先补最小可行测试。\n" "- 如果发现需求与现有代码冲突,明确指出冲突点,不要私自扩范围。\n" ) def _serialize_binding(self, binding: ProjectModelBinding, item: ModelCatalogItem) -> dict[str, Any]: return { "id": str(binding.id), "project_id": str(binding.project_id), "charter_id": str(binding.charter_id) if binding.charter_id is not None else None, "status": binding.status, "estimated_daily_tokens": int(binding.estimated_daily_tokens or 0), "estimated_monthly_cost_usd": float(binding.estimated_monthly_cost_usd or 0.0) if binding.estimated_monthly_cost_usd is not None else None, "active_from": binding.active_from.isoformat() if binding.active_from else None, "selection_reason": dict(binding.selection_reason or {}), "catalog_item_id": str(item.id), "display_name": item.display_name, "model_key": item.model_key, "provider": item.provider, "version": item.version, "modalities": list(item.modalities or []), "capability_tags": list(item.capability_tags or []), "compliance_status": item.compliance_status, "filing_status": item.filing_status, } def _serialize_release_validation_run(self, run: ReleaseValidationRun) -> dict[str, Any]: summary = dict(run.validation_summary or {}) return { "id": str(run.id), "status": run.status, "score": float(run.score or 0.0) if run.score is not None else None, "key_metrics": dict(run.key_metrics or {}), "risk_items": list(run.risk_items or []), "rollback_plan": dict(run.rollback_plan or {}), "summary": summary, "success_criteria_hits": list(summary.get("success_criteria_hits") or []), "allow_release": bool(summary.get("allow_release")), "blocking_reason": summary.get("blocking_reason"), "created_at": run.created_at.isoformat() if run.created_at else None, } def _serialize_feedback_ingest(self, ingest: FeedbackIngest) -> dict[str, Any]: return { "id": str(ingest.id), "project_id": str(ingest.project_id), "charter_id": str(ingest.charter_id) if ingest.charter_id is not None else None, "source_type": ingest.source_type, "title": ingest.title, "content": ingest.content, "metrics": dict(ingest.metrics or {}), "samples": list(ingest.samples or []), "status": ingest.status, "created_at": ingest.created_at.isoformat() if ingest.created_at else None, } def _serialize_iteration_recommendation(self, recommendation: IterationRecommendation) -> dict[str, Any]: return { "id": str(recommendation.id), "project_id": str(recommendation.project_id), "charter_id": str(recommendation.charter_id) if recommendation.charter_id is not None else None, "feedback_ingest_id": str(recommendation.feedback_ingest_id) if recommendation.feedback_ingest_id is not None else None, "status": recommendation.status, "priority": recommendation.priority, "recommendation_summary": dict(recommendation.recommendation_summary or {}), "proposed_charter_patch": dict(recommendation.proposed_charter_patch or {}), "proposed_tasks": list(recommendation.proposed_tasks or []), "created_at": recommendation.created_at.isoformat() if recommendation.created_at else None, } def _serialize_roi_snapshot(self, snapshot: ROISnapshot) -> dict[str, Any]: return { "id": str(snapshot.id), "project_id": str(snapshot.project_id), "range_key": snapshot.range_key, "window_start": snapshot.window_start.isoformat() if snapshot.window_start else None, "window_end": snapshot.window_end.isoformat() if snapshot.window_end else None, "total_revenue_usd": float(snapshot.total_revenue_usd or 0.0), "total_cost_usd": float(snapshot.total_cost_usd or 0.0), "roi_ratio": float(snapshot.roi_ratio or 0.0) if snapshot.roi_ratio is not None else None, "snapshot_payload": dict(snapshot.snapshot_payload or {}), "cost_breakdown": dict((snapshot.snapshot_payload or {}).get("cost_breakdown") or {}), "revenue_breakdown": dict((snapshot.snapshot_payload or {}).get("revenue_breakdown") or {}), "conclusion": str((snapshot.snapshot_payload or {}).get("conclusion") or ""), } def _serialize_compliance_check_run(self, run: ComplianceCheckRun) -> dict[str, Any]: return { "id": str(run.id), "status": run.status, "score": float(run.score or 0.0) if run.score is not None else None, "checklist": list(run.checklist or []), "issues": list(run.issues or []), "summary": dict(run.summary or {}), "release_gate": "blocked" if run.status == "blocked" else "passable", "created_at": run.created_at.isoformat() if run.created_at else None, } def _serialize_evidence_pack(self, pack: EvidencePack) -> dict[str, Any]: return { "id": str(pack.id), "project_id": str(pack.project_id), "compliance_check_run_id": str(pack.compliance_check_run_id) if pack.compliance_check_run_id is not None else None, "package_kind": pack.package_kind, "status": pack.status, "content": dict(pack.content or {}), "export_payload": dict(pack.export_payload or {}), "created_at": pack.created_at.isoformat() if pack.created_at else None, } def _serialize_usage_row(self, row: Any) -> dict[str, Any]: input_tokens, output_tokens, cost_usd = row return { "input_tokens": int(input_tokens or 0), "output_tokens": int(output_tokens or 0), "total_tokens": int(input_tokens or 0) + int(output_tokens or 0), "cost_usd": float(cost_usd or 0.0), } def _serialize_repo_binding(self, binding: RepoBinding) -> dict[str, Any]: return { "id": str(binding.id), "project_id": str(binding.project_id), "provider": binding.provider, "owner": binding.owner, "repo_name": binding.repo_name, "full_name": f"{binding.owner}/{binding.repo_name}", "default_branch": binding.default_branch, "external_repo_id": binding.external_repo_id, "installation_id": binding.installation_id, "installation_account_login": binding.installation_account_login, "app_state": "connected" if binding.installation_id else "unbound", "status": binding.status, } def _serialize_github_installation(self, installation: GitHubAppInstallation) -> dict[str, Any]: return { "id": str(installation.id), "installation_id": installation.installation_id, "account_login": installation.account_login, "account_type": installation.account_type, "app_slug": installation.app_slug, "target_type": installation.target_type, "status": installation.status, "permissions": dict(installation.permissions or {}), "events": list(installation.events or []), "metadata": dict(installation.app_metadata or {}), "last_synced_at": installation.last_synced_at.isoformat() if installation.last_synced_at else None, } def _serialize_github_delivery(self, delivery: GitHubWebhookDelivery) -> dict[str, Any]: repository = dict((delivery.payload or {}).get("repository") or {}) return { "id": str(delivery.id), "delivery_id": delivery.delivery_id, "event_type": delivery.event_type, "project_id": str(delivery.project_id) if delivery.project_id is not None else None, "repo_binding_id": str(delivery.repo_binding_id) if delivery.repo_binding_id is not None else None, "installation_id": delivery.installation_id, "status": delivery.status, "failure_reason": delivery.failure_reason, "repository_full_name": str(repository.get("full_name") or "").strip() or None, "received_at": delivery.received_at.isoformat() if delivery.received_at else None, } def _serialize_project_dev_task( self, *, task: Task, charter: TruthNode, assignee: Optional[User], prompt_package_count: int, code_link: Optional[TaskCodeLink], repo_binding_map: dict[int, RepoBinding], active_model_binding: Optional[dict[str, Any]], ) -> dict[str, Any]: code_link_payload = None if code_link is not None: repo_binding = repo_binding_map.get(int(code_link.repo_binding_id)) code_link_payload = { "id": str(code_link.id), "status": code_link.status, "branch_name": code_link.branch_name, "pr_number": code_link.pr_number, "last_commit_sha": code_link.last_commit_sha, "repo": None if repo_binding is None else self._serialize_repo_binding(repo_binding), } return { "id": str(task.id), "title": task.title, "description": task.description, "status": task.status, "priority": task.priority, "estimated_hours": float(task.estimated_hours or 0.0) if task.estimated_hours is not None else None, "project_id": str(task.project_id), "charter_id": str(charter.id), "charter_title": charter.title, "assignee": None if assignee is None else { "id": str(assignee.id), "name": assignee.name, "role": assignee.role, }, "prompt_package_count": int(prompt_package_count), "model_binding": active_model_binding, "code_link": code_link_payload, } def _serialize_progress_event_item( self, *, event: ProgressEvent, task: Task, user: Optional[User], repo_binding: Optional[RepoBinding], ) -> dict[str, Any]: payload = dict(event.payload or {}) return { "id": str(event.id), "event_type": event.event_type, "source": event.source, "summary": event.summary, "workflow_state": str(payload.get("workflow_state") or ""), "derived_task_status": event.derived_task_status, "delivery_id": event.delivery_id, "event_source_id": event.event_source_id, "occurred_at": event.occurred_at.isoformat() if event.occurred_at else None, "task": { "id": str(task.id), "title": task.title, "status": task.status, "priority": task.priority, }, "user": None if user is None else { "id": str(user.id), "name": user.name, "role": user.role, }, "repo": None if repo_binding is None else self._serialize_repo_binding(repo_binding), "branch_name": str(payload.get("branch_name") or "").strip() or None, "pr_number": self._safe_int(payload.get("pr_number")), "commit_sha": str(payload.get("commit_sha") or "").strip() or None, } async def _upsert_task_code_link( self, *, task_id: int, repo_binding_id: int, branch_name: Optional[str], pr_number: Optional[int], commit_sha: Optional[str], ) -> None: query = select(TaskCodeLink).where( and_( TaskCodeLink.tenant_id == self.tenant_id, TaskCodeLink.task_id == int(task_id), TaskCodeLink.repo_binding_id == int(repo_binding_id), TaskCodeLink.branch_name == branch_name, ) ) result = await self.db.execute(query) link = result.scalar_one_or_none() if link is None: link = TaskCodeLink( tenant_id=self.tenant_id, task_id=int(task_id), repo_binding_id=int(repo_binding_id), branch_name=branch_name, pr_number=pr_number, last_commit_sha=commit_sha, status="linked", ) self.db.add(link) return if pr_number is not None: link.pr_number = pr_number if commit_sha: link.last_commit_sha = commit_sha def _derive_task_status_from_event(self, current_status: Optional[str], event_type: str, payload: dict[str, Any]) -> str: current = str(current_status or "todo").strip().lower() or "todo" if event_type in {"commit", "push"}: return "in_progress" if current == "todo" else current if event_type in {"pr_opened", "pr_synced"}: return "review" if event_type in {"checks_passed", "merged"}: return "ready_for_acceptance" if event_type == "accepted": return "done" if event_type == "blocked": return "blocked" return current def _derive_workflow_state(self, event_type: str, payload: dict[str, Any]) -> str: if event_type == "merged": return "ready_for_acceptance" if event_type == "pr_opened": return "in_review" if event_type == "commit": return "in_progress" if event_type == "checks_passed": return "ready_for_acceptance" if event_type == "accepted": return "done" return str(payload.get("workflow_state") or "") def _map_github_event_type(self, *, event_type: str, payload: dict[str, Any]) -> str: et = str(event_type or "").strip().lower() action = str(payload.get("action") or "").strip().lower() if et == "push": return "commit" if et == "pull_request" and action == "opened": return "pr_opened" if et == "pull_request" and action == "synchronize": return "pr_synced" if et == "pull_request" and action == "closed" and bool((payload.get("pull_request") or {}).get("merged")): return "merged" if et in {"check_run", "check_suite"} and action == "completed": conclusion = str(payload.get("conclusion") or (payload.get("check_run") or {}).get("conclusion") or "").lower() if conclusion == "success": return "checks_passed" return et or "github_event" def _branch_from_github_payload(self, event_type: str, payload: dict[str, Any]) -> Optional[str]: et = str(event_type or "").strip().lower() if et == "push": ref = str(payload.get("ref") or "").strip() if ref.startswith("refs/heads/"): return ref.removeprefix("refs/heads/") return ref or None pr = payload.get("pull_request") or {} head = (pr.get("head") or {}) if isinstance(pr, dict) else {} return str(head.get("ref") or "").strip() or None def _coerce_time(self, value: Any) -> Optional[datetime]: if value in {None, ""}: return None if hasattr(value, "isoformat"): return value text = str(value).strip() if not text: return None try: parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) return parsed.replace(tzinfo=None) except ValueError: return None def _safe_int(self, value: Any) -> Optional[int]: try: if value in {None, ""}: return None return int(value) except (TypeError, ValueError): return None def _score_success_criteria_hits( self, *, success_criteria: list[str], usage: dict[str, Any], prompt_package_count: int, has_binding: bool, ) -> list[dict[str, Any]]: hits: list[dict[str, Any]] = [] for criterion in success_criteria: normalized = str(criterion or "").strip() hit = has_binding and prompt_package_count > 0 lower = normalized.lower() if "成本" in normalized or "cost" in lower: estimated = float((usage.get("summary", {}) or {}).get("estimated_monthly_cost_usd") or 0.0) current = float(((usage.get("summary", {}) or {}).get("month") or {}).get("cost_usd") or 0.0) hit = estimated <= 0 or current <= estimated hits.append({"criterion": normalized, "hit": hit}) return hits async def _create_validation_fix_task( self, *, project_id: int, charter: TruthNode, validation_run: ReleaseValidationRun, ) -> Task: top_risk = next(iter(list(validation_run.risk_items or [])), {}) task = Task( tenant_id=self.tenant_id, project_id=int(project_id), truth_node_id=int(charter.id), title=f"修复发布阻断:{str(top_risk.get('title') or '验证未通过')}", description=str(top_risk.get("detail") or "补齐阻断问题后重新跑发布验证。"), required_skill_tag_ids=[], status="todo", priority="high", manual_overrides={ "origin": "release_validation", "validation_run_id": int(validation_run.id), "fix_task_kind": "release_fix", }, ) self.db.add(task) await self.db.flush() return task def _github_install_url(self) -> Optional[str]: install_url = str(settings.github_app_install_url or "").strip() if install_url: return install_url slug = str(settings.github_app_slug or "").strip() if slug: return f"https://github.com/apps/{slug}/installations/new" return None def _verify_github_signature(self, *, raw_body: bytes, signature_256: Optional[str]) -> None: expected_secret = str(settings.github_app_webhook_secret or "").strip() if not expected_secret: return if not signature_256: raise ValueError("缺少 GitHub webhook 签名") expected = "sha256=" + hmac.new( expected_secret.encode("utf-8"), raw_body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, str(signature_256).strip()): raise ValueError("GitHub webhook 签名校验失败") async def _load_github_installation_payloads(self) -> list[dict[str, Any]]: endpoint = str(settings.github_app_sync_endpoint or "").strip() if endpoint: headers: dict[str, str] = { "Accept": "application/json", "User-Agent": "PMFlow-GitHubAppSync/1.0", } token = str(settings.github_app_sync_token or "").strip() if token: headers["Authorization"] = f"Bearer {token}" req = urllib_request.Request(endpoint, headers=headers, method="GET") with urllib_request.urlopen(req, timeout=20) as response: payload = json.loads(response.read().decode("utf-8")) if isinstance(payload, dict) and isinstance(payload.get("installations"), list): return list(payload.get("installations") or []) if isinstance(payload, list): return list(payload) fixture = str(settings.github_app_installations_fixture or "").strip() if fixture: if fixture.startswith("["): parsed = json.loads(fixture) else: with open(os.path.expanduser(fixture), "r", encoding="utf-8") as handle: parsed = json.load(handle) return list(parsed or []) return [] async def _upsert_github_installation(self, row: dict[str, Any]) -> GitHubAppInstallation: installation_id = str(row.get("installation_id") or row.get("id") or "").strip() if not installation_id: raise ValueError("GitHub App 安装记录缺少 installation_id") account = dict(row.get("account") or {}) account_login = str(row.get("account_login") or account.get("login") or "").strip() or "unknown" result = await self.db.execute( select(GitHubAppInstallation).where( and_( GitHubAppInstallation.tenant_id == self.tenant_id, GitHubAppInstallation.installation_id == installation_id, ) ) ) installation = result.scalar_one_or_none() if installation is None: installation = GitHubAppInstallation( tenant_id=self.tenant_id, installation_id=installation_id, account_login=account_login, account_type=str(row.get("account_type") or account.get("type") or "").strip() or None, app_slug=str(row.get("app_slug") or settings.github_app_slug or "").strip() or None, target_type=str(row.get("target_type") or "").strip() or None, status=str(row.get("status") or "active").strip() or "active", permissions=dict(row.get("permissions") or {}), events=list(row.get("events") or []), app_metadata=dict(row.get("metadata") or {}), last_synced_at=now_cn_naive(), ) self.db.add(installation) await self.db.flush() return installation installation.account_login = account_login or installation.account_login installation.account_type = str(row.get("account_type") or account.get("type") or "").strip() or installation.account_type installation.app_slug = str(row.get("app_slug") or settings.github_app_slug or "").strip() or installation.app_slug installation.target_type = str(row.get("target_type") or "").strip() or installation.target_type installation.status = str(row.get("status") or installation.status).strip() or installation.status installation.permissions = dict(row.get("permissions") or installation.permissions or {}) installation.events = list(row.get("events") or installation.events or []) installation.app_metadata = dict(row.get("metadata") or installation.app_metadata or {}) installation.last_synced_at = now_cn_naive() return installation async def _upsert_github_installation_repositories(self, *, installation_id: str, repositories: list[dict[str, Any]]) -> int: seen_ids: set[str] = set() count = 0 for row in repositories: external_repo_id = str(row.get("external_repo_id") or row.get("id") or "").strip() if not external_repo_id: continue seen_ids.add(external_repo_id) full_name = str(row.get("full_name") or "").strip() owner = str(row.get("owner") or (full_name.split("/")[0] if "/" in full_name else "")).strip() repo_name = str(row.get("repo_name") or (full_name.split("/")[1] if "/" in full_name else "")).strip() result = await self.db.execute( select(GitHubAppRepository).where( and_( GitHubAppRepository.tenant_id == self.tenant_id, GitHubAppRepository.installation_id == installation_id, GitHubAppRepository.external_repo_id == external_repo_id, ) ) ) repo = result.scalar_one_or_none() if repo is None: repo = GitHubAppRepository( tenant_id=self.tenant_id, installation_id=installation_id, external_repo_id=external_repo_id, owner=owner, repo_name=repo_name, full_name=full_name or f"{owner}/{repo_name}", default_branch=str(row.get("default_branch") or "main"), is_private=bool(row.get("is_private", True)), permissions=dict(row.get("permissions") or {}), status="active", last_synced_at=now_cn_naive(), ) self.db.add(repo) else: repo.owner = owner or repo.owner repo.repo_name = repo_name or repo.repo_name repo.full_name = full_name or repo.full_name repo.default_branch = str(row.get("default_branch") or repo.default_branch or "main") repo.is_private = bool(row.get("is_private", repo.is_private)) repo.permissions = dict(row.get("permissions") or repo.permissions or {}) repo.status = "active" repo.last_synced_at = now_cn_naive() count += 1 if seen_ids: stale_result = await self.db.execute( select(GitHubAppRepository).where( and_( GitHubAppRepository.tenant_id == self.tenant_id, GitHubAppRepository.installation_id == installation_id, GitHubAppRepository.external_repo_id.not_in(list(seen_ids)), ) ) ) for stale in stale_result.scalars().all(): stale.status = "archived" stale.last_synced_at = now_cn_naive() return count def _render_evidence_pack_markdown(self, content: dict[str, Any]) -> str: lines = [ "# PMFlow 合规证据包", "", f"- 项目 ID: {content.get('project_id') or '-'}", f"- 宪章 ID: {content.get('charter_id') or '-'}", f"- 发布节点 ID: {content.get('release_node_id') or '-'}", "", "## 模型绑定", "", f"```json\n{json.dumps(content.get('model_binding') or {}, ensure_ascii=False, indent=2)}\n```", "", "## 检查清单", "", ] for item in list(content.get("checklist") or []): lines.append(f"- [{str(item.get('status') or '').upper()}] {item.get('label') or item.get('key')}") detail = str(item.get("detail") or "").strip() if detail: lines.append(f" - {detail}") issues = list(content.get("issues") or []) lines.extend(["", "## 问题项", ""]) if not issues: lines.append("- 无") else: for issue in issues: lines.append(f"- {issue.get('label') or issue.get('key')}: {issue.get('detail') or ''}".strip()) return "\n".join(lines).strip() + "\n"