"""AI flux-narrative generation via 百炼 (our own LLM), with a deterministic fallback so the feature degrades gracefully when no LLM key is configured. This is a genuine upgrade over the legacy portal, whose "AI Analysis" was a hard-coded string template. Here the real model gets the line's computed variance + threshold context and writes a contextual explanation; analysts adopt it into the editable commentary with one click. When the platform LLM key (`ai_spending.llm.*`) is absent we still return a useful, numbers-grounded deterministic narrative (the legacy behaviour) rather than erroring. """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.finance_flux import constants as c from app.domains.finance_flux import flux_service as fx from app.domains.finance_flux.models import ( FinanceFluxCommentary, FinanceFluxConfig, FinanceFluxPeriod, ) from app.integrations.models import PlatformSetting _KEY_API = "ai_spending.llm.api_key" _KEY_MODEL = "ai_spending.llm.model" _KEY_REGION = "ai_spending.llm.region" _SYSTEM = ( "You are a senior FP&A analyst writing a concise month-end flux (variance) " "explanation for one financial-statement line. Be specific and grounded in " "the numbers given. 2-3 sentences, no preamble, no markdown. If the change " "looks like it needs follow-up, say what to check." ) class FluxLineNotFound(ValueError): """The requested line has no data in the period being analysed.""" async def _setting(db: AsyncSession, key: str) -> str | None: return (await db.execute( select(PlatformSetting.value).where(PlatformSetting.key == key) )).scalar_one_or_none() async def _llm_client(db: AsyncSession): api_key = (await _setting(db, _KEY_API) or "").strip() if not api_key: return None from app.domains.bailian.llm_client import BailianLLMClient return BailianLLMClient( api_key=api_key, model=await _setting(db, _KEY_MODEL), region=await _setting(db, _KEY_REGION), purpose="finance.flux_narrative") def _fmt(amount: Decimal, currency: str) -> str: sign = "-" if amount < 0 else "" return f"{sign}{currency} {abs(amount):,.0f}" def deterministic_narrative(line: dict, currency: str, pct_threshold: Decimal) -> str: """Numbers-grounded narrative used when no LLM key is configured.""" delta = Decimal(line["delta"]) pct = line["delta_pct"] if delta == 0: return ( f"{line['label']} was unchanged at " f"{_fmt(Decimal(line['current']), currency)} versus the prior period." ) direction = "increased" if delta > 0 else "decreased" parts = [ f"{line['label']} {direction} by {_fmt(abs(delta), currency)}" + (f" ({pct:+.1f}%)" if pct is not None else "") + f" to {_fmt(Decimal(line['current']), currency)}." ] if pct is not None and abs(pct) > float(pct_threshold): parts.append( f"The {abs(pct):.1f}% move exceeds the {pct_threshold:g}% review " "threshold — recommend confirming the driver against source " "documents / subsidiary detail.") return " ".join(parts) def _user_prompt(line: dict, currency: str, period_key: str, prior_key: str) -> str: return ( f"Statement line: {line['label']}\n" f"Period {period_key} vs {prior_key}\n" f"Current: {_fmt(Decimal(line['current']), currency)}\n" f"Prior: {_fmt(Decimal(line['prior']), currency)}\n" f"Change: {_fmt(Decimal(line['delta']), currency)}" + (f" ({line['delta_pct']:+.1f}%)" if line['delta_pct'] is not None else "") + f"\nFlag: {line['flag']}\n" "Write the flux explanation." ) async def generate_narrative( db: AsyncSession, *, period_key: str, statement: str, line_key: str, mode: str = c.COMPARE_MOM, ) -> dict: """Generate (and persist) the AI narrative for one line. Caller commits.""" st = await fx.compute_statement( db, period_key=period_key, statement=statement, mode=mode) line = next((ln for ln in st["lines"] if ln["line_key"] == line_key), None) if line is None: raise FluxLineNotFound(f"{statement}/{line_key} not in {period_key}") cfg = (await db.execute( select(FinanceFluxConfig).order_by(FinanceFluxConfig.created_at) )).scalars().first() currency = (cfg.currency if cfg else c.DEFAULT_CURRENCY) or c.DEFAULT_CURRENCY pct_thr = Decimal(cfg.pct_threshold) if cfg else Decimal(c.DEFAULT_PCT_THRESHOLD) ai_enabled = cfg.ai_enabled if cfg else True narrative = None source = "deterministic" if ai_enabled: client = await _llm_client(db) if client is not None: try: narrative = await client.chat_text( system=cfg.ai_prompt_template or _SYSTEM if cfg else _SYSTEM, user=_user_prompt(line, currency, period_key, st["prior_period_key"]), max_tokens=400, ) source = "llm" except Exception: # noqa: BLE001 — degrade to deterministic on any LLM error narrative = None if not narrative: narrative = deterministic_narrative(line, currency, pct_thr) await _store_narrative(db, period_key, statement, line_key, narrative) return {"line_key": line_key, "statement": statement, "narrative": narrative, "source": source} _SUMMARY_SYSTEM = ( "You are a senior FP&A analyst writing the executive flux (variance) " "commentary for a whole financial statement at month-end. Tell the story: " "the biggest drivers of the period-over-period change, what they imply, and " "which items warrant follow-up. 4-6 sentences, grounded in the numbers " "given, no preamble, no markdown, no bullet lists." ) def _summary_user_prompt( statement: str, period_key: str, prior_key: str, currency: str, movers: list[dict], totals: list[dict], ) -> str: out = [ f"Statement: {statement} Period {period_key} vs {prior_key} " f"Currency: {currency}", "", "Headline totals:", ] for t in totals: out.append( f"- {t['label']}: {_fmt(Decimal(t['current']), currency)} " f"(change {_fmt(Decimal(t['delta']), currency)}" + (f", {t['delta_pct']:+.1f}%" if t['delta_pct'] is not None else "") + ")") out += ["", "Largest line movers:"] for m in movers: out.append( f"- {m['label']}: {_fmt(Decimal(m['delta']), currency)}" + (f" ({m['delta_pct']:+.1f}%)" if m['delta_pct'] is not None else "") + (f" [flagged: {m['flag']}]" if m['flag'] != 'none' else "")) out += ["", "Write the executive flux summary."] return "\n".join(out) def deterministic_summary( movers: list[dict], totals: list[dict], currency: str, ) -> str: """Numbers-grounded statement summary used when no LLM key is configured.""" bits: list[str] = [] for t in totals[:3]: d = Decimal(t["delta"]) bits.append( f"{t['label']} {'rose' if d > 0 else 'fell' if d < 0 else 'held'} by " f"{_fmt(abs(d), currency)}" + (f" ({t['delta_pct']:+.1f}%)" if t['delta_pct'] is not None else "") + ".") if movers: m = movers[0] bits.append( f"The largest single line mover was {m['label']} " f"({_fmt(Decimal(m['delta']), currency)}).") flagged = [m for m in movers if m["flag"] != "none"] if flagged: bits.append( f"{len(flagged)} line(s) breached a review threshold and should be " "confirmed against source/subsidiary detail.") return " ".join(bits) or "No material movement this period." async def generate_statement_summary( db: AsyncSession, *, period_key: str, statement: str, mode: str = c.COMPARE_MOM, ) -> dict: """Executive AI summary of a whole statement's flux (ephemeral, read-only). Feeds the biggest movers + headline totals to 百炼; falls back to a deterministic numbers-grounded summary when no LLM key is configured. """ st = await fx.compute_statement( db, period_key=period_key, statement=statement, mode=mode) lines = st["lines"] cfg = (await db.execute( select(FinanceFluxConfig).order_by(FinanceFluxConfig.created_at) )).scalars().first() currency = (cfg.currency if cfg else c.DEFAULT_CURRENCY) or c.DEFAULT_CURRENCY ai_enabled = cfg.ai_enabled if cfg else True totals = [ln for ln in lines if ln["row_type"] != "line"] movers = sorted( (ln for ln in lines if ln["row_type"] == "line"), key=lambda x: abs(Decimal(x["delta"])), reverse=True)[:8] narrative = None source = "deterministic" if ai_enabled and lines: client = await _llm_client(db) if client is not None: try: narrative = await client.chat_text( system=_SUMMARY_SYSTEM, user=_summary_user_prompt( statement, period_key, st["prior_period_key"], currency, movers, totals[:6]), max_tokens=600, ) source = "llm" except Exception: # noqa: BLE001 — degrade to deterministic narrative = None if not narrative: narrative = deterministic_summary(movers, totals, currency) return { "statement": statement, "period_key": period_key, "prior_period_key": st["prior_period_key"], "narrative": narrative, "source": source, } async def _commentary_row( db: AsyncSession, period_key: str, statement: str, line_key: str, *, create: bool, ) -> FinanceFluxCommentary | None: period = (await db.execute( select(FinanceFluxPeriod).where(FinanceFluxPeriod.period_key == period_key) )).scalar_one_or_none() if period is None: return None row = (await db.execute( select(FinanceFluxCommentary).where( FinanceFluxCommentary.period_id == period.id, FinanceFluxCommentary.statement == statement, FinanceFluxCommentary.line_key == line_key, ) )).scalar_one_or_none() if row is None and create: row = FinanceFluxCommentary( id=uuid.uuid4(), period_id=period.id, statement=statement, line_key=line_key, ) db.add(row) return row async def _store_narrative( db: AsyncSession, period_key: str, statement: str, line_key: str, text: str, ) -> None: row = await _commentary_row(db, period_key, statement, line_key, create=True) if row is not None: row.ai_narrative = text row.ai_generated_at = datetime.now(UTC) await db.flush()