"""Intelligence Center HTTP surfaces behind one release gate.""" from __future__ import annotations import uuid from decimal import Decimal from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( get_current_user, ) from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import AppException from app.core.response import success_response from app.intelligence.access import resolve_access_plan from app.intelligence.cloud_scope_facade import ( generate_cloud_scope_analysis_public, get_cloud_scope_analysis_history_public, get_cloud_scope_analysis_public, ) from app.intelligence.generation import generate_analysis from app.intelligence.models import IntelligenceRecommendation from app.intelligence.provider_registry import BUSINESS_AUDIENCE_KEY from app.intelligence.read_service import ( read_domain, read_history, read_latest_analysis, read_overview, ) from app.intelligence.recommendations import ( materialize_analysis_recommendations, read_recommendation_roi, read_recommendations, send_recommendation_notifications, transition_recommendation, upgrade_recommendation_handoff, ) from app.intelligence.settings import ( apply_intelligence_read_limit, require_intelligence_enabled, ) from app.intelligence.snapshot_service import SnapshotSubject, latest_snapshot router = APIRouter(prefix="/api/v1/intelligence", tags=["intelligence"]) cloud_scope_router = APIRouter( prefix="/api/v1/cmdb/scopes", tags=["intelligence"], dependencies=[Depends(_leaver_runtime_gate)], ) class CloudScopeAnalysisGenerateRequest(BaseModel): model_config = ConfigDict(extra="forbid") force: bool = False confirm_force: bool = False class RecommendationUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") status: str ignored_reason: str | None = None confirmed_savings: Decimal | None = None realized_savings: Decimal | None = None currency: str | None = None class IntelligenceAnalysisGenerateRequest(BaseModel): model_config = ConfigDict(extra="forbid") force: bool = False confirm_force: bool = False def _trace_id(request: Request) -> str: return getattr(request.state, "trace_id", None) or str(uuid.uuid4()) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_type: str, resource_id: str, detail: dict[str, Any], ) -> None: """Write the platform audit row for an Intelligence Center mutation. The MCP twins already land one through ``app/mcp/runtime.py``; until this existed the HTTP surface — the one an actual administrator clicks — left no trace of who forced a billed brief or who fanned a model-written card out to every IT admin. The row rides the caller's session and is committed by the caller, matching the ``AuditService(db).log(...)`` idiom used by cmdb / asset / sso / auditor. """ await AuditService(db).log( trace_id=_trace_id(request), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type=resource_type, resource_id=resource_id[:128], detail=detail, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) async def _access( db: AsyncSession, user: AuthUser, *, consume_read_limit: bool = True, ): # Release gate first: hidden means 404 for every role, including employees. await require_intelligence_enabled(db) plan = await resolve_access_plan( db, person_id=user.id, role_names=user.role_names, ) # Denied requests do not consume the caller's own read allowance. if consume_read_limit: await apply_intelligence_read_limit(db, user_id=user.id) return plan @router.get("/overview") async def intelligence_overview( request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): plan = await _access(db, current_user) return success_response(await read_overview(db, plan=plan), request) @router.get("/domains/{domain}") async def intelligence_domain( domain: str, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): plan = await _access(db, current_user) return success_response( await read_domain(db, plan=plan, domain=domain), request, ) @router.get("/history") async def intelligence_history( request: Request, limit: int = Query(default=90, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): plan = await _access(db, current_user) return success_response( await read_history(db, plan=plan, limit=limit), request, ) @router.get("/analyses/latest") async def intelligence_latest_analysis( request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): plan = await _access(db, current_user) return success_response(await read_latest_analysis(db, plan=plan), request) @router.post("/analyses/generate") async def intelligence_analysis_generate( payload: IntelligenceAnalysisGenerateRequest, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), _active_employee: None = Depends(_leaver_runtime_gate), ): plan = await _access(db, current_user, consume_read_limit=False) if not plan.can_force_generate: raise AppException( code="INTELLIGENCE_GENERATE_FORBIDDEN", message="Only global administrators can generate the platform brief.", status_code=403, ) if payload.force and not payload.confirm_force: raise AppException( code="INTELLIGENCE_FORCE_CONFIRMATION_REQUIRED", message="Force generation requires explicit confirmation.", status_code=400, ) snapshot = await latest_snapshot( db, subject=SnapshotSubject("platform", "platform", BUSINESS_AUDIENCE_KEY), ) if snapshot is None: raise AppException( code="INTELLIGENCE_NO_SNAPSHOT", message="A deterministic platform snapshot is required before analysis.", status_code=409, ) result = await generate_analysis( db, snapshot=snapshot, analysis_kind="platform_daily_brief", actor_id=current_user.id, force=payload.force, ) materialized = await materialize_analysis_recommendations(db, result.analysis) await _audit( db, request, current_user, action="intelligence.analysis.generate", resource_type="intelligence.analysis", resource_id=str(result.analysis.id), detail={ "analysis_kind": "platform_daily_brief", # ``force`` is the money question: it bypasses the cache, debits # the daily quota and bills a fresh model call. "force": payload.force, "cache_reused": result.cache_reused, "model_calls": 0 if result.cache_reused else 1, "status": result.analysis.status, "recommendations_created": materialized.created, "recommendations_refreshed": materialized.refreshed, "recommendations_superseded": materialized.superseded, "input_token_count": result.analysis.input_token_count, "output_token_count": result.analysis.output_token_count, }, ) await db.commit() return success_response({ "status": result.analysis.status, "analysis_id": str(result.analysis.id), "cache_reused": result.cache_reused, "recommendations_created": materialized.created, # Two counters the panel needs to explain itself: how many standing # suggestions this run re-stated with fresh numbers, and how many it # archived for having gone quiet. "recommendations_refreshed": materialized.refreshed, "recommendations_superseded": materialized.superseded, "model_calls": 0 if result.cache_reused else 1, "input_token_count": result.analysis.input_token_count, "output_token_count": result.analysis.output_token_count, }, request) @router.get("/recommendations") async def intelligence_recommendations( request: Request, limit: int = Query(default=50, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): plan = await _access(db, current_user) subjects = {(item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions} allowed_dimensions = {item.dimension for item in plan.dimensions} return success_response({ "items": await read_recommendations(db, subject_keys=subjects, allowed_dimensions=allowed_dimensions, limit=limit), "roi": await read_recommendation_roi(db, subject_keys=subjects, allowed_dimensions=allowed_dimensions), "model_calls": 0, }, request) @router.patch("/recommendations/{recommendation_id}") async def intelligence_recommendation_update( recommendation_id: str, payload: RecommendationUpdateRequest, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), _active_employee: None = Depends(_leaver_runtime_gate), ): plan = await _access(db, current_user) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can update recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await db.get(IntelligenceRecommendation, parsed_id) if row is None: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) visible_subjects = { (item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions } if (row.subject_type, row.subject_id, row.audience_key) not in visible_subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) if payload.confirmed_savings is not None and (not payload.confirmed_savings.is_finite() or payload.confirmed_savings < 0): raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Confirmed savings cannot be negative.", status_code=422) if payload.realized_savings is not None and (not payload.realized_savings.is_finite() or payload.realized_savings < 0): raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Realized savings cannot be negative.", status_code=422) if payload.realized_savings is not None and payload.status != "resolved": raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Realized savings require a resolved recommendation.", status_code=422) previous_status = row.status transition_recommendation( row, status=payload.status, ignored_reason=payload.ignored_reason, actor_id=current_user.id, confirmed_savings=payload.confirmed_savings, realized_savings=payload.realized_savings, currency=payload.currency, ) await _audit( db, request, current_user, action="intelligence.recommendation.update", resource_type="intelligence.recommendation", resource_id=str(row.id), detail={ "from_status": previous_status, "to_status": row.status, "ignored": payload.status == "ignored", "confirmed_savings": ( str(payload.confirmed_savings) if payload.confirmed_savings is not None else None ), "realized_savings": ( str(payload.realized_savings) if payload.realized_savings is not None else None ), "currency": row.currency, }, ) await db.commit() return success_response({"id": str(row.id), "status": row.status, "model_calls": 0}, request) @router.post("/recommendations/{recommendation_id}/send") async def intelligence_recommendation_send( recommendation_id: str, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), _active_employee: None = Depends(_leaver_runtime_gate), ): plan = await _access(db, current_user) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can send recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await db.get(IntelligenceRecommendation, parsed_id) if row is None or row.status == "ignored": raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) subjects = {(item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions} if (row.subject_type, row.subject_id, row.audience_key) not in subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) queued = await send_recommendation_notifications(db, row, actor_id=current_user.id) await _audit( db, request, current_user, action="intelligence.recommendation.send", resource_type="intelligence.recommendation", resource_id=str(row.id), detail={ # Who pushed model-written prose at every IT admin, and how many # cards that actually became after outbox de-duplication. "queued": queued, "severity": row.severity, "status": row.status, "channel_fanout": "admin_broadcast", }, ) await db.commit() return success_response({"id": str(row.id), "queued": queued, "model_calls": 0}, request) @router.post("/recommendations/{recommendation_id}/execute") # All non-notification actions fail closed into their existing workflow; # no AI output can become a generic write primitive. async def intelligence_recommendation_execute( recommendation_id: str, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), _active_employee: None = Depends(_leaver_runtime_gate), ): """Hand an AI action to an existing, audited Helix mutation boundary. AI recommendations never receive a generic write primitive. Notification is the only action currently executable directly; domain mutations must be routed through their existing API/approval flow. """ plan = await _access(db, current_user) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can execute recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await db.get(IntelligenceRecommendation, parsed_id) subjects = {(item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions} if row is None or row.status == "ignored" or (row.subject_type, row.subject_id, row.audience_key) not in subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) action_kind = str((row.action_contract or {}).get("action_kind") or "").strip().lower() if action_kind == "notify": queued = await send_recommendation_notifications(db, row, actor_id=current_user.id) await _audit( db, request, current_user, action="intelligence.recommendation.execute", resource_type="intelligence.recommendation", resource_id=str(row.id), detail={ "action_kind": action_kind, "queued": queued, "severity": row.severity, "status": row.status, "channel_fanout": "admin_broadcast", }, ) await db.commit() # ``status`` is part of this endpoint's contract for every other action # kind, and clients merge the response into their row. Omitting it here # wrote ``status: undefined`` over a valid status, which hid every # action button and left the recommendation unresolvable. A notify does # not advance the lifecycle, so the unchanged current status is # returned rather than a fabricated one. return success_response({ "id": str(row.id), "action_kind": action_kind, "queued": queued, "status": row.status, "model_calls": 0, }, request) contract = upgrade_recommendation_handoff(row.action_contract) # Persist only a local, deterministic upgrade. This lets historical # recommendations gain the explanatory handoff without exposing a second # execution path or trusting any stored redirect outside the allow-list. row.action_contract = contract previous_status = row.status transition_recommendation( row, status="in_progress", ignored_reason=None, actor_id=current_user.id, ) await _audit( db, request, current_user, action="intelligence.recommendation.execute", resource_type="intelligence.recommendation", resource_id=str(row.id), detail={ "action_kind": action_kind, "from_status": previous_status, "to_status": row.status, "workbench_path": contract.get("workbench_path"), "requires_approval": contract.get("requires_approval"), "handoff_only": True, }, ) await db.commit() return success_response({ "id": str(row.id), "action_kind": action_kind, "handoff": contract, "status": row.status, "model_calls": 0, }, request) @cloud_scope_router.get("/{scope_id}/ai-analysis") async def cloud_scope_ai_analysis( scope_id: str, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): return success_response( await get_cloud_scope_analysis_public( db, scope_id=scope_id, person_id=current_user.id, role_names=current_user.role_names, ), request, ) @cloud_scope_router.get("/{scope_id}/ai-analysis/history") async def cloud_scope_ai_analysis_history( scope_id: str, request: Request, limit: int = Query(default=50, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): return success_response( await get_cloud_scope_analysis_history_public( db, scope_id=scope_id, person_id=current_user.id, role_names=current_user.role_names, limit=limit, ), request, ) @cloud_scope_router.post("/{scope_id}/ai-analysis/generate") async def cloud_scope_ai_analysis_generate( scope_id: str, payload: CloudScopeAnalysisGenerateRequest, request: Request, db: AsyncSession = Depends(get_db), current_user: AuthUser = Depends(get_current_user), ): result = await generate_cloud_scope_analysis_public( db, scope_id=scope_id, person_id=current_user.id, role_names=current_user.role_names, force=payload.force, confirm_force=payload.confirm_force, ) # The one billed mutation an ORDINARY EMPLOYEE can reach (a subscription # owner or an active grantee may spend tokens on their own scope), and it # was the only one leaving no audit row over HTTP — the platform brief is # audited, and the MCP twin gets one from the tool runtime, so this was # also a surface asymmetry. Records who spent, on which scope, whether the # cache absorbed it, and what it cost. await _audit( db, request, current_user, action="intelligence.cloud_scope_analysis.generate", resource_type="cmdb.cloud_scope", resource_id=str(scope_id), detail={ "force": bool(payload.force), "status": result.get("status"), "cache_reused": result.get("cache_reused"), "model_calls": result.get("model_calls"), "input_token_count": result.get("input_token_count"), "output_token_count": result.get("output_token_count"), }, ) await db.commit() return success_response(result, request) __all__ = ["cloud_scope_router", "router"]