from __future__ import annotations import asyncio import json import os import time from dataclasses import dataclass from typing import Any from agent_framework.cache.cache import Cache from .client import TimApiClient @dataclass(slots=True) class InvoiceContext: msisdn: str invoice_id: str = "" complete_invoices: dict[str, Any] | None = None billing_analysis: dict[str, Any] | None = None invoice_detail: Any = None customer_id: str = "" error: str | None = None cache_hit: bool = False business_events: list[dict[str, Any]] | None = None errors: dict[str, str] | None = None metadata: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: return { "msisdn": self.msisdn, "invoice_id": self.invoice_id, "complete_invoices_payload": self.complete_invoices, "billing_analysis": self.billing_analysis, "invoice_detail": self.invoice_detail, "customer_id": self.customer_id, "invoice_context_error": self.error, "invoice_context_cache_hit": self.cache_hit, "invoice_context_business_events": list(self.business_events or []), "invoice_context_errors": dict(self.errors or {}), "invoice_context_metadata": dict(self.metadata or {}), } class InvoiceContextService: """Session-scoped invoice prefetch backed by the framework cache. This replaces the legacy InvoiceContextProvider without owning session, thread, LangGraph or LLM infrastructure. The cache implementation and TTL belong to ``agent_framework``; this class only knows which TIM evidence is useful to the Contas domain. """ def __init__(self, client: TimApiClient, cache: Cache, *, ttl_seconds: int | None = None) -> None: self.client = client self.cache = cache self.ttl_seconds = ttl_seconds or int(os.getenv("TIM_INVOICE_CONTEXT_TTL_SECONDS", "1800")) self._inflight: dict[str, asyncio.Task[InvoiceContext]] = {} self._inflight_lock = asyncio.Lock() @staticmethod def _key(*, session_id: str, msisdn: str, invoice_id: str) -> str | None: # No global cache without a session: prevents cross-customer leakage. sid = str(session_id or "").strip() if not sid: return None return f"contas:invoice-context:{sid}:{msisdn}:{invoice_id or 'latest'}" @staticmethod def _extract_identity(complete: Any, requested_invoice_id: str = "") -> tuple[str, str]: if not isinstance(complete, dict): return "", requested_invoice_id billing = complete.get("billingProfile") or complete.get("billing_profile") or {} customer = billing.get("customer") if isinstance(billing, dict) else {} customer_id = str((customer or {}).get("customerId") or (customer or {}).get("id") or "") if isinstance(customer, dict) else "" items = complete.get("paymentItems") or complete.get("payment_items") or [] invoice_id = requested_invoice_id if not invoice_id and isinstance(items, list): first = next((x for x in items if isinstance(x, dict)), {}) invoice_id = str(first.get("invoiceId") or first.get("invoiceNumber") or "") if isinstance(first, dict) else "" return customer_id, invoice_id @staticmethod def _event(code: str, *, msisdn: str, invoice_id: str, session_id: str, message_id: str = "", api_status_code: int = 0, error: str = "", channel_id: str = "URA", ura_call_id: str = "") -> dict[str, Any]: payload = { "tag": code, "agentId": os.getenv("TIM_AGENT_ID", "contas"), "gsm": msisdn, "sessionId": session_id, "messageId": message_id, "channelId": channel_id or "URA", "uraCallId": ura_call_id or "", "agentSpecificData": json.dumps({"billingId": invoice_id}, ensure_ascii=False) if invoice_id else "", "billingId": invoice_id or "", "apiStatusCode": int(api_status_code or 0), } if error: payload["error"] = error return {"code": code, "payload": payload, "component": "invoice_context_prefetch"} async def _result_event_once(self, code: str, *, session_id: str, msisdn: str, invoice_id: str, message_id: str, api_status_code: int = 0, error: str = "") -> list[dict[str, Any]]: if not session_id: return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)] marker = f"contas:invoice-context:event-result:{code}:{session_id}:{msisdn}:{invoice_id or 'latest'}" if await self.cache.get(marker): return [] await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds) return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)] async def _started_event_once(self, *, session_id: str, msisdn: str, invoice_id: str, message_id: str) -> list[dict[str, Any]]: if not session_id: return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)] marker = f"contas:invoice-context:event-start:{session_id}:{msisdn}:{invoice_id or 'latest'}" if await self.cache.get(marker): return [] await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds) return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)] @staticmethod async def _timed_to_thread(name: str, fn: Any, *args: Any, **kwargs: Any) -> tuple[Any, str, float]: started = time.perf_counter() try: value = await asyncio.to_thread(fn, *args, **kwargs) return value, "", round((time.perf_counter() - started) * 1000, 3) except Exception as exc: return None, f"{type(exc).__name__}: {exc}", round((time.perf_counter() - started) * 1000, 3) async def _fetch( self, *, session_id: str, msisdn: str, invoice_id: str, include_detail: bool, message_id: str ) -> InvoiceContext: events = await self._started_event_once( session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, message_id=message_id ) fetch_started = time.perf_counter() complete_result, billing_result = await asyncio.gather( self._timed_to_thread("complete_invoices", self.client.consultar_faturas, msisdn), self._timed_to_thread("billing_analysis", self.client.billing_analysis, msisdn, invoice_id=invoice_id), ) complete, complete_error, complete_ms = complete_result billing, billing_error, billing_ms = billing_result errors: dict[str, str] = {} if complete_error: errors["complete_invoices"] = complete_error if billing_error: errors["billing_analysis"] = billing_error error_parts = [f"{k}: {v}" for k, v in errors.items()] billing_status = 0 customer_id, resolved_invoice_id = self._extract_identity(complete, invoice_id) event_invoice_id = resolved_invoice_id or invoice_id if billing is None: events.extend(await self._result_event_once("CVN.007", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id, api_status_code=billing_status, error=error_parts[-1] if error_parts else "billing_analysis failed")) else: events.extend(await self._result_event_once("CVN.006", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id)) detail = None detail_ms = 0.0 if include_detail and resolved_invoice_id: detail, detail_error, detail_ms = await self._timed_to_thread( "invoice_detail", self.client.bill_pdf, msisdn, resolved_invoice_id, customer_id, include_danfe=True, output="json" ) if detail_error: errors["invoice_detail"] = detail_error error_parts.append(f"invoice_detail: {detail_error}") metadata = { "cache_hit": False, "fetch_elapsed_ms": round((time.perf_counter() - fetch_started) * 1000, 3), "task_timings": { "complete_invoices": complete_ms, "billing_analysis": billing_ms, **({"invoice_detail": detail_ms} if include_detail else {}), }, } return InvoiceContext( msisdn=msisdn, invoice_id=resolved_invoice_id, complete_invoices=complete if isinstance(complete, dict) else None, billing_analysis=billing if isinstance(billing, dict) else None, invoice_detail=detail, customer_id=customer_id, error="; ".join(error_parts) or None, cache_hit=False, business_events=events, errors=errors, metadata=metadata, ) async def get( self, *, session_id: str, msisdn: str, invoice_id: str = "", use_cache: bool = True, include_detail: bool = False, message_id: str = "", ) -> InvoiceContext: key = self._key(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id) if use_cache and key: cached = await self.cache.get(key) if isinstance(cached, dict) and (not include_detail or cached.get("invoice_detail") is not None): cached = dict(cached) cached["cache_hit"] = True cached["business_events"] = [] # cache hit must not duplicate business effects metadata = dict(cached.get("metadata") or {}) metadata["cache_hit"] = True fetched_at = float(cached.get("_fetched_at") or time.time()) metadata["cache_age_ms"] = max(0.0, round((time.time() - fetched_at) * 1000, 3)) cached["metadata"] = metadata return InvoiceContext(**{k: cached.get(k) for k in InvoiceContext.__dataclass_fields__}) task_key = (f"{key}:detail={int(include_detail)}" if key else f"nocache:{session_id}:{msisdn}:{invoice_id}:{include_detail}") if use_cache and key: async with self._inflight_lock: task = self._inflight.get(task_key) if task is None: task = asyncio.create_task(self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id)) self._inflight[task_key] = task try: ctx = await task finally: async with self._inflight_lock: if self._inflight.get(task_key) is task and task.done(): self._inflight.pop(task_key, None) else: ctx = await self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id) if key: await self.cache.set(key, { "msisdn": ctx.msisdn, "invoice_id": ctx.invoice_id, "complete_invoices": ctx.complete_invoices, "billing_analysis": ctx.billing_analysis, "invoice_detail": ctx.invoice_detail, "customer_id": ctx.customer_id, "error": ctx.error, "cache_hit": False, "errors": dict(ctx.errors or {}), "metadata": dict(ctx.metadata or {}), "_fetched_at": time.time(), }, ttl_seconds=self.ttl_seconds) return ctx