Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ An evidence-backed recommendation — with its reasoning, precedents, and audit

<br>

[![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))
&nbsp;
[![Eval gate](https://img.shields.io/badge/CI-eval_gate_passing-16a34a?style=for-the-badge)](.github/workflows/eval.yml)

Expand All @@ -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).

<div align="center">
Expand Down
4 changes: 3 additions & 1 deletion api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
13 changes: 11 additions & 2 deletions api/claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,7 +45,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()]
Expand Down Expand Up @@ -77,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",
Expand Down
114 changes: 114 additions & 0 deletions data/demo_claims_index.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
45 changes: 28 additions & 17 deletions web/src/ClaimDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function ClaimDetail({
}) {
const [c, setC] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);

useEffect(() => {
setC(null);
Expand All @@ -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);
}
Expand All @@ -62,7 +66,7 @@ export default function ClaimDetail({
</h1>
<p className="mt-0.5 text-sm capitalize text-zinc-500">{c.status}</p>
</div>
<Badge decision={r.decision} />
<Badge decision={decided ? c.human_decision : "pending"} />
</div>

{/* recommendation */}
Expand All @@ -84,7 +88,7 @@ export default function ClaimDetail({
</dd>
</div>
<div className="px-5 py-4">
<dt className={ui.label}>Decision</dt>
<dt className={ui.label}>Recommended</dt>
<dd className="mt-1.5">
<Badge decision={r.decision} />
</dd>
Expand Down Expand Up @@ -185,21 +189,28 @@ export default function ClaimDetail({
)}
</div>
) : (
<div className="mt-6 flex items-center gap-3">
<button
disabled={busy}
onClick={() => act("approve")}
className={ui.btnPrimary}
>
Approve recommendation
</button>
<button
disabled={busy}
onClick={() => act("override", "deny")}
className={ui.btnSecondary}
>
Override → Deny
</button>
<div className="mt-6 space-y-3">
{err && (
<p className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{err}
</p>
)}
<div className="flex items-center gap-3">
<button
disabled={busy}
onClick={() => act("approve")}
className={ui.btnPrimary}
>
Approve recommendation
</button>
<button
disabled={busy}
onClick={() => act("override", "deny")}
className={ui.btnSecondary}
>
Override → Deny
</button>
</div>
</div>
)}
</div>
Expand Down
11 changes: 9 additions & 2 deletions web/src/ClaimList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -60,7 +61,13 @@ export default function ClaimList({
<span className="text-sm font-medium text-zinc-900">
{c.claim_id}
</span>
<Badge decision={c.decision} />
<Badge
decision={
c.status === "decided"
? c.human_decision
: "pending"
}
/>
</div>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<span className="capitalize">{c.status}</span>
Expand Down
76 changes: 76 additions & 0 deletions web/src/WakeApi.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid h-screen place-items-center bg-zinc-50 px-6">
<div className="w-80 text-center">
<div className="mx-auto mb-5 h-10 w-10 animate-spin rounded-full border-[3px] border-violet-600 border-t-transparent" />
{failed ? (
<>
<p className="text-sm font-medium text-zinc-800">
API didn&apos;t wake up in time
</p>
<p className="mt-2 text-sm text-zinc-500">
The demo API may still be starting. Refresh in a moment, or check
Render dashboard.
</p>
<button
onClick={() => window.location.reload()}
className="mt-5 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700"
>
Retry
</button>
</>
) : (
<>
<p className="text-sm font-medium text-zinc-800">
Waking demo API…
</p>
<p className="mt-2 text-sm text-zinc-500">
Free-tier container was asleep. First load can take up to ~30s.
</p>
{attempt > 3 && (
<p className="mt-3 text-xs text-zinc-400">
Still waiting… ({attempt}/{MAX_ATTEMPTS})
</p>
)}
</>
)}
</div>
</div>
);
}
Loading
Loading