"""Bailian AI narratives for low-efficiency cloud instances. Turns the deterministic rule verdict into a short, human-readable explanation an owner can act on ("this box has sat at ~2% CPU for two weeks with almost no network — safe to stop, ~$120/mo back"). The rule verdict stands on its own; the narrative is a best-effort layer on top — if the LLM isn't configured or errors, the feature degrades silently and the deterministic verdict/recommendation still render everywhere. Cached on the ``CmdbServerEfficiency`` row (``ai_narrative`` + ``ai_narrative_at``); regenerated only when missing or older than ``NARRATIVE_TTL_DAYS``. Only actionable verdicts (idle / oversized) get a narrative, top-N by estimated savings, to bound LLM cost. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.safe_logging import get_safe_logger from app.domains.cmdb.models import ( EFFICIENCY_VERDICT_IDLE, EFFICIENCY_VERDICT_OVERSIZED, CmdbServer, CmdbServerEfficiency, ) from app.integrations.models import Integration from app.integrations.service import get_platform_setting logger = get_safe_logger(__name__) NARRATIVE_TTL_DAYS = 7 DEFAULT_NARRATE_LIMIT = 20 _ACTIONABLE = (EFFICIENCY_VERDICT_IDLE, EFFICIENCY_VERDICT_OVERSIZED) _BAILIAN_KEY = "ai_spending.llm.api_key" _BAILIAN_MODEL = "ai_spending.llm.model" _BAILIAN_REGION = "ai_spending.llm.region" _DEFAULT_MODEL = "qwen-max-latest" _SYSTEM = ( "你是 Helix 平台的云成本优化助手。根据给定的一台云服务器的利用率与成本事实," "用简洁、专业的中文向机器负责人解释这台机器为什么被判定为「闲置」或「超配」," "以及建议怎么做、预计能省多少。要求:2–3 句话,不超过 120 字;只依据给定事实," "不要编造未提供的数字;语气务实、给出可执行建议;不要使用 Markdown 标题或列表。" ) async def _resolve_llm(db: AsyncSession) -> tuple[str, str, str | None] | None: key = await get_platform_setting(_BAILIAN_KEY, None, session=db) key = key.strip() if isinstance(key, str) else "" if not key: return None model = await get_platform_setting(_BAILIAN_MODEL, _DEFAULT_MODEL, session=db) region = await get_platform_setting(_BAILIAN_REGION, None, session=db) return key, str(model or _DEFAULT_MODEL), region def _facts(eff: CmdbServerEfficiency, server: CmdbServer, provider: str | None = None) -> str: verdict_cn = "闲置" if eff.verdict == EFFICIENCY_VERDICT_IDLE else "超配" reco_cn = { "stop": "关停", "downsize": "降配", "keep": "保持", "investigate": "核查", }.get(eff.recommendation, eff.recommendation) lines = [ f"机器名称: {server.name or server.instance_id}", f"规格: {server.instance_type or '未知'} ({server.vcpu or '?'} vCPU / " f"{round((server.memory_mb or 0) / 1024) or '?'} GB)", f"云厂商: {provider or '未知'}", f"效率判定: {verdict_cn}", f"建议: {reco_cn}", f"评估窗口: {eff.window_days} 天(其中 {eff.data_days} 天有采样)", f"CPU P95: {round(eff.cpu_p95, 1) if eff.cpu_p95 is not None else '无'}%", f"CPU 均值: {round(eff.cpu_avg, 1) if eff.cpu_avg is not None else '无'}%", f"内存 P95: {round(eff.mem_p95, 1) if eff.mem_p95 is not None else '无(未装监控 agent)'}%", f"月成本(USD): {float(eff.monthly_cost_usd) if eff.monthly_cost_usd is not None else '未关联'}", f"预估月省(USD): {float(eff.estimated_monthly_savings_usd) if eff.estimated_monthly_savings_usd is not None else '未知'}", ] return "\n".join(lines) def _is_stale(ts: datetime | None) -> bool: if ts is None: return True if ts.tzinfo is None: ts = ts.replace(tzinfo=UTC) return datetime.now(UTC) - ts > timedelta(days=NARRATIVE_TTL_DAYS) async def generate_narratives( db: AsyncSession, integration_id: str, *, limit: int = DEFAULT_NARRATE_LIMIT, force: bool = False, ) -> dict[str, Any]: """Generate + cache AI narratives for the top-N actionable instances of one integration. Best-effort: returns a status dict, never raises on LLM failure (the deterministic verdict already stands).""" llm = await _resolve_llm(db) if llm is None: return {"status": "skipped", "reason": "llm_not_configured"} api_key, model, region = llm stmt = ( select(CmdbServerEfficiency, CmdbServer, Integration.provider) .join(CmdbServer, CmdbServer.id == CmdbServerEfficiency.server_id) .join( Integration, Integration.id == CmdbServerEfficiency.integration_id, isouter=True, ) .where( CmdbServerEfficiency.integration_id == integration_id, CmdbServerEfficiency.verdict.in_(_ACTIONABLE), CmdbServer.deleted_at.is_(None), ) .order_by(CmdbServerEfficiency.estimated_monthly_savings_usd.desc()) .limit(max(1, limit)) ) rows = (await db.execute(stmt)).all() if not rows: return {"status": "ok", "generated": 0, "candidates": 0} # Lazy import keeps cold-start light + isolates the optional dep. from app.domains.bailian.llm_client import BailianLLMClient client = BailianLLMClient(api_key, model=model, region=region, timeout=30.0, purpose="cloud.efficiency") generated = 0 failed = 0 now = datetime.now(UTC) for eff, server, provider in rows: if not force and eff.ai_narrative and not _is_stale(eff.ai_narrative_at): continue try: text = await client.chat_text( system=_SYSTEM, user=_facts(eff, server, provider), max_tokens=300, temperature=0.4, ) except Exception as exc: # noqa: BLE001 — AI is best-effort failed += 1 logger.warning( "cloud-efficiency narrative failed for server %s: %s", eff.server_id, str(exc)[:200], ) # A transient failure shouldn't poison the whole batch; keep going. continue text = (text or "").strip() if not text: continue eff.ai_narrative = text[:2000] eff.ai_narrative_at = now generated += 1 if generated: await db.commit() else: await db.rollback() return { "status": "ok", "candidates": len(rows), "generated": generated, "failed": failed, } async def narrate_one(db: AsyncSession, server_id: str) -> dict[str, Any]: """Force-(re)generate the AI narrative for a single server on demand (admin 'regenerate' button). Only actionable verdicts get a narrative.""" llm = await _resolve_llm(db) if llm is None: return {"status": "skipped", "reason": "llm_not_configured"} api_key, model, region = llm row = ( await db.execute( select(CmdbServerEfficiency, CmdbServer, Integration.provider) .join(CmdbServer, CmdbServer.id == CmdbServerEfficiency.server_id) .join( Integration, Integration.id == CmdbServerEfficiency.integration_id, isouter=True, ) .where(CmdbServerEfficiency.server_id == server_id) ) ).first() if row is None: return {"status": "skipped", "reason": "no_profile"} eff, server, provider = row if eff.verdict not in _ACTIONABLE: return {"status": "skipped", "reason": "verdict_not_actionable"} from app.domains.bailian.llm_client import BailianLLMClient client = BailianLLMClient(api_key, model=model, region=region, timeout=30.0, purpose="cloud.efficiency") try: text = await client.chat_text( system=_SYSTEM, user=_facts(eff, server, provider), max_tokens=300, temperature=0.4, ) except Exception as exc: # noqa: BLE001 logger.warning("cloud-efficiency narrate_one failed %s: %s", server_id, str(exc)[:200]) await db.rollback() return {"status": "error", "reason": "llm_error"} text = (text or "").strip() if not text: return {"status": "error", "reason": "empty"} eff.ai_narrative = text[:2000] eff.ai_narrative_at = datetime.now(UTC) await db.commit() return {"status": "ok", "narrative": text}