rajvivan's picture
fix: make backend routes resilient on Hugging Face
c86876a verified
Raw
History Blame Contribute Delete
11.5 kB
import { promises as fs } from 'fs';
import path from 'path';
import type { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ path: string[] }>;
};
type ChatRequest = {
question?: string;
doc_ids?: string[];
};
type JsonValue = Record<string, unknown> | Array<unknown>;
const BACKEND_INTERNAL_URL = process.env.BACKEND_INTERNAL_URL || 'http://127.0.0.1:8000';
const PRIMARY_DOC_ID = 'emiratesnbd_investor_presentation_2026_q1';
const DATA_DIR = path.join(process.cwd(), 'backend', 'data');
const CACHE_DIR = path.join(DATA_DIR, 'response_cache', PRIMARY_DOC_ID);
const DOCS_FILE = path.join(DATA_DIR, 'documents.json');
const PAGES_DIR = path.join(DATA_DIR, 'pages');
const UPSTREAM_TIMEOUT_MS = 1200;
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'content-encoding',
'content-length',
'host',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const INTENT_CACHE_FILES: Record<string, string> = {
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',
};
const INTENT_PATTERNS: Array<[string, RegExp[]]> = [
['esg', [
/\besg\b/i, /\benvironmental social governance\b/i, /\bsustainab/i,
/\bgreen\b/i, /\bclimate\b/i, /\bemissions?\b/i, /\bghg\b/i,
/\bsustainable finance\b/i, /\bsustainalytics\b/i, /\bmsci\b/i,
]],
['segment', [
/\bbusiness segment/i, /\bsegment performance\b/i, /\bsegmental performance\b/i,
/\brbwm\b/i, /\bcib\b/i, /\bgm&t\b/i, /\bglobal markets\b/i,
/\btreasury\b/i, /\bdenizbank\b/i, /\bretail banking\b/i,
/\bwealth management\b/i, /\bdivision/i,
]],
['non_funded_income', [
/\bnon[- ]?funded\b/i, /\bnfi\b/i, /\bfee/i, /\bcommission/i,
/\bclient flow/i, /\btrading income/i, /\bfx\b/i,
]],
['net_interest_margin', [
/\bnim\b/i, /\bnet interest margin\b/i, /\binterest margin\b/i,
]],
['capital', [
/\bcapital adequacy\b/i, /\bcet[- ]?1\b/i, /\bcar\b/i, /\brwa\b/i,
/\bbasel\b/i, /\bcapital ratio/i,
]],
['liquidity', [
/\bliquidity\b/i, /\bliquidity coverage ratio\b/i, /\blcr\b/i,
/\badr\b/i, /\bfunding\b/i, /\bdebt maturit/i,
]],
['credit_quality', [
/\bcost of risk\b/i, /\bcredit quality\b/i, /\bnpl\b/i,
/\bcoverage ratio\b/i, /\bimpairment/i, /\bprovision/i,
]],
['ecl_scenario', [
/\becl\b/i, /\bexpected credit loss\b/i, /\bscenario/i, /\bstage\s*[123]\b/i,
]],
['cost_efficiency', [
/\bcost[- ]?to[- ]?income\b/i, /\bcir\b/i, /\bcost efficiency\b/i,
/\boperating expense/i,
]],
['loans_sector', [
/\bloans? by sector\b/i, /\bsector mix\b/i, /\bsector concentration/i,
/\bloan portfolio.*sector\b/i,
]],
['loans', [
/\bloans?\b/i, /\badvances?\b/i, /\blending\b/i, /\bloan growth\b/i,
/\bloans? and deposits?\b/i, /\bloan.*deposit\b/i,
]],
['deposits', [
/\bdeposits?\b/i, /\bcasa\b/i, /\btime deposits?\b/i, /\bfunding base\b/i,
]],
['hyperinflation', [
/\bhyperinflation\b/i, /\bias\s*29\b/i, /\bturkiye cpi\b/i,
/\bmonetary correction\b/i,
]],
['macro', [
/\bmacro/i, /\beconomic environment\b/i, /\bgdp\b/i, /\binflation\b/i,
/\btourism\b/i, /\breal estate\b/i, /\bpopulation\b/i, /\bproject awards?\b/i,
]],
['profitability', [
/\bnet profit\b/i, /\btotal profit\b/i, /\bprofitability\b/i,
/\bprofit perform\b/i, /\bprofit growth\b/i, /\bprofit before tax\b/i,
/\bpbt\b/i, /\brote\b/i, /\bmain drivers?\b/i, /\bkey drivers?\b/i,
/\bperformance highlights?\b/i, /\bresults? highlights?\b/i,
/\boverall performance\b/i,
]],
['income', [
/\btotal income\b/i, /\bincome statement\b/i, /\boperating income\b/i,
/\bnet interest income\b/i, /\bnii\b/i, /\brevenue\b/i,
]],
];
function jsonResponse(body: JsonValue, init?: ResponseInit): Response {
return Response.json(body, {
...init,
headers: {
'Cache-Control': 'no-store',
...(init?.headers || {}),
},
});
}
async function readJsonFile<T extends JsonValue>(filePath: string): Promise<T | null> {
try {
const file = await fs.readFile(filePath, 'utf8');
return JSON.parse(file) as T;
} catch {
return null;
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function detectIntent(question: string): string | null {
for (const [intent, patterns] of INTENT_PATTERNS) {
if (patterns.some(pattern => pattern.test(question))) {
return intent;
}
}
return null;
}
function insufficientResponse(question: string, latencyMs: number): Record<string, unknown> {
return {
response_type: 'insufficient',
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: latencyMs,
model_used: 'next-cache-fallback',
};
}
async function fallbackHealth(): Promise<Response> {
return jsonResponse({
status: 'ok',
service: 'IRIS IR Intelligence',
institution: 'Emirates NBD',
mode: 'next-cache-fallback',
backend_live: true,
});
}
async function fallbackDocuments(): Promise<Response> {
const documents = await readJsonFile<Array<unknown>>(DOCS_FILE);
if (documents) {
return jsonResponse(documents);
}
return jsonResponse([{
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',
}]);
}
async function fallbackChat(request: Request): Promise<Response> {
const start = Date.now();
let payload: ChatRequest = {};
try {
payload = await request.json() as ChatRequest;
} catch {
return jsonResponse({ detail: 'Invalid JSON body' }, { status: 400 });
}
const question = payload.question?.trim() || '';
if (!question) {
return jsonResponse({ detail: 'Question is required' }, { status: 400 });
}
const intent = detectIntent(question);
if (intent) {
const cacheFile = INTENT_CACHE_FILES[intent];
const cached = await readJsonFile<Record<string, unknown>>(path.join(CACHE_DIR, cacheFile));
if (cached) {
return jsonResponse({
...cached,
question,
latency_ms: Math.max(1, Date.now() - start),
model_used: cached.model_used || 'next-cache-fallback',
});
}
}
return jsonResponse(insufficientResponse(question, Math.max(1, Date.now() - start)));
}
async function fallbackVisual(pathParts: string[]): Promise<Response> {
const docId = pathParts[2];
const pageNumber = Number.parseInt(pathParts[3] || '', 10);
if (!docId || Number.isNaN(pageNumber)) {
return jsonResponse({ detail: 'Invalid visual path' }, { status: 400 });
}
const pagesDir = path.join(PAGES_DIR, docId, 'pages');
const pageStem = `page_${String(pageNumber).padStart(4, '0')}`;
const candidates = [
path.join(pagesDir, `${pageStem}.png`),
path.join(pagesDir, `${pageStem}_colpali.png`),
path.join(pagesDir, `${pageStem}_colpali_index.png`),
];
const imagePath = await candidates.reduce<Promise<string | null>>(async (previous, candidate) => {
const found = await previous;
if (found) return found;
return (await fileExists(candidate)) ? candidate : null;
}, Promise.resolve(null));
if (!imagePath) {
return jsonResponse({ detail: `Page image not found: ${docId}/${pageNumber}` }, { status: 404 });
}
const image = await fs.readFile(imagePath);
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
}
async function fallbackLegacyPage(pathParts: string[]): Promise<Response> {
const docId = pathParts[1];
const filename = pathParts.length >= 4 && pathParts[2] === 'pages'
? path.basename(pathParts[3])
: null;
if (!docId || !filename || !filename.endsWith('.png')) {
return jsonResponse({ detail: 'Invalid page image path' }, { status: 400 });
}
const imagePath = path.join(PAGES_DIR, docId, 'pages', filename);
if (!(await fileExists(imagePath))) {
return jsonResponse({ detail: `Page image not found: ${docId}/${filename}` }, { status: 404 });
}
const image = await fs.readFile(imagePath);
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600',
},
});
}
async function fallbackRequest(request: Request, pathParts: string[]): Promise<Response> {
const route = `/${pathParts.join('/')}`.replace(/\/$/, '');
if (request.method === 'GET' && route === '/api/health') {
return fallbackHealth();
}
if (request.method === 'GET' && route === '/api/documents') {
return fallbackDocuments();
}
if (request.method === 'POST' && route === '/api/chat/query') {
return fallbackChat(request);
}
if (request.method === 'GET' && pathParts[0] === 'api' && pathParts[1] === 'visuals') {
return fallbackVisual(pathParts);
}
if (request.method === 'GET' && pathParts[0] === 'pages') {
return fallbackLegacyPage(pathParts);
}
return jsonResponse({
detail: 'Backend route unavailable',
route,
}, { status: 503 });
}
async function proxyRequest(request: NextRequest, context: RouteContext): Promise<Response> {
const { path: pathParts } = await context.params;
const upstream = new URL(`/${pathParts.join('/')}`, BACKEND_INTERNAL_URL);
upstream.search = request.nextUrl.search;
const fallbackRequestClone = request.clone();
const headers = new Headers(request.headers);
for (const header of HOP_BY_HOP_HEADERS) {
headers.delete(header);
}
try {
const response = await fetch(upstream, {
method: request.method,
headers,
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body,
cache: 'no-store',
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
duplex: 'half',
} as RequestInit & { duplex: 'half' });
if (response.ok) {
const responseHeaders = new Headers(response.headers);
for (const header of HOP_BY_HOP_HEADERS) {
responseHeaders.delete(header);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
} catch {
// Fall through to the cache-backed route below.
}
return fallbackRequest(fallbackRequestClone, pathParts);
}
export const GET = proxyRequest;
export const POST = proxyRequest;
export const PUT = proxyRequest;
export const PATCH = proxyRequest;
export const DELETE = proxyRequest;
export const HEAD = proxyRequest;
export const OPTIONS = proxyRequest;