"""Real AI assistance for a month-end close task — via 百炼 (our own LLM), with a deterministic fallback so it degrades gracefully when no LLM key is configured (mirrors :mod:`app.domains.finance_flux.ai_service`). The checklist's ``ai_work`` flag (``assist`` / ``perform``) is no longer a dead label: it drives a genuine model call grounded in the task's process, sub- process, description, applicable entities, deadline, and live close signal. * **assist** → a concise, actionable how-to: the key steps, the data/systems to pull, and the main risks/checks for that task. * **perform** → a **draft work product** for human review (e.g. the journal- entry structure or the concrete worksheet steps). It is explicitly advisory — the AI never posts entries or completes the task; a human reviews and acts. Read-only with respect to the close tables (the result is returned, not persisted; the user "adopts" it into the task comment if useful). """ from __future__ import annotations from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.safe_logging import get_safe_logger from app.domains.finance.close_models import FinanceCloseTask from app.integrations.models import PlatformSetting logger = get_safe_logger(__name__) # Reuse the platform LLM credential the rest of finance/AI uses. _KEY_API = "ai_spending.llm.api_key" _KEY_MODEL = "ai_spending.llm.model" _KEY_REGION = "ai_spending.llm.region" AI_MODES = ("assist", "perform") _SYSTEM_ASSIST = ( "You are a senior accountant helping a colleague complete one month-end " "book-close task. Give a concise, actionable how-to: the key steps in " "order, which systems/data to pull, and the main risks or checks. " "3-6 short numbered steps, grounded in the task given. No preamble, no " "markdown headings." ) _SYSTEM_PERFORM = ( "You are a senior accountant drafting the work for one month-end book-close " "task so a colleague can review and finalise it. Produce a concrete DRAFT " "work product — e.g. the journal-entry structure (accounts + Dr/Cr intent), " "or the worksheet/reconciliation steps with the figures to fill in. Make " "clear this is a draft for human review; never claim the task is done. " "Grounded in the task given, no preamble." ) 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.close_assist") def _task_context(task: FinanceCloseTask, signal: dict | None) -> str: lines = [ f"Period: {task.period}", f"Process: {task.process}" + (f" / {task.sub_process}" if task.sub_process else ""), f"Task: {task.description}", f"Frequency: {task.frequency}; deadline anchor: {task.calendar_day}" + (f"; deadline {task.deadline.isoformat()}" if task.deadline else ""), ] if task.entity_codes: lines.append(f"Applicable entities: {', '.join(task.entity_codes)}") if signal: lines.append( f"Live Helix data signal ({signal['key']}): {signal['status']} — {signal['detail']}" ) return "\n".join(lines) def deterministic_assist(task: FinanceCloseTask, mode: str, signal: dict | None) -> str: """Grounded fallback when no LLM key is configured (no dead-end).""" head = ( f"[Draft — review before use] {task.process} · {task.description}" if mode == "perform" else f"How to: {task.process} · {task.description}" ) steps = [ "Pull the source data/report for this task from the system named in the description.", f"Apply the {task.process} logic; reconcile against the supporting documents / GL.", "Record the entry / output, then tie the posted figures back to the source.", ] if signal and signal.get("status") not in (None, "ok"): steps.insert(0, f"First resolve the upstream signal: {signal['detail']}.") body = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(steps)) return f"{head}\n{body}\n(AI key not configured — generic checklist; configure ai_spending.llm.* for a tailored draft.)" class CloseTaskNotEligible(ValueError): """The task's ai_work flag is not assist/perform.""" async def generate_task_assist( db: AsyncSession, task_id: int, *, mode: str | None = None ) -> dict: """Generate AI assistance for one close task. Caller need not commit (read).""" task = await db.get(FinanceCloseTask, task_id) if task is None: from app.core.exceptions import NotFoundError raise NotFoundError("close task not found") effective_mode = (mode or task.ai_work or "").strip() if effective_mode not in AI_MODES: raise CloseTaskNotEligible( f"task ai_work={task.ai_work!r} is not AI-enabled (need assist/perform)" ) # Best-effort live signal context for a more grounded answer. signal = None if task.signal_key: try: from app.domains.finance import close_signals sigs = await close_signals.evaluate_signals(db, task.period) signal = sigs.get(task.signal_key) except Exception: # noqa: BLE001 — signal context is optional signal = None system = _SYSTEM_PERFORM if effective_mode == "perform" else _SYSTEM_ASSIST text = None source = "deterministic" client = await _llm_client(db) if client is not None: try: text = await client.chat_text( system=system, user=_task_context(task, signal), max_tokens=600, ) source = "llm" except Exception: # noqa: BLE001 — degrade to deterministic on any LLM error logger.warning("close AI assist: LLM call failed for task %s", task_id, exc_info=True) text = None if not text: text = deterministic_assist(task, effective_mode, signal) return {"task_id": task_id, "mode": effective_mode, "text": text, "source": source}