PMFlow/backend/app/services/sstc/governance.py

230 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from .shared import *
from .signal_core import _as_dict
async def _project_task_metrics(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
) -> dict[str, int]:
result = await db.execute(
select(Task.status).where(
and_(
Task.project_id == project_id,
Task.tenant_id == tenant_id,
)
)
)
statuses = [str(row[0] or "").lower() for row in result.all()]
total = len(statuses)
done = len([s for s in statuses if s in DONE_STATUSES])
return {"total": total, "done": done}
async def _build_governance_snapshot(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
charter_id: int | None = None,
) -> dict[str, Any]:
charter: TruthNode | None = None
if charter_id:
charter = (
await db.execute(
select(TruthNode).where(
and_(
TruthNode.id == charter_id,
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
)
).scalar_one_or_none()
if charter is None:
charter = (
await db.execute(
select(TruthNode)
.where(
and_(
TruthNode.project_id == project_id,
TruthNode.tenant_id == tenant_id,
TruthNode.node_type == "charter",
)
)
.order_by(TruthNode.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
confidence_score = None
confidence_risk = False
charter_id_value = int(charter.id) if charter else None
if charter:
charter_content = _as_dict(charter.content)
metadata = charter_content.get("metadata")
if isinstance(metadata, dict):
raw_score = metadata.get("confidence_score")
try:
confidence_score = float(raw_score)
except (TypeError, ValueError):
confidence_score = None
confidence_risk = confidence_score is not None and confidence_score < 0.8
binding = None
if charter_id_value:
binding = (
await db.execute(
select(ModelRequirementBinding, ModelVersion)
.join(
ModelVersion,
ModelVersion.id == ModelRequirementBinding.model_version_id,
isouter=True,
)
.where(
and_(
ModelRequirementBinding.tenant_id == tenant_id,
ModelRequirementBinding.truth_node_id == charter_id_value,
)
)
.order_by(ModelRequirementBinding.created_at.desc())
.limit(1)
)
).first()
model_block = False
model_info: dict[str, Any] = {
"bound": False,
"compatibility_score": None,
"model_version_id": None,
"model_name": None,
"provider": None,
}
est_cost = 0.01
if binding:
binding_row, model_row = binding
score = int(binding_row.compatibility_score or 0)
model_block = score < 70
model_info = {
"bound": True,
"compatibility_score": score,
"model_version_id": int(binding_row.model_version_id),
"model_name": getattr(model_row, "name", None),
"provider": getattr(model_row, "provider", None),
}
try:
est_cost = max(float(binding_row.estimated_cost or 0.01), 0.01)
except (TypeError, ValueError):
est_cost = 0.01
budget_service = BudgetEnforcementService(db)
budget_check = await budget_service.check_budget_before_ai_call(
tenant_id=tenant_id,
estimated_cost_usd=est_cost,
project_id=project_id,
)
budget_block = not bool(budget_check.get("allowed", True))
intervention_required = confidence_risk or model_block or budget_block
blockers: list[str] = []
warnings: list[str] = []
if confidence_risk:
warnings.append("宪章 AI 置信度低于 0.80,建议补充关键事实后再推进。")
if model_block:
blockers.append("模型匹配分低于 70,禁止自动推进发布验证。")
elif not model_info["bound"]:
warnings.append("当前宪章未绑定模型版本,建议先完成模型能力矩阵校验。")
if budget_block:
blockers.append(f"预算管控阻断:{budget_check.get('reason') or 'budget_blocked'}")
elif str(budget_check.get("reason") or "") == "within_budget":
usage = budget_check.get("usage_percent")
if isinstance(usage, (int, float)) and float(usage) >= 80:
warnings.append("预算已接近阈值,建议执行成本优化方案。")
return {
"project_id": project_id,
"charter_id": charter_id_value,
"intervention_required": intervention_required,
"blockers": blockers,
"warnings": warnings,
"confidence": {
"score": confidence_score,
"risk": confidence_risk,
},
"model_binding": model_info,
"budget": budget_check,
}
async def _assert_release_transition_allowed(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
release_node: TruthNode,
target_status: str,
) -> None:
current = str(release_node.status or "pending").lower()
target = str(target_status or "").lower()
if target == "verified":
raise HTTPException(status_code=409, detail="请通过验证完成接口标记为已验证")
if target not in RELEASE_STATUS_TRANSITIONS.get(current, set()):
raise HTTPException(
status_code=409,
detail=f"非法状态流转:{current} -> {target}",
)
content = _as_dict(release_node.content)
if target in {"releasing", "online"}:
task_ids = [int(tid) for tid in (content.get("task_ids") or []) if str(tid).isdigit()]
if not task_ids:
raise HTTPException(status_code=409, detail="发布计划未绑定任务")
task_result = await db.execute(
select(Task.id, Task.status).where(
and_(
Task.tenant_id == tenant_id,
Task.project_id == project_id,
Task.id.in_(task_ids),
)
)
)
task_rows = list(task_result.all())
if len(task_rows) != len(task_ids):
raise HTTPException(status_code=409, detail="发布计划存在无效任务绑定")
not_done = [row for row in task_rows if str(row[1] or "").lower() not in DONE_STATUSES]
if not_done:
raise HTTPException(status_code=409, detail="存在未完成任务,禁止进入发布流程")
governance = await _build_governance_snapshot(
db,
tenant_id=tenant_id,
project_id=project_id,
charter_id=int(content.get("charter_id") or 0) or None,
)
if governance.get("blockers"):
raise HTTPException(
status_code=409,
detail=";".join([str(item) for item in governance.get("blockers", [])]),
)
verification_items = content.get("verification_items")
if not isinstance(verification_items, list) or len(verification_items) == 0:
raise HTTPException(status_code=409, detail="请先生成发布验证清单")
def _assert_charter_aligned(charter: TruthNode) -> None:
if (charter.status or "").lower() not in ALIGNED_STATUSES:
raise HTTPException(
status_code=409,
detail="宪章未对齐,任务环节未解锁",
)
def _is_manager(user: User) -> bool:
return has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"])
def _require_manager(user: User) -> None:
if not _is_manager(user):
raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作")