From 0cb59676ad96cd5cdb69704aeb1c5ef814b87d3c Mon Sep 17 00:00:00 2001 From: Atti Ur Rehman Date: Thu, 2 Jul 2026 21:20:57 +0300 Subject: [PATCH 1/2] fix(ui): show pending until human decides; send API key on writes --- .example.env | 4 ++++ README.md | 4 ++-- api/auth.py | 4 +++- api/claims.py | 3 ++- web/src/ClaimDetail.tsx | 45 +++++++++++++++++++++++++---------------- web/src/ClaimList.tsx | 11 ++++++++-- web/src/api.ts | 11 +++++++++- web/src/theme.ts | 9 ++++++++- 8 files changed, 66 insertions(+), 25 deletions(-) diff --git a/.example.env b/.example.env index 897f13a..d38c29f 100644 --- a/.example.env +++ b/.example.env @@ -9,3 +9,7 @@ LANGFUSE_SECRET_KEY= LANGFUSE_HOST=https://cloud.langfuse.com EVINCTA_API_KEY= # required for write routes in production ALLOWED_ORIGINS=http://localhost:5173 + +# web/ — set in Vercel (same value as EVINCTA_API_KEY for approve/override) +# VITE_API_URL=http://localhost:8000 +# VITE_EVINCTA_API_KEY= diff --git a/README.md b/README.md index 7483b85..8d86ae7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ An evidence-backed recommendation — with its reasoning, precedents, and audit
-[![Live demo](https://img.shields.io/badge/▶_Live_demo-Enter_as_demo_adjuster-7c3aed?style=for-the-badge)](https://YOUR-APP.vercel.app) +[![Live demo](https://img.shields.io/badge/▶_Live_demo-Enter_as_demo_adjuster-7c3aed?style=for-the-badge)]([Evincta](https://evincta.vercel.app))   [![Eval gate](https://img.shields.io/badge/CI-eval_gate_passing-16a34a?style=for-the-badge)](.github/workflows/eval.yml) @@ -31,7 +31,7 @@ An evidence-backed recommendation — with its reasoning, precedents, and audit --- > [!NOTE] -> **Live demo:** [YOUR-APP.vercel.app](https://YOUR-APP.vercel.app) → click **“Enter as demo adjuster.”** +> **Live demo:** [https://evincta.vercel.app](https://evincta.vercel.app) → click **“Enter as demo adjuster.”** > The API runs on a free-tier container that sleeps when idle, so the **first load may take ~30s to wake** — after that it's fast. Browsing and deciding claims costs nothing (recommendations are pre-computed and cached).
diff --git a/api/auth.py b/api/auth.py index 1b9973f..614b862 100644 --- a/api/auth.py +++ b/api/auth.py @@ -5,5 +5,7 @@ def require_key(x_api_key: str = Header(None)): - if not API_KEY or x_api_key != API_KEY: + if not API_KEY: + return # local dev — no key configured + if x_api_key != API_KEY: raise HTTPException(401, "invalid or missing API key") diff --git a/api/claims.py b/api/claims.py index 749e5d0..ed5564c 100644 --- a/api/claims.py +++ b/api/claims.py @@ -44,7 +44,8 @@ def _run(): def list_claims(): # compact rows for the queue view return [{"claim_id": c["claim_id"], "status": c["status"], - "decision": c["recommendation"]["decision"], + "recommendation": c["recommendation"]["decision"], + "human_decision": c.get("human_decision"), "fraud_risk": c["recommendation"]["fraud_risk"]} for c in store.all_claims()] diff --git a/web/src/ClaimDetail.tsx b/web/src/ClaimDetail.tsx index 7d200a2..23f88a7 100644 --- a/web/src/ClaimDetail.tsx +++ b/web/src/ClaimDetail.tsx @@ -28,6 +28,7 @@ export default function ClaimDetail({ }) { const [c, setC] = useState(null); const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); useEffect(() => { setC(null); @@ -43,10 +44,13 @@ export default function ClaimDetail({ async function act(decision: string, override_to?: string) { setBusy(true); + setErr(null); try { await api.decide(id, { decision, approver: "atti@evincta", override_to }); await api.get(id).then(setC); onDecided(); + } catch (e) { + setErr(e instanceof Error ? e.message : "Request failed"); } finally { setBusy(false); } @@ -62,7 +66,7 @@ export default function ClaimDetail({

{c.status}

- + {/* recommendation */} @@ -84,7 +88,7 @@ export default function ClaimDetail({
-
Decision
+
Recommended
@@ -185,21 +189,28 @@ export default function ClaimDetail({ )}
) : ( -
- - +
+ {err && ( +

+ {err} +

+ )} +
+ + +
)}
diff --git a/web/src/ClaimList.tsx b/web/src/ClaimList.tsx index 3b5d4e4..f5c19b3 100644 --- a/web/src/ClaimList.tsx +++ b/web/src/ClaimList.tsx @@ -5,7 +5,8 @@ import Badge from "./Badge"; type Row = { claim_id: string; status: string; - decision: string; + recommendation: string; + human_decision?: string; fraud_risk: string; }; @@ -60,7 +61,13 @@ export default function ClaimList({ {c.claim_id} - +
{c.status} diff --git a/web/src/api.ts b/web/src/api.ts index 14578ae..356e7ed 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,8 +1,17 @@ const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +const API_KEY = import.meta.env.VITE_EVINCTA_API_KEY as string | undefined; + +function headers(method?: string): HeadersInit { + const h: Record = { "Content-Type": "application/json" }; + if (method === "POST" && API_KEY) h["X-API-Key"] = API_KEY; + return h; +} async function j(path: string, opts?: RequestInit) { const r = await fetch(BASE + path, { - headers: { "Content-Type": "application/json" }, ...opts }); + ...opts, + headers: { ...headers(opts?.method), ...opts?.headers }, + }); if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); return r.json(); } diff --git a/web/src/theme.ts b/web/src/theme.ts index 30748fd..8d89b31 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -2,7 +2,7 @@ // cards, a single violet accent (#7c3aed = violet-600). Color is used only to // encode meaning: the three decision states. Never for decoration. -export type Decision = "approve" | "investigate" | "deny"; +export type Decision = "approve" | "investigate" | "deny" | "pending"; type DecisionToken = { label: string; @@ -34,6 +34,13 @@ export const decision: Record = { border: "border-rose-200", dot: "bg-rose-500", }, + pending: { + label: "Pending", + bg: "bg-zinc-50", + text: "text-zinc-600", + border: "border-zinc-200", + dot: "bg-zinc-400", + }, }; const neutralToken: DecisionToken = { From d82fce032042ce928053897050a59164b780c0db Mon Sep 17 00:00:00 2001 From: Atti Ur Rehman Date: Thu, 2 Jul 2026 21:32:59 +0300 Subject: [PATCH 2/2] feat(ui): show wake spinner while Render API cold-starts --- Dockerfile | 3 +- api/claims.py | 10 +++- data/demo_claims_index.json | 114 ++++++++++++++++++++++++++++++++++++ web/src/WakeApi.tsx | 76 ++++++++++++++++++++++++ web/src/api.ts | 7 +++ web/src/main.tsx | 18 +++++- 6 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 data/demo_claims_index.json create mode 100644 web/src/WakeApi.tsx diff --git a/Dockerfile b/Dockerfile index f7ab599..e4f65a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY services/ services/ COPY api/ api/ -# Demo claims (committed). Mount or bake data/generated/ for the full seeded set (issue #47). +# Demo claims (committed). Pre-baked index survives Render redeploys. +COPY data/demo_claims_index.json data/claims_index.json COPY data/samples/ data/samples/ # NOTE: no .env, no secrets copied — injected at runtime by Render diff --git a/api/claims.py b/api/claims.py index ed5564c..1589ef6 100644 --- a/api/claims.py +++ b/api/claims.py @@ -2,6 +2,7 @@ from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel from services.agents.run import start_claim, resume_claim +from services.agents.audit import audit_node from services.ingest.extractor import extract_claim from fastapi import Depends from .auth import require_key @@ -78,7 +79,14 @@ async def decide(claim_id: str, body: DecisionIn): else c["recommendation"]["decision"]) def _resume(): - resume_claim(c["thread_id"], human_decision=final, approver=body.approver) + try: + resume_claim(c["thread_id"], human_decision=final, + approver=body.approver) + except Exception: + # baked demo index may not have a matching LangGraph checkpoint + audit_node({"claim_id": claim_id, + "recommendation": c["recommendation"], + "human_decision": final, "approver": body.approver}) await run_in_threadpool(_resume) store.upsert(claim_id, status="decided", diff --git a/data/demo_claims_index.json b/data/demo_claims_index.json new file mode 100644 index 0000000..0c3a3c7 --- /dev/null +++ b/data/demo_claims_index.json @@ -0,0 +1,114 @@ +{ + "claim_0001": { + "thread_id": "3de5e4ee-ad20-4576-99e5-c13b37dc8590", + "status": "pending", + "recommendation": { + "decision": "approve", + "payout_low_usd": 6500.0, + "payout_high_usd": 26000.0, + "fraud_risk": "low", + "confidence": 0.83, + "rationale": "Applying adjudication rules in order: (1) Coverage is confirmed - covered is true, so no denial on coverage grounds. (2) Fraud risk is low with no flags, so no investigation required. (3) Cost midpoint ($17,250) exceeds deductible ($1,000), so no denial on deductible grounds. (4) Therefore, we approve the claim. Payout range is calculated as repair cost estimates ($7,500-$27,000) minus the $1,000 deductible, with the high end capped at the coverage limit of $26,000. Similar precedent claim_0012 involved vandalism and was approved with a comparable payout of $8,750.", + "cited_precedents": [ + "claim_0012", + "claim_0013", + "claim_0023" + ], + "policy_basis": "Claim meets all approval criteria: incident is covered under policy, fraud risk is low, and estimated repair costs exceed the deductible amount. Payout is subject to $1,000 deductible and $26,000 coverage limit as specified in the policy terms." + }, + "evidence": { + "incident_type": "collision", + "damaged_parts": [ + "front end", + "hood", + "front driver side", + "windshield", + "front bumper", + "driver door" + ], + "severity": "severe", + "visible_pre_existing_damage": false, + "image_inconsistency": true, + "policy_number": "AUTO-42953", + "deductible_usd": 1000.0, + "coverage_limit_usd": 26000.0, + "notes_summary": "Insured reported vandalism at junction with severe damage. However, the photo evidence shows extensive collision damage consistent with a high-speed impact rather than vandalism. The front end is severely crushed, hood crumpled, windshield shattered, and driver door damaged - all typical of a collision. Adjuster notes mention 'severe dmg to unknown' suggesting uncertainty about the incident type. Discrepancy exists between claimed vandalism and visible collision damage.", + "extraction_confidence": 0.65 + }, + "claim_id": "claim_0001" + }, + "claim_0000": { + "thread_id": "b9fcefa6-51b0-4e57-9e1c-f5436a4d11f0", + "status": "pending", + "recommendation": { + "decision": "approve", + "payout_low_usd": 80.0, + "payout_high_usd": 1400.0, + "fraud_risk": "low", + "confidence": 0.92, + "rationale": "Coverage is confirmed and the claim passes all checks. The cost midpoint ($990) exceeds the deductible ($250), satisfying the threshold requirement. Fraud risk is assessed as low with no flags present. All precedent cases with similar image characteristics were approved with no fraud issues. The estimated repair costs fall well within the policy coverage limit of $26,000. Payout range calculated as cost estimates ($330-$1,650) minus the $250 deductible.", + "cited_precedents": [ + "claim_0000", + "claim_0014", + "claim_0032", + "claim_0072", + "claim_0062" + ], + "policy_basis": "Claim is covered under policy with $250 deductible and $26,000 coverage limit. Cost estimate midpoint of $990 exceeds deductible threshold. No fraud indicators or coverage exclusions apply." + }, + "evidence": { + "incident_type": "theft", + "damaged_parts": [ + "rear bumper", + "quarter panel" + ], + "severity": "minor", + "visible_pre_existing_damage": false, + "image_inconsistency": true, + "policy_number": "AUTO-74686", + "deductible_usd": 250.0, + "coverage_limit_usd": 26000.0, + "notes_summary": "Reporting party reports theft incident occurred on driveway with minor damage to unknown parts. Image shows blue vehicle with scrapes and damage to rear bumper/quarter panel area.", + "extraction_confidence": 0.65 + }, + "claim_id": "claim_0000" + }, + "claim_0007": { + "thread_id": "af646410-16fa-4592-97df-b65693ad59a2", + "status": "pending", + "recommendation": { + "decision": "approve", + "payout_low_usd": 6750.0, + "payout_high_usd": 24950.0, + "fraud_risk": "low", + "confidence": 0.85, + "rationale": "Coverage is confirmed with a deductible of $250 and coverage limit of $26,000. The estimated repair cost ranges from $7,000 to $25,200 (midpoint $16,100), which exceeds the deductible threshold. Fraud risk is assessed as low with no flags identified. Following the decision rules in order: (1) coverage is confirmed as covered, (2) fraud risk is low (not medium or high), (3) the midpoint cost of $16,100 exceeds the $250 deductible. Therefore, the claim should be approved. Similar precedents show approved severe damage claims (claim_0059, claim_0006, claim_0012) with comparable severity patterns. Payout calculation: cost range minus $250 deductible = $6,750 to $24,950.", + "cited_precedents": [ + "claim_0059", + "claim_0006", + "claim_0012", + "claim_0061" + ], + "policy_basis": "Claim meets coverage requirements under the policy. The incident is covered, fraud risk is low, and repair costs significantly exceed the deductible amount. Policy allows for approval with payout calculated as estimated costs less applicable deductible." + }, + "evidence": { + "incident_type": "collision", + "damaged_parts": [ + "rear end", + "trunk", + "rear bumper", + "rear quarter panel", + "roof" + ], + "severity": "severe", + "visible_pre_existing_damage": false, + "image_inconsistency": true, + "policy_number": "AUTO-41195", + "deductible_usd": 250.0, + "coverage_limit_usd": 26000.0, + "notes_summary": "Caller reports collision in car park with severe damage. No injuries reported. Image shows catastrophic damage to entire rear section and roof structure with complete collapse - inconsistent with typical car park collision.", + "extraction_confidence": 0.65 + }, + "claim_id": "claim_0007" + } +} \ No newline at end of file diff --git a/web/src/WakeApi.tsx b/web/src/WakeApi.tsx new file mode 100644 index 0000000..ba52229 --- /dev/null +++ b/web/src/WakeApi.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { api } from "./api"; + +const INTERVAL_MS = 2_000; +const MAX_ATTEMPTS = 45; // ~90s — covers Render free-tier cold start + +export default function WakeApi({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + + async function poll() { + for (let i = 0; i < MAX_ATTEMPTS; i++) { + if (cancelled) return; + setAttempt(i + 1); + try { + await api.health(); + setReady(true); + return; + } catch { + await new Promise((r) => setTimeout(r, INTERVAL_MS)); + } + } + if (!cancelled) setFailed(true); + } + + poll(); + return () => { + cancelled = true; + }; + }, []); + + if (ready) return <>{children}; + + return ( +
+
+
+ {failed ? ( + <> +

+ API didn't wake up in time +

+

+ The demo API may still be starting. Refresh in a moment, or check + Render dashboard. +

+ + + ) : ( + <> +

+ Waking demo API… +

+

+ Free-tier container was asleep. First load can take up to ~30s. +

+ {attempt > 3 && ( +

+ Still waiting… ({attempt}/{MAX_ATTEMPTS}) +

+ )} + + )} +
+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 356e7ed..7bce0bf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -16,7 +16,14 @@ async function j(path: string, opts?: RequestInit) { return r.json(); } +async function health() { + const r = await fetch(BASE + "/health"); + if (!r.ok) throw new Error("unhealthy"); + return r.json(); +} + export const api = { + health, list: () => j("/claims"), get: (id: string) => j(`/claims/${id}`), submit: (claim_dir: string) => diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..736aea2 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,24 @@ -import { StrictMode } from 'react' +import { StrictMode, useState } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import Login from './Login.tsx' +import WakeApi from './WakeApi.tsx' + +function Root() { + const [authed, setAuthed] = useState( + () => localStorage.getItem('demo') === '1', + ) + if (!authed) return setAuthed(true)} /> + return ( + + + + ) +} createRoot(document.getElementById('root')!).render( - + , )