PMFlow/backend/app/services/ai_readiness_service.py

476 lines
21 KiB
Python

"""AI readiness evaluation for protected PMFlow product scenes."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import AICallLog, Integration
from ..services.ai_routing_scene_contracts import AI_SCENE_REGISTRY, scene_contract
from ..services.ai_routing_governance_service import (
CAPABILITY_SLOT_INTEGRATION_TYPE,
LLM_ENGINE_INTEGRATION_TYPE,
PLATFORM_RUNTIME_TENANT_ID,
)
from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
from ..utils.timezone import now_cn_naive
def _safe_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _safe_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _is_success_status(status: str) -> bool:
normalized = str(status or "").strip().lower()
return normalized.startswith("success") or normalized in {"ok", "success_cache_hit", "success_failover"}
class AIReadinessService:
"""Derive customer-facing AI readiness from runtime facts."""
SCENE_KEY = "project_launch"
FUNCTION_NAME = "pmflow_launch_draft_v1"
def __init__(self, db: AsyncSession, *, tenant_id: int):
self.db = db
self.tenant_id = int(tenant_id)
async def get_project_launch_readiness(self) -> dict[str, Any]:
engine_bundle = await self._load_platform_integration(LLM_ENGINE_INTEGRATION_TYPE)
slot_bundle = await self._load_platform_integration(CAPABILITY_SLOT_INTEGRATION_TYPE)
recent_calls = await self._recent_launch_calls()
latest_call = recent_calls[0] if recent_calls else None
return self._build_project_launch_readiness(
engine_bundle=engine_bundle,
slot_bundle=slot_bundle,
latest_call=latest_call,
recent_calls=recent_calls,
smoke_error=None,
)
async def run_project_launch_check(self, *, user_id: int) -> dict[str, Any]:
smoke_error: str | None = None
try:
await PMFlowLaunchService(self.db, tenant_id=PLATFORM_RUNTIME_TENANT_ID).generate_draft_payload_for_readiness_check(
user_id=int(user_id),
)
except PMFlowLaunchError as exc:
smoke_error = f"{exc.code}: {exc.message}"
except Exception as exc: # pragma: no cover - defensive boundary
smoke_error = str(exc)
engine_bundle = await self._load_platform_integration(LLM_ENGINE_INTEGRATION_TYPE)
slot_bundle = await self._load_platform_integration(CAPABILITY_SLOT_INTEGRATION_TYPE)
recent_calls = await self._recent_launch_calls()
latest_call = recent_calls[0] if recent_calls else None
payload = self._build_project_launch_readiness(
engine_bundle=engine_bundle,
slot_bundle=slot_bundle,
latest_call=latest_call,
recent_calls=recent_calls,
smoke_error=smoke_error,
)
payload["platform"]["last_smoke_error"] = smoke_error
payload["platform"]["last_checked_at"] = now_cn_naive().isoformat()
return payload
async def _load_platform_integration(self, integration_type: str) -> dict[str, Any]:
row = (
await self.db.execute(
select(Integration).where(
and_(
Integration.tenant_id == PLATFORM_RUNTIME_TENANT_ID,
Integration.type == integration_type,
)
)
)
).scalar_one_or_none()
return _safe_dict(row.config if row is not None else {})
async def _recent_launch_calls(self, *, limit: int = 20, hours: int = 24) -> list[AICallLog]:
tenant_ids = sorted({int(self.tenant_id), int(PLATFORM_RUNTIME_TENANT_ID)})
since = now_cn_naive() - timedelta(hours=hours)
return (
await self.db.execute(
select(AICallLog)
.where(
and_(
AICallLog.tenant_id.in_(tenant_ids),
AICallLog.function_name == self.FUNCTION_NAME,
AICallLog.created_at >= since,
)
)
.order_by(AICallLog.created_at.desc(), AICallLog.id.desc())
.limit(limit)
)
).scalars().all()
def _build_project_launch_readiness(
self,
*,
engine_bundle: dict[str, Any],
slot_bundle: dict[str, Any],
latest_call: AICallLog | None,
recent_calls: list[AICallLog],
smoke_error: str | None,
) -> dict[str, Any]:
blockers: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
checks: dict[str, str] = {
"provider_runtime": "unknown",
"capability_probe": "unknown",
"active_slot": "unknown",
"scene_route": "unknown",
"recent_smoke": "missing",
"recent_failures": "unknown",
}
engines = [item for item in _safe_list(engine_bundle.get("engines")) if isinstance(item, dict)]
engines_by_id = {str(item.get("id") or ""): item for item in engines if str(item.get("id") or "")}
active_slots = self._active_slots(slot_bundle)
contract = scene_contract(self.SCENE_KEY)
scene_registry_entry = _safe_dict(AI_SCENE_REGISTRY.get(self.SCENE_KEY))
provider_ok = any(self._engine_connectable(item) for item in engines)
checks["provider_runtime"] = "passed" if provider_ok else "failed"
if not provider_ok:
blockers.append(
self._blocker(
"provider_unavailable",
"供应商运行时不可用",
"平台运行租户没有可用的模型运行时,项目草案生成必须保持关闭。",
"failed",
"查看供应商与凭据",
"/platform/ai-routing-providers",
)
)
scene_ok = (
self.FUNCTION_NAME in set(scene_registry_entry.get("function_names") or [])
and "structured" in set(scene_registry_entry.get("required_slots") or [])
and str(contract.get("expected_slot_key") or "").strip() == "structured"
)
checks["scene_route"] = "passed" if scene_ok else "failed"
if not scene_ok:
blockers.append(
self._blocker(
"scene_route_mismatch",
"项目启动场景合同不匹配",
"project_launch 必须绑定结构化草案函数和 structured 槽。",
"failed",
"查看场景路由",
"/platform/ai-routing-scenes?scene=project_launch",
)
)
structured_slot = _safe_dict(active_slots.get("structured"))
structured_engine_id = str(structured_slot.get("primary_engine_id") or "").strip()
structured_engine = engines_by_id.get(structured_engine_id)
if not structured_engine_id:
checks["active_slot"] = "failed"
blockers.append(
self._blocker(
"structured_slot_unpublished",
"结构化槽未发布",
"project_launch 需要 active structured 槽;当前结构化槽未发布到运行版本。",
"failed",
"发布结构化槽",
"/platform/ai-routing-slots?slot=structured",
)
)
elif not isinstance(structured_engine, dict) or not self._engine_connectable(structured_engine):
checks["active_slot"] = "failed"
blockers.append(
self._blocker(
"provider_unavailable",
"结构化槽运行时不可用",
"active structured 槽绑定的运行时不存在、被停用或连接不可用。",
"failed",
"查看结构化槽",
"/platform/ai-routing-slots?slot=structured",
)
)
else:
checks["active_slot"] = "passed"
coverage = self._structured_capability_coverage(structured_engine)
checks["capability_probe"] = "passed" if coverage["satisfied"] else "failed"
if not coverage["satisfied"]:
blockers.append(
self._blocker(
"capability_probe_missing",
"结构化能力证据不足",
"结构化槽运行时必须具备已验证的结构化输出与 JSON Schema 能力。",
"failed",
"执行能力探测",
"/platform/ai-routing-providers?capability=structured",
)
)
if latest_call is None:
checks["recent_smoke"] = "missing"
checks["recent_failures"] = "unknown"
warnings.append(
self._blocker(
"smoke_not_run",
"尚未运行草案 smoke test",
"供应商和槽位恢复后,需要一次项目草案结构化 smoke test 覆盖历史失败。",
"warning",
"运行草案 smoke",
"/platform/ai-routing-overview",
)
)
else:
latest_success = _is_success_status(str(latest_call.status or ""))
recent_success = next((call for call in recent_calls if _is_success_status(str(call.status or ""))), None)
consecutive_failures = 0
for call in recent_calls:
if _is_success_status(str(call.status or "")):
break
consecutive_failures += 1
if latest_success:
checks["recent_smoke"] = "passed"
checks["recent_failures"] = "passed"
elif recent_success is not None and consecutive_failures < 3:
checks["recent_smoke"] = "passed"
checks["recent_failures"] = "warning"
warnings.append(
self._blocker(
"recent_generation_failed",
"最近一次草案生成失败",
self._customer_safe_failure_summary(str(latest_call.response or latest_call.status or "")),
"warning",
"查看失败诊断",
"/platform/ai-routing-failures",
)
)
else:
checks["recent_smoke"] = "failed"
checks["recent_failures"] = "failed"
warnings.append(
self._blocker(
"recent_generation_failed",
"最近一次草案生成失败",
self._customer_safe_failure_summary(str(latest_call.response or latest_call.status or "")),
"warning",
"运行草案 smoke",
"/platform/ai-routing-overview",
)
)
if smoke_error:
checks["recent_smoke"] = "failed"
warnings.append(
self._blocker(
"smoke_failed",
"草案 smoke test 失败",
self._customer_safe_failure_summary(smoke_error),
"warning",
"查看失败诊断",
"/platform/ai-routing-failures",
)
)
if blockers:
status = "blocked"
elif checks["recent_smoke"] == "passed":
status = "ready"
elif warnings:
status = "recovering"
else:
status = "unknown"
can_generate = status == "ready"
customer = self._customer_copy(status)
workspace_owner = self._workspace_owner_copy(status)
visible_blockers = self._customer_blockers(status=status, warnings=warnings, hard_blockers=blockers)
platform_blockers = [*blockers, *warnings]
return {
"scene": self.SCENE_KEY,
"status": status,
"customer_state": "ai_collaboration_ready" if can_generate else "ai_draft_temporarily_unavailable",
"short_label": "AI 协作可用" if can_generate else ("AI 协作恢复中" if status == "recovering" else "AI 协作暂不可用"),
"title": customer["title"],
"summary": customer["summary"],
"updated_at_label": "可用" if can_generate else ("恢复中" if status == "recovering" else "暂不可用"),
"can_generate_project_draft": can_generate,
"blockers": visible_blockers,
"customer_actions": [{"label": "新建项目", "to": "/project-launch"}] if can_generate else [
{"label": "记录项目想法", "to": "/project-launch"},
{"label": "查看所有项目", "to": "/space/projects"},
],
"workspace_owner_actions": [
{"label": "查看空间 AI 状态", "to": "/ai-config?tab=setup"},
{"label": "查看团队策略", "to": "/ai-config/team-ai-policy"},
],
"platform": {
"severity": "ok" if can_generate else ("warning" if status == "recovering" else "critical"),
"blockers": platform_blockers,
"checks": checks,
"latest_call": self._latest_call_payload(latest_call),
"last_checked_at": now_cn_naive().isoformat(),
},
"platform_diagnostic": {
"cause_code": "ready" if can_generate else (platform_blockers[0]["code"] if platform_blockers else "readiness_unknown"),
"cause_owner": "pmflow_platform",
"raw_status": status,
"severity": "ok" if can_generate else ("warning" if status == "recovering" else "critical"),
"affected_scenes": ["项目草案生成", "暂存想法晋升草案"],
"customer_title": customer["title"],
"customer_summary": customer["summary"],
"operator_summary": workspace_owner["summary"] if status != "ready" else "项目草案生成链路可用。",
"platform_actions": [
{"label": "查看供应商与凭据", "to": "/platform/ai-routing-providers"},
{"label": "查看能力槽", "to": "/platform/ai-routing-slots"},
{"label": "查看失败诊断", "to": "/platform/ai-routing-failures"},
],
"checks": checks,
"blockers": platform_blockers,
},
}
@staticmethod
def _active_slots(slot_bundle: dict[str, Any]) -> dict[str, Any]:
active = _safe_dict(slot_bundle.get("active"))
slots = active.get("slots") if isinstance(active.get("slots"), dict) else slot_bundle.get("slots")
return _safe_dict(slots)
@staticmethod
def _engine_connectable(engine: dict[str, Any]) -> bool:
if not bool(engine.get("enabled", True)):
return False
if not str(engine.get("api_key") or "").strip() and str(engine.get("provider") or "").strip().lower() != "ollama":
return False
status = str(engine.get("connection_status") or "").strip().lower()
last_test = str(engine.get("last_test_status") or "").strip().lower()
return status == "available" or last_test == "success"
@staticmethod
def _structured_capability_coverage(engine: dict[str, Any]) -> dict[str, Any]:
capabilities = _safe_dict(engine.get("capabilities"))
evidence = _safe_dict(capabilities.get("capability_evidence"))
def _verified(key: str) -> bool:
row = _safe_dict(evidence.get(key))
return str(row.get("status") or "").strip().lower() == "verified"
structured = bool(capabilities.get("structured_output"))
json_schema = bool(capabilities.get("json_schema"))
probe_status = str(capabilities.get("capability_probe_status") or "").strip().lower()
has_evidence = _verified("structured_output") and _verified("json_schema")
return {
"satisfied": structured and json_schema and (probe_status == "passed" or has_evidence),
"structured_output": structured,
"json_schema": json_schema,
"probe_status": probe_status or "untested",
"has_evidence": has_evidence,
}
@staticmethod
def _blocker(
code: str,
label: str,
detail: str,
status: str,
action_label: str | None = None,
action_to: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"code": code,
"key": code,
"label": label,
"description": detail,
"detail": detail,
"status": status,
}
if action_label and action_to:
payload["action_label"] = action_label
payload["action_to"] = action_to
return payload
@staticmethod
def _customer_copy(status: str) -> dict[str, str]:
if status == "ready":
return {
"title": "AI 协作可用",
"summary": "PMFlow 可以生成项目草案、提取需求与风险,并把结果交回主流程确认。",
}
if status == "recovering":
return {
"title": "AI 草案生成正在恢复",
"summary": "平台能力正在恢复验证中。你可以先记录想法,系统确认稳定后会开放草案生成。",
}
return {
"title": "AI 项目草案暂不可用",
"summary": "当前空间暂时不能生成 AI 项目草案。你可以先记录项目想法、查看项目和处理待确认事项;系统不会用模板草案冒充 AI 结果。",
}
@staticmethod
def _workspace_owner_copy(status: str) -> dict[str, str]:
if status == "ready":
return {"summary": "项目草案生成链路可用,确认前仍不会写入正式项目。"}
if status == "recovering":
return {"summary": "平台 AI 草案能力正在恢复验证。空间配置无需反复调整,可以继续维护团队策略和知识组织。"}
return {"summary": "平台 AI 草案能力尚未恢复。空间内仍可维护团队策略、工具连接和知识组织。"}
def _customer_blockers(
self,
*,
status: str,
warnings: list[dict[str, Any]],
hard_blockers: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if status == "ready":
return [
self._blocker("draft_generation", "项目草案生成", "可以把项目想法生成可确认的结构化草案。", "passed"),
self._blocker("safe_confirmation", "确认保护", "生成结果仍进入项目主流程确认,不会直接写入正式项目状态。", "passed"),
]
impact_status = "warning" if status == "recovering" else "failed"
impact_text = "AI 草案生成正在恢复验证。" if status == "recovering" else "暂时不能生成 AI 项目草案。"
if hard_blockers:
impact_text = "平台 AI 草案能力尚未恢复。"
elif warnings:
impact_text = "平台正在验证 AI 草案生成链路。"
return [
self._blocker("draft_generation", "当前影响", impact_text, impact_status),
self._blocker("existing_work", "仍可继续", "记录想法、处理待确认、查看项目和推进已有项目不受影响。", "passed"),
self._blocker("safe_fallback", "保护规则", "平台能力未恢复前,不会开放 AI 草案生成,也不会用模板草案冒充 AI 结果。", "warning"),
]
@staticmethod
def _latest_call_payload(row: AICallLog | None) -> dict[str, Any] | None:
if row is None:
return None
return {
"id": int(row.id),
"created_at": row.created_at.isoformat() if row.created_at else None,
"function_name": str(row.function_name or ""),
"status": str(row.status or ""),
"planned_tier": str(row.planned_tier or ""),
"executed_tier": str(row.executed_tier or ""),
"routing_decision": str(row.routing_decision or ""),
"route_source": str(row.route_source or ""),
"route_rule_name": str(row.route_rule_name or ""),
}
@staticmethod
def _customer_safe_failure_summary(raw: str) -> str:
text = str(raw or "").strip()
if not text:
return "系统没有拿到可用的结构化草案结果。"
if "401" in text or "Unauthorized" in text:
return "最近一次草案生成失败,平台凭据或供应商授权需要重新验证。"
if "structured" in text:
return "最近一次草案生成没有通过结构化结果校验。"
return text[:180]
__all__ = ["AIReadinessService"]