/* ============================================================ api.js — Server API calls (server.py reads keys from config.json) ============================================================ */ /** Landed property types (values as they appear in URA raw data) */ const LANDED_TYPES = new Set([ 'Terrace', 'Semi-detached', 'Detached', 'Strata Terrace', 'Strata Semi-detached', 'Strata Detached' ]); /** Non-landed property types (condominiums, apartments, ECs) */ const NONLANDED_TYPES = new Set([ 'Apartment', 'Condominium', 'Executive Condominium' ]); /** * Check whether server is configured (URA key present in config.json). * Returns { uraReady: bool, openaiReady: bool } */ async function fetchConfigStatus(){ const res = await fetch('/api/config/status'); if(!res.ok) throw new Error(`Server unreachable (HTTP ${res.status})`); return res.json(); } /** * Fetch all 4 URA district batches in parallel and merge raw Result arrays. * B1=D01-07 B2=D08-14 B3=D15-21 B4=D22-28 * @param {string} year Optional 4-digit year string (e.g. "2026"). * When supplied the server returns only transactions from that year, * dramatically reducing payload size and parse time. * Pass "" or omit for the full 5-year dataset. */ async function fetchAllBatches(year = ''){ const yearParam = (year && /^\d{4}$/.test(year)) ? `&year=${year}` : ''; const results = await Promise.all([1,2,3,4].map(async batch => { const res = await fetch(`/api/ura/transactions?batch=${batch}${yearParam}`); if(!res.ok){ const err = await res.json().catch(()=>({})); throw new Error(err.error || `Batch ${batch} failed (HTTP ${res.status})`); } const data = await res.json(); if(data.Status !== 'Success') throw new Error(`Batch ${batch}: ${data.Message || data.Msg || 'unknown'}`); if(data._cache){ const c = data._cache; console.info(`[Cache] Batch ${batch} ${c.hit?'HIT':'MISS'} — ${(c.records||0).toLocaleString()} records`); } return data.Result || []; })); return results.flat(); } /** * Flatten the nested URA structure and remove indistinguishable duplicates. * URA response: [ { project, street, x, y, transaction: [...] }, ... ] * Each transaction has: contractDate, typeOfSale, price, propertyType, * district, area, floorRange, noOfUnits, typeOfArea, tenure, nettPrice * * Duplicates: URA sometimes records the same physical sale as multiple * transaction entries (e.g. developer bulk submissions or data entry). * When all identifying fields match we keep only the first occurrence. */ function flattenUraTransactions(records){ const out = []; const seen = new Set(); for(const rec of (records || [])){ for(const tx of (rec.transaction || [])){ // Composite key — all fields a user could use to distinguish transactions const key = [ rec.project || '', rec.street || '', tx.propertyType || '', tx.district || '', tx.area || '', tx.floorRange || '', tx.price || '', tx.contractDate || '', tx.typeOfSale || '', tx.tenure || '', ].join('|'); if(seen.has(key)) continue; seen.add(key); out.push({ project: rec.project || '', street: rec.street || '', x: rec.x, y: rec.y, contractDate: tx.contractDate || '', typeOfSale: tx.typeOfSale || '', price: tx.price || '', propertyType: tx.propertyType || '', district: tx.district || '', area: tx.area || '', floorRange: tx.floorRange || '', noOfUnits: tx.noOfUnits || '', typeOfArea: tx.typeOfArea || '', tenure: tx.tenure || '', nettPrice: tx.nettPrice || '', }); } } return out; } /** Trigger a full cache refresh via the server */ async function triggerCacheDownload(batches=[1,2,3,4]){ const res = await fetch(`/api/ura/download?batches=${batches.join(',')}`, { method:'POST' }); if(!res.ok){ const err = await res.json().catch(()=>({})); throw new Error(err.error || `Download failed (HTTP ${res.status})`); } return res.json(); } /** Get cache status for all 4 batches */ async function fetchCacheStatus(){ const res = await fetch('/api/ura/cache-status'); if(!res.ok) throw new Error(`Cache status failed (HTTP ${res.status})`); return res.json(); } /* ── OneMap address lookup ─────────────────────────────────────────────────── */ async function fetchOneMapPage(street, page){ const url = `https://www.onemap.gov.sg/api/common/elastic/search` + `?searchVal=${encodeURIComponent(street)}&returnGeom=Y&getAddrDetails=Y&pageNum=${page}`; const r = await fetch(url); if(!r.ok) throw new Error(`Address lookup failed (HTTP ${r.status})`); return r.json(); } async function getAllAddresses(street){ const first = await fetchOneMapPage(street, 1); let results = first.results || []; const pages = parseInt(first.totalNumPages || 1); for(let p = 2; p <= Math.min(pages, 10); p++){ const d = await fetchOneMapPage(street, p); results = results.concat(d.results || []); } return results; } async function bruteForceHouseNumbers(street, onProgress){ const numbers = Array.from({length:200}, (_, i) => i + 1); const BATCH = 15; let found = []; for(let i = 0; i < numbers.length; i += BATCH){ const batch = numbers.slice(i, i + BATCH); onProgress(`Identifying properties on ${street}…`); const results = await Promise.all(batch.map(async n => { try { const r = await fetch( `https://www.onemap.gov.sg/api/common/elastic/search` + `?searchVal=${encodeURIComponent(n+' '+street)}&returnGeom=Y&getAddrDetails=Y&pageNum=1` ); if(!r.ok) return []; const d = await r.json(); return (d.results||[]).filter(c => { const addr = (c.ADDRESS||'').toUpperCase(); const postal = (c.POSTAL||''); return addr.includes(street) && postal && postal !== 'NIL' && /^\d{6}$/.test(postal); }); } catch(e){ return []; } })); results.flat().forEach(c => { if(!found.some(f => f.POSTAL === c.POSTAL)) found.push(c); }); } return found; } /* ── GPT-4o AI ranking ─────────────────────────────────────────────────────── */ async function gptRank(apiKey, candidates, tx, calibNote){ const areaSqm = sqftToSqm(tx.area); const areaLow = Math.round(tx.area * 0.88); const areaHigh = Math.round(tx.area * 1.12); const system = `You are a Singapore SLA/URA property data expert identifying which specific landed property unit corresponds to a URA transaction record. LAND AREA MATCHING RULES: - URA area: ${tx.area} sqft (≈${areaSqm} sqm), soft range ±12%: ${areaLow}–${areaHigh} sqft - SLA cadastral areas CAN legitimately differ from URA due to: (1) road/drain reserve deductions in older estates, (2) geocoding offsets - If ALL SLA areas are systematically offset from URA — apply calibration and rank by tenure/type/position - NEVER return empty candidates. Always return 3–5 ranked results. MATCHING FACTORS: 1. Land area (50% weight, soft) — SLA cadastral area vs URA transaction area 2. Tenure (20%) — tenure start year indicates estate section and era 3. Property type (20%) — semi-D pairs share one wall; terrace rows are sequential; bungalows are standalone 4. Street position (10%) — odd/even side, proximity to junctions, sequential numbering RULES: - Only eliminate candidates for confirmed TENURE conflict or clearly wrong property type - NEVER eliminate based on area alone - Confidence > 70 only when multiple factors strongly align - DO NOT use price or PSF as a matching factor OUTPUT FORMAT — valid JSON only, no markdown: { "analysis": "3-4 sentences: SLA area quality, tenure pattern, type configuration, confidence", "land_area_note": "explain SLA vs URA area relationship; if systematic offset, state likely cause", "eliminated": ["candidates ruled out by tenure conflict or wrong type only"], "candidates": [ { "address": "full address", "postal": "6-digit", "confidence": 0-100, "land_area_fit": "SLA vs URA comparison", "tenure_fit": "tenure match reasoning", "type_fit": "property type reasoning", "reason": "concise ranking rationale", "lat": "latitude", "lng": "longitude" } ] }`; const hasSla = candidates.some(c => c.sla_area_sqft); const slaInRange = hasSla ? candidates.filter(c => c.sla_area_sqft >= areaLow && c.sla_area_sqft <= areaHigh).length : 0; const calibLine = calibNote ? `\nCALIBRATION APPLIED: ${calibNote}\nUse calibrated sla_area_sqft for matching.\n` : ''; const slaNote = !hasSla ? `SLA cadastral data unavailable. Estimate lot sizes from street context.` : slaInRange > 0 ? `SLA data available and calibrated. ${slaInRange}/${candidates.length} candidates fall within ±12%.${calibLine}` : `SLA data available. No candidates within ±12% of ${tx.area} sqft after calibration — likely systematic offset.${calibLine}`; const userMsg = `URA Transaction: Street: ${tx.street} | Type: ${tx.type} | Tenure: ${tx.tenure} Area: ${tx.area} sqft (≈${areaSqm} sqm) [soft ±12%: ${areaLow}–${areaHigh}] Date: ${tx.date} | Price: SGD ${Number(tx.price).toLocaleString()} (context only) ${slaNote} Candidates on ${tx.street}: ${candidates.slice(0,60).map((c,i) => { const sla = c.sla_area_sqft ? `| SLA: ${Number(c.sla_area_sqft).toLocaleString()} sqft` : '| SLA: n/a'; return `${i+1}. ${c.ADDRESS} | Postal: ${c.POSTAL} | Lat: ${c.LATITUDE} | Lng: ${c.LONGITUDE} ${sla}`; }).join('\n')} Return top 3–5 candidates as JSON.`; const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: {'Content-Type':'application/json','Authorization':`Bearer ${apiKey}`}, body: JSON.stringify({ model: 'gpt-4o', temperature: 0.2, messages: [{role:'system',content:system},{role:'user',content:userMsg}] }) }); if(!res.ok){ const e = await res.json().catch(()=>({})); throw new Error(e?.error?.message || `AI service error (${res.status})`); } const data = await res.json(); const text = data.choices[0].message.content.trim() .replace(/^```json\s*/i,'').replace(/^```\s*/i,'').replace(/```\s*$/i,'').trim(); return JSON.parse(text); }