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