PMFlow/backend/app/services/ai_project_control_service.py

2770 lines
118 KiB
Python

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