PMFlow/backend/app/services/ai_project_control_service.py

3004 lines
129 KiB
Python

"""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"