""" Lightweight Hugging Face runtime for IRIS. This server exposes the same public API paths the frontend uses, but answers from precomputed cache files so the Space can run without rebuilding vector indexes on every cold start. """ from __future__ import annotations import copy import json import re import time from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" PRIMARY_DOC_ID = "emiratesnbd_investor_presentation_2026_q1" CACHE_DIR = DATA_DIR / "response_cache" / PRIMARY_DOC_ID DOCS_FILE = DATA_DIR / "documents.json" PAGES_DIR = DATA_DIR / "pages" INTENT_CACHE_FILES = { "capital": "capital.json", "cost_efficiency": "cost-efficiency.json", "credit_quality": "credit-quality.json", "deposits": "deposits.json", "ecl_scenario": "ecl-scenario.json", "esg": "esg.json", "hyperinflation": "hyperinflation.json", "income": "income.json", "liquidity": "liquidity.json", "loans_sector": "loans-sector.json", "loans": "loans.json", "macro": "macro.json", "net_interest_margin": "net-interest-margin.json", "non_funded_income": "non-funded-income.json", "profitability": "profitability.json", "segment": "segment.json", } INTENT_PATTERNS = [ ("esg", [ r"\besg\b", r"\benvironmental social governance\b", r"\bsustainab", r"\bgreen\b", r"\bclimate\b", r"\bemissions?\b", r"\bghg\b", r"\bscope\s*[12]\b", r"\btransition finance\b", r"\bdiversity\b", r"\bgovernance\b", r"\bsustainable finance\b", r"\bsustainalytics\b", r"\bmsci\b", r"\bs&p\b", r"\bfemale leadership\b", r"\bnet[- ]?zero\b", r"\bdecarboni", r"\bcarbon reduction\b", r"\bgreen bond\b", r"\bsustainable issuance\b", r"\buse of proceeds\b", r"\bicma\b", r"\bsecond[- ]party opinions?\b", ]), ("segment", [ r"\bbusiness segment", r"\bsegment performance\b", r"\bdivisional\b", r"\bsegmental performance\b", r"\bdivision\b", r"\brbwm\b", r"\bcib\b", r"\bgm&t\b", r"\bglobal markets\b", r"\btreasury\b", r"\bdenizbank\b", r"\bretail banking\b", r"\bwealth management\b", r"\bsbu\b", r"\bstrategic business unit\b", r"\bbusiness unit\b", r"\bsegment breakdown\b", r"\bdivisional financial contribution", r"\bsegment.*operating income\b", r"\bsegment.*\bpbt\b", r"\bcontributed most.*\bpbt\b", r"\bcontributed most.*operating income\b", ]), ("non_funded_income", [ r"\bnon[- ]?funded\b", r"\bnfi\b", r"\bfee", r"\bcommission", r"\bclient flow", r"\btrading income", r"\bfx\b", r"\bderivative", ]), ("net_interest_margin", [ r"\bnim\b", r"\bnet interest margin\b", r"\binterest margin\b", r"\bmargins remain\b", ]), ("capital", [ r"\bcapital adequacy\b", r"\bcet[- ]?1\b", r"\bcar\b", r"\brwa\b", r"\bbasel\b", r"\bcapital ratio", ]), ("liquidity", [ r"\bliquidity\b", r"\bliquidity coverage ratio\b", r"\blcr\b", r"\badr\b", r"\bfunding\b", r"\bdebt maturit", r"\bwhat is the lcr\b", r"\blcr performance\b", ]), ("credit_quality", [ r"\bcost of risk\b", r"\bcredit quality\b", r"\bnpl\b", r"\bcoverage ratio\b", r"\bimpairment", r"\bprovision", ]), ("ecl_scenario", [ r"\becl\b", r"\bexpected credit loss\b", r"\bscenario", r"\bstage\s*[123]\b", ]), ("cost_efficiency", [ r"\bcost[- ]?to[- ]?income\b", r"\bcir\b", r"\bcost efficiency\b", r"\boperating expense", ]), ("loans_sector", [ r"\bloans? by sector\b", r"\bsector mix\b", r"\bsector concentration", r"\bgross loan.*sector", r"\bloans? by sector distribution\b", r"\bsector.*distribution\b", r"\bloan portfolio.*sector\b", ]), ("loans", [ r"\bloans?\b", r"\badvances?\b", r"\blending\b", r"\bloan growth\b", r"\bloan growth.*deposit growth\b", r"\bloans? and deposits?\b", r"\bloan.*deposit\b", ]), ("deposits", [ r"\bdeposits?\b", r"\bcasa\b", r"\btime deposits?\b", r"\bfunding base\b", ]), ("hyperinflation", [ r"\bhyperinflation\b", r"\bias\s*29\b", r"\bturkiye cpi\b", r"\bmonetary correction\b", ]), ("macro", [ r"\bmacro", r"\beconomic environment\b", r"\bgdp\b", r"\binflation\b", r"\btourism\b", r"\breal estate\b", r"\bpopulation\b", r"\bproject awards?\b", ]), ("profitability", [ r"\bnet profit\b", r"\btotal profit\b", r"\bprofitability\b", r"\bprofit perform\b", r"\bprofit growth\b", r"\bprofit before tax\b", r"\bpbt\b", r"\brote\b", r"\bmain drivers?\b", r"\bkey drivers?\b", r"\bperformance highlights?\b", r"\bresults? highlights?\b", r"\bq1\s*2026\s+performance\b", r"\bq1\s*2026\s+results?\b", r"\boverall performance\b", ]), ("income", [ r"\btotal income\b", r"\bincome statement\b", r"\boperating income\b", r"\bnet interest income\b", r"\bnii\b", r"\brevenue\b", ]), ] class ChatRequest(BaseModel): question: str doc_ids: list[str] = [PRIMARY_DOC_ID] app = FastAPI(title="IRIS IR Intelligence API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) def _load_json(path: Path) -> Any: with path.open("r", encoding="utf-8") as handle: return json.load(handle) def _detect_cached_intent(question: str) -> str | None: q_lower = question.lower() for intent, patterns in INTENT_PATTERNS: if any(re.search(pattern, q_lower) for pattern in patterns): return intent return None def _load_cached_response(intent: str) -> dict[str, Any] | None: filename = INTENT_CACHE_FILES.get(intent) if not filename: return None path = CACHE_DIR / filename if not path.exists(): return None return _load_json(path) def _insufficient_response(question: str, latency_ms: int) -> dict[str, Any]: return { "response_type": "insufficient", "question": question, "executive_summary": ( "I do not have enough evidence in the indexed Emirates NBD Q1 2026 " "presentation cache to answer this question." ), "sources": [], "financial_kpis": [], "key_drivers": [], "visual_evidence": [], "latency_ms": latency_ms, "model_used": "cache-retrieval", } def _page_image_candidates(doc_id: str, page_number: int) -> list[Path]: pages_dir = PAGES_DIR / doc_id / "pages" page_stem = f"page_{page_number:04d}" return [ pages_dir / f"{page_stem}.png", pages_dir / f"{page_stem}_colpali.png", pages_dir / f"{page_stem}_colpali_index.png", ] @app.get("/api/health") async def health() -> dict[str, str]: return { "status": "ok", "service": "IRIS IR Intelligence", "institution": "Emirates NBD", "mode": "hf-cache", } @app.post("/api/chat/query") async def chat_query(request: ChatRequest) -> dict[str, Any]: start = time.time() intent = _detect_cached_intent(request.question) if intent: cached = _load_cached_response(intent) if cached: response = copy.deepcopy(cached) response["question"] = request.question response["latency_ms"] = max(1, int((time.time() - start) * 1000)) response["model_used"] = response.get("model_used") or "cache-retrieval" return response return _insufficient_response(request.question, max(1, int((time.time() - start) * 1000))) @app.get("/api/documents") @app.get("/api/documents/") async def list_documents() -> list[dict[str, Any]]: if DOCS_FILE.exists(): return _load_json(DOCS_FILE) return [ { "doc_id": PRIMARY_DOC_ID, "name": "Emiratesnbd Investor Presentation 2026 Q1", "doc_type": "Investor Presentation", "period": "2026", "institution": "Emirates NBD", "total_pages": 36, "status": "indexed", "filename": "emiratesnbd_investor_presentation_2026_q1.pdf", } ] @app.get("/api/visuals/{doc_id}/{page_number}") async def get_page_image(doc_id: str, page_number: int) -> FileResponse: img_path = next( (candidate for candidate in _page_image_candidates(doc_id, page_number) if candidate.exists()), None, ) if img_path is None: raise HTTPException(status_code=404, detail=f"Page image not found: {doc_id}/{page_number}") return FileResponse( str(img_path), media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}, )