"""AI 应用项目控制面服务。""" from __future__ import annotations import hashlib import hmac import json import os import time from dataclasses import dataclass from datetime import datetime from typing import Any, Optional from urllib import request as urllib_request import httpx from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession try: # pragma: no cover - dependency fallback is environment-specific from jose import jwt as jose_jwt except Exception: # pragma: no cover try: import jwt as jose_jwt # type: ignore except Exception: jose_jwt = None # type: ignore[assignment] 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)) normalized_delivery_id = str(delivery_id or "").strip() or None normalized_event_source_id = str(event_source_id or "").strip() or None existing_event = await self._find_existing_progress_event( task_id=int(task_id), project_id=int(project_id), delivery_id=normalized_delivery_id, event_source_id=normalized_event_source_id, ) if existing_event is not None: return { "id": str(existing_event.id), "task_id": str(task.id), "project_id": str(project_id), "event_type": existing_event.event_type, "source": existing_event.source, "workflow_state": str((existing_event.payload or {}).get("workflow_state") or ""), "derived_task_status": existing_event.derived_task_status, "task_status": task.status, "delivery_id": existing_event.delivery_id, "event_source_id": existing_event.event_source_id, "occurred_at": existing_event.occurred_at.isoformat() if existing_event.occurred_at else None, "payload": existing_event.payload, "idempotent_replay": True, } 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=normalized_delivery_id, event_source_id=normalized_event_source_id, 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, "idempotent_replay": False, } async def _find_existing_progress_event( self, *, task_id: int, project_id: int, delivery_id: Optional[str], event_source_id: Optional[str], ) -> Optional[ProgressEvent]: clauses = [] if delivery_id: clauses.append(ProgressEvent.delivery_id == delivery_id) if event_source_id: clauses.append(ProgressEvent.event_source_id == event_source_id) if not clauses: return None result = await self.db.execute( select(ProgressEvent) .where( and_( ProgressEvent.tenant_id == self.tenant_id, ProgressEvent.project_id == int(project_id), ProgressEvent.task_id == int(task_id), or_(*clauses), ) ) .order_by(ProgressEvent.occurred_at.desc(), ProgressEvent.created_at.desc(), ProgressEvent.id.desc()) .limit(1) ) return result.scalar_one_or_none() 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 {}) execution_result = payload.get("execution_result") if isinstance(payload.get("execution_result"), dict) else {} delivery_artifacts = payload.get("delivery_artifacts") if not isinstance(delivery_artifacts, dict): delivery_artifacts = execution_result.get("delivery_artifacts") if isinstance(execution_result, dict) else None artifact_review_action = payload if payload.get("protocol_version") == "artifact_review_action_v1" else None artifact_rework_package = payload if payload.get("protocol_version") == "artifact_rework_package_v1" else None runtime_execution_package_pull = payload if payload.get("protocol_version") == "runtime_execution_package_pull_v1" else None 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, "prompt_package_id": str(payload.get("prompt_package_id") or "").strip() or None, "delivery_artifacts": delivery_artifacts if isinstance(delivery_artifacts, dict) else None, "artifact_review_action": artifact_review_action if isinstance(artifact_review_action, dict) else None, "artifact_rework_package": artifact_rework_package if isinstance(artifact_rework_package, dict) else None, "runtime_execution_package_pull": runtime_execution_package_pull if isinstance(runtime_execution_package_pull, dict) else 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 []) if str(settings.github_app_id or "").strip() or self._github_app_private_key(): return await self._load_github_installations_from_github_app() return [] def _github_app_private_key(self) -> Optional[str]: private_key = str(settings.github_app_private_key or "").strip() if private_key: return private_key.replace("\\n", "\n") private_key_path = str(settings.github_app_private_key_path or "").strip() if not private_key_path: return None with open(os.path.expanduser(private_key_path), "r", encoding="utf-8") as handle: return handle.read() def _github_app_jwt(self) -> str: app_id = str(settings.github_app_id or "").strip() private_key = self._github_app_private_key() if not app_id or not private_key: raise ValueError("GitHub App 同步缺少 GITHUB_APP_ID 或 GITHUB_APP_PRIVATE_KEY(_PATH)") if jose_jwt is None: raise ValueError("GitHub App 同步缺少 JWT 依赖 python-jose 或 PyJWT") issued_at = int(time.time()) - 60 payload = { "iat": issued_at, "exp": issued_at + 540, "iss": app_id, } return jose_jwt.encode(payload, private_key, algorithm="RS256") async def _load_github_installations_from_github_app(self) -> list[dict[str, Any]]: token = self._github_app_jwt() headers = { "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", "User-Agent": "PMFlow-GitHubAppSync/1.0", "X-GitHub-Api-Version": "2022-11-28", } async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client: installations_payload = await self._github_get_all_pages( client, "https://api.github.com/app/installations", headers=headers, ) rows: list[dict[str, Any]] = [] for installation in installations_payload: if not isinstance(installation, dict): continue installation_id = str(installation.get("id") or "").strip() if not installation_id: continue installation_token = await self._github_installation_access_token( client, installation_id, headers=headers, ) installation_headers = { **headers, "Authorization": f"Bearer {installation_token}", } repositories_payload = await self._github_get_all_pages( client, "https://api.github.com/installation/repositories", headers=installation_headers, ) rows.append(self._normalize_github_installation_payload(installation, repositories_payload)) return rows async def _github_installation_access_token( self, client: httpx.AsyncClient, installation_id: str, *, headers: dict[str, str], ) -> str: response = await client.post( f"https://api.github.com/app/installations/{installation_id}/access_tokens", headers=headers, ) response.raise_for_status() payload = response.json() token = str((payload or {}).get("token") or "").strip() if isinstance(payload, dict) else "" if not token: raise ValueError("GitHub App 安装令牌响应缺少 token") return token async def _github_get_all_pages( self, client: httpx.AsyncClient, url: str, *, headers: dict[str, str], ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] next_url: Optional[str] = url while next_url: response = await client.get(next_url, headers=headers, params={"per_page": 100} if next_url == url else None) response.raise_for_status() payload = response.json() if isinstance(payload, dict) and isinstance(payload.get("installations"), list): items.extend([item for item in payload.get("installations") or [] if isinstance(item, dict)]) elif isinstance(payload, dict) and isinstance(payload.get("repositories"), list): items.extend([item for item in payload.get("repositories") or [] if isinstance(item, dict)]) elif isinstance(payload, list): items.extend([item for item in payload if isinstance(item, dict)]) next_url = self._github_next_link(response.headers.get("link")) return items @staticmethod def _github_next_link(link_header: Optional[str]) -> Optional[str]: if not link_header: return None for part in str(link_header).split(","): section = part.strip() if 'rel="next"' not in section: continue start = section.find("<") end = section.find(">") if start >= 0 and end > start: return section[start + 1 : end] return None def _normalize_github_installation_payload( self, installation: dict[str, Any], repositories: list[dict[str, Any]], ) -> dict[str, Any]: account = dict(installation.get("account") or {}) normalized_repositories = [] for repo in repositories: full_name = str(repo.get("full_name") or "").strip() owner = dict(repo.get("owner") or {}) normalized_repositories.append( { "external_repo_id": str(repo.get("id") or "").strip(), "full_name": full_name, "owner": str(owner.get("login") or (full_name.split("/")[0] if "/" in full_name else "")).strip(), "repo_name": str(repo.get("name") or (full_name.split("/")[1] if "/" in full_name else "")).strip(), "default_branch": str(repo.get("default_branch") or "main").strip() or "main", "is_private": bool(repo.get("private", True)), "permissions": dict(repo.get("permissions") or {}), } ) return { "installation_id": str(installation.get("id") or "").strip(), "account": account, "account_login": str(account.get("login") or "").strip() or "unknown", "account_type": str(account.get("type") or "").strip() or None, "app_slug": str(settings.github_app_slug or "").strip() or None, "target_type": str(installation.get("target_type") or "").strip() or None, "status": "active" if not bool(installation.get("suspended_at")) else "suspended", "permissions": dict(installation.get("permissions") or {}), "events": list(installation.get("events") or []), "metadata": { "repository_selection": installation.get("repository_selection"), "html_url": installation.get("html_url"), }, "repositories": normalized_repositories, } 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"