iris-ir-platform / backend /lightweight_server.py
rajvivan's picture
fix: make backend routes resilient on Hugging Face
c86876a verified
Raw
History Blame Contribute Delete
8.99 kB
"""
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"},
)