"""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=self.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"]