// src/search.ts // ============================================================================ // UNIFIED SEARCH LAYER // ============================================================================ // // Combines results from: // 1. TNA API (2003+) // 2. Local PostgreSQL (pre-2003 BAILII content) - optional, when database configured // // Results are merged using Reciprocal Rank Fusion (RRF). // ============================================================================ import { searchTna, COURT_CODE_MAP, LEGAL_AREA_COURTS } from './tna-client.js'; import type { SearchResult } from './types.js'; export interface SearchParams { query: string; legalArea?: string; court?: string; yearFrom?: number; yearTo?: number; limit?: number; page?: number; } export async function searchCaseLaw(params: SearchParams): Promise { const limit = params.limit || 10; // Determine which TNA courts to search let tnaCourts: string[] | undefined; if (params.court && params.court !== 'any') { tnaCourts = COURT_CODE_MAP[params.court]; } if (params.legalArea && params.legalArea !== 'any') { const areaCourts = LEGAL_AREA_COURTS[params.legalArea]; if (areaCourts) { // Intersect with court filter if both specified if (tnaCourts) { tnaCourts = tnaCourts.filter(c => areaCourts.includes(c)); } else { tnaCourts = areaCourts; } } } // For MVP, only use TNA API // TODO: Add local database search when PostgreSQL is configured const tnaResults = await searchTna({ query: params.query, courts: tnaCourts, yearFrom: params.yearFrom, yearTo: params.yearTo, limit: limit, page: params.page, }); return tnaResults.slice(0, limit); } // ============================================================================ // RECIPROCAL RANK FUSION (for future use with multiple sources) // ============================================================================ // // Combines ranked lists from different sources. // Each document gets a score based on its rank in each list: // score = sum(1 / (k + rank)) for each list // // k=60 is a standard constant that prevents top results from // dominating too heavily. // ============================================================================ export function reciprocalRankFusion( resultLists: SearchResult[][], limit: number, k: number = 60 ): SearchResult[] { const scores = new Map(); const docMap = new Map(); for (const results of resultLists) { for (let rank = 0; rank < results.length; rank++) { const result = results[rank]; if (!result) continue; const docId = result.documentUri || result.neutralCitation || result.title; const currentScore = scores.get(docId) || 0; scores.set(docId, currentScore + 1 / (k + rank + 1)); // Keep the first occurrence (usually has more complete metadata) if (!docMap.has(docId)) { docMap.set(docId, result); } } } // Sort by RRF score const sortedIds = [...scores.entries()] .sort((a, b) => b[1] - a[1]) .map(([id]) => id); return sortedIds .slice(0, limit) .map(id => docMap.get(id)) .filter((r): r is SearchResult => r !== undefined); }