feat: add insurance claim live agent team

This commit is contained in:
Shubhamsaboo
2026-05-01 23:30:12 -07:00
parent 5d9c0b282a
commit 6bc214b6fa
14 changed files with 3890 additions and 0 deletions
@@ -0,0 +1,8 @@
# Local ADK Web development with Google AI Studio.
GOOGLE_GENAI_USE_VERTEXAI=False
GOOGLE_API_KEY=replace-me
# Optional Vertex AI settings for future Agent Platform work.
# GOOGLE_GENAI_USE_VERTEXAI=True
# GOOGLE_CLOUD_PROJECT=your-project-id
# GOOGLE_CLOUD_LOCATION=global
@@ -0,0 +1,109 @@
# Insurance Claim Live Agent Team
A voice-first insurance claim intake app that lets a claimant talk naturally while the agent builds a structured claim packet in real time. The UI shows the live conversation, extracted claim facts, operator guidance, missing items, and an adjuster-ready handoff.
This is designed as a realistic first notice of loss (FNOL) workflow: the claimant does not need to fill out a rigid form, and the operator does not need to manually translate a messy conversation into claim fields.
## Features
### Voice + Text Claim Intake
- Native voice conversation with the claim intake agent
- Real-time transcript for claimant and agent turns
- Text input fallback for typed claim details
- Live audio responses from the agent
### Real-Time Claim Packet
- Automatically extracts claimant name, contact method, policy number, loss type, date, location, description, safety details, evidence, and report numbers
- Updates the claim state as the conversation progresses
- Highlights missing or uncertain information
- Builds an adjuster handoff packet while the call is still happening
### Operator Guidance
- Shows the current claim disposition
- Suggests the next best question or confirmation
- Lists blocking items before handoff
- Separates the operator-facing summary from the lower-level audit trail
### Insurance-Specific Routing
- Handles home water damage, auto collision, theft/property loss, travel claims, medical reimbursement examples, and unclear claims
- Applies deterministic evidence and document checks
- Flags injury, safety, habitability, timing, SIU, and escalation signals
- Avoids promising coverage, payment, or liability
## App Engine
The app combines live voice, structured extraction, and deterministic insurance rules:
| Layer | Model / Engine | Purpose |
| --- | --- | --- |
| Live voice | `gemini-3.1-flash-live-preview` | Voice-to-voice conversation, audio responses, and transcription |
| Structured extraction | `gemini-3-flash-preview` | Converts messy claim language into structured claim facts |
| Agent workflow | ADK `SequentialAgent` + function nodes | Organizes normalization, classification, validation, routing, and packet generation |
| Business rules | Python + Pydantic | Deterministic missing-field checks, evidence gates, safety routing, SIU signals, and handoff packet output |
| App backend | FastAPI | Serves the frontend and coordinates the model calls, WebSocket audio stream, and claim state |
| Frontend | HTML, CSS, JavaScript | Dark professional live cockpit for voice, transcript, claim state, and handoff |
## Architecture
![Insurance Claim Live Agent Team architecture](assets/insurance-claim-live-agent-team-architecture.png)
## Project Structure
```text
insurance_claim_live_agent_team/
|-- agent.py
|-- schemas.py
|-- policies.py
|-- examples.py
|-- requirements.txt
|-- .env.example
|-- assets/
| `-- insurance-claim-live-agent-team-architecture.png
|-- live_demo/
| |-- index.html
| |-- styles.css
| |-- app.js
| |-- server.py
| `-- assets/
| `-- fnol-live-intake-concept.png
`-- README.md
```
## How to Get Started
From the app directory:
```bash
cd voice_ai_agents/insurance_claim_live_agent_team
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
```
Edit `.env` and set your Google API key:
```bash
GOOGLE_GENAI_USE_VERTEXAI=False
GOOGLE_API_KEY=your-google-api-key
```
## Run the App
Start the backend and frontend server:
```bash
python -m uvicorn live_demo.server:app --reload --host 127.0.0.1 --port 4177
```
Open the app:
```text
http://127.0.0.1:4177/index.html
```
Use the microphone button to start a live claim conversation, or type into the text box if microphone access is unavailable.
@@ -0,0 +1,5 @@
"""AI Insurance Claim Intake Agent."""
from .agent import root_agent
__all__ = ["root_agent"]
@@ -0,0 +1,238 @@
"""ADK hybrid graph workflow for AI Insurance Claim Intake."""
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable
from google.adk.agents import BaseAgent, LlmAgent, SequentialAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events import Event, EventActions
from google.genai import types as genai_types
from pydantic import ConfigDict
from typing_extensions import override
try:
from .policies import (
apply_coverage_and_evidence_rules,
build_claim_intake_packet,
fraud_signal_and_safety_gate,
generate_document_checklist,
validate_required_claim_fields,
)
from .schemas import ClaimClassification, ClaimNarrative
except ImportError:
from policies import (
apply_coverage_and_evidence_rules,
build_claim_intake_packet,
fraud_signal_and_safety_gate,
generate_document_checklist,
validate_required_claim_fields,
)
from schemas import ClaimClassification, ClaimNarrative
MODEL = "gemini-3-flash-preview"
def _content(text: str) -> genai_types.Content:
return genai_types.Content(role="model", parts=[genai_types.Part(text=text)])
def _state_event(author: str, text: str, updates: dict[str, Any]) -> Event:
return Event(
author=author,
content=_content(text),
actions=EventActions(state_delta=updates),
)
class FunctionNode(BaseAgent):
"""Deterministic workflow node that reads and writes ADK session state."""
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
handler: Callable[[InvocationContext], dict[str, Any]]
output_key: str
summary: str
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
result = self.handler(ctx)
ctx.session.state[self.output_key] = result
yield _state_event(self.name, self.summary, {self.output_key: result})
class FinalPacketNode(FunctionNode):
"""Function node that returns the final packet Markdown as ADK Web output."""
@override
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
result = self.handler(ctx)
updates = {self.output_key: result, "final_markdown": result["markdown"]}
ctx.session.state.update(updates)
yield _state_event(self.name, result["markdown"], updates)
def _validate_claim_handler(ctx: InvocationContext) -> dict[str, Any]:
return validate_required_claim_fields(ctx.session.state.get("normalized_claim"))
def _coverage_evidence_handler(ctx: InvocationContext) -> dict[str, Any]:
return apply_coverage_and_evidence_rules(
ctx.session.state.get("normalized_claim"),
ctx.session.state.get("field_validation"),
ctx.session.state.get("claim_classification"),
)
def _document_checklist_handler(ctx: InvocationContext) -> dict[str, Any]:
return generate_document_checklist(
ctx.session.state.get("normalized_claim"),
ctx.session.state.get("claim_classification"),
ctx.session.state.get("coverage_evidence_decision"),
)
def _fraud_safety_handler(ctx: InvocationContext) -> dict[str, Any]:
return fraud_signal_and_safety_gate(
ctx.session.state.get("normalized_claim"),
ctx.session.state.get("field_validation"),
ctx.session.state.get("claim_classification"),
ctx.session.state.get("coverage_evidence_decision"),
)
def _final_packet_handler(ctx: InvocationContext) -> dict[str, Any]:
return build_claim_intake_packet(
ctx.session.state.get("normalized_claim"),
ctx.session.state.get("field_validation"),
ctx.session.state.get("claim_classification"),
ctx.session.state.get("coverage_evidence_decision"),
ctx.session.state.get("document_checklist"),
ctx.session.state.get("fraud_safety_gate"),
)
def create_normalizer() -> LlmAgent:
return LlmAgent(
name="NormalizeClaimNarrative",
model=MODEL,
description="Normalizes messy insurance claim narratives into structured intake facts.",
instruction="""
You are the intake specialist for an AI Insurance Claim Intake Agent.
Read the user's messy insurance claim narrative and produce a structured
ClaimNarrative. Preserve facts exactly when possible. Do not invent policy
numbers, contacts, dates, locations, evidence, or dollar amounts.
Extraction rules:
- policyholder_name: claimant or policyholder name, otherwise "not specified".
- policy_number: policy/member number, otherwise "not specified".
- contact_method: phone, email, mailing address, or preferred channel, otherwise "not specified".
- date_of_loss: date or date range of the loss, otherwise "not specified".
- reported_date: date the user says they are reporting the claim, otherwise "not specified".
- loss_location: address, city, intersection, provider, or travel route, otherwise "not specified".
- loss_description: concise factual description of what happened.
- estimated_loss_usd: numeric USD estimate only if supplied.
- injuries_or_safety_concerns: include injuries, urgent medical care, unsafe housing, electrical hazards, sewage, mold, or no place to live.
- evidence_available: photos, video, receipts, report numbers, estimates, bills, carrier notices, EOBs, proof of payment, serial numbers, or similar evidence already mentioned.
- documents_mentioned: specific documents mentioned whether available or missing.
- missing_or_uncertain_facts: key facts the narrative says are unknown, vague, or incomplete.
This is an intake normalization step only. Do not confirm coverage or payment.
""",
output_schema=ClaimNarrative,
output_key="normalized_claim",
)
def create_classifier() -> LlmAgent:
return LlmAgent(
name="ClassifyClaimTypeAndSeverity",
model=MODEL,
description="Classifies claim type, severity, policy line, and claimant needs.",
instruction="""
Classify this normalized claim for insurance intake routing.
Normalized claim:
{normalized_claim}
Validation:
{field_validation}
Supported claim types:
- home_water_damage
- auto_collision
- theft_property_loss
- health_medical_reimbursement
- travel_delay_cancellation
- other
Severity rubric:
- low: complete, low-dollar, no injury/safety issue, routine documentation.
- medium: missing documents or moderate complexity.
- high: high estimated loss, unclear liability, missing core facts, or specialized handling likely.
- urgent: injury, unsafe living condition, emergency medical/safety concern, or time-sensitive mitigation.
Return only the structured ClaimClassification. This is classification, not a
coverage decision.
""",
output_schema=ClaimClassification,
output_key="claim_classification",
)
def create_workflow() -> SequentialAgent:
return SequentialAgent(
name="insurance_claim_live_agent_team",
description="Hybrid voice-first agent team for insurance claim intake, evidence triage, and routing.",
sub_agents=[
create_normalizer(),
FunctionNode(
name="ValidateRequiredClaimFields",
description="Deterministically validates required claim intake fields.",
handler=_validate_claim_handler,
output_key="field_validation",
summary="Validated required claim intake fields.",
),
create_classifier(),
FunctionNode(
name="ApplyCoverageAndEvidenceRules",
description="Applies deterministic coverage, evidence, severity, and routing gates.",
handler=_coverage_evidence_handler,
output_key="coverage_evidence_decision",
summary="Applied deterministic coverage and evidence rules.",
),
FunctionNode(
name="GenerateDocumentChecklist",
description="Builds a claimant-facing document checklist from deterministic rules.",
handler=_document_checklist_handler,
output_key="document_checklist",
summary="Generated required document checklist.",
),
FunctionNode(
name="FraudSignalAndSafetyGate",
description="Applies deterministic fraud signal, suspicious timing, and safety gates.",
handler=_fraud_safety_handler,
output_key="fraud_safety_gate",
summary="Applied fraud, timing, and safety routing gates.",
),
FinalPacketNode(
name="FinalClaimIntakePacket",
description="Builds the final polished Markdown claim intake packet.",
handler=_final_packet_handler,
output_key="claim_intake_packet",
summary="Built final claim intake packet.",
),
],
)
root_agent = create_workflow()
__all__ = ["root_agent", "create_workflow"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,48 @@
"""Demo prompts for ADK Web."""
BASEMENT_FLOOD_WITH_PHOTOS = """
I need to start a homeowners claim. Policyholder is Maya Singh, policy H0-44721.
Phone is 415-555-0134 and email is maya@example.com. On March 18, 2026 our
finished basement in Denver flooded after the sump pump failed during heavy rain.
There is soaked carpet, damaged drywall, and water around stored boxes. We took
photos and a short video before moving anything. I do not have repair receipts or
contractor estimates yet. I think damage is around $18,000.
"""
CAR_ACCIDENT_WITH_INJURIES = """
Auto claim for Jordan Lee, policy AUTO-90210. Text me at 503-555-0199.
On April 2, 2026 at 6:40 PM near SE 12th and Hawthorne in Portland, another car
ran a red light and hit my driver's side. My passenger has neck pain and went to
urgent care. Police came and gave report number PDX-24-8811. I have photos,
the other driver's plate, and the tow receipt. Car may be totaled, estimate unknown.
"""
STOLEN_LAPTOP_NO_POLICE_REPORT = """
I want to file for a stolen laptop. I'm Priya Shah, renter policy RNT-3008,
priya@example.com. It was taken from my backpack at a coffee shop in Austin on
February 9, 2026 around 3 PM. MacBook Pro and charger, maybe $2,400. I have the
purchase receipt and serial number but I have not filed a police report yet.
"""
TRAVEL_CANCELLATION_STORM = """
Travel claim: Alex Chen, policy TRV-7711, alex.chen@example.com, 646-555-0112.
Our flight from JFK to Reykjavik on January 14, 2026 was cancelled because of a
major winter storm and the airline could not rebook us for three days, so we
missed the prepaid glacier tour and first two hotel nights. I have airline emails,
hotel receipts, tour confirmation, and credit card statements. Total loss about
$3,200.
"""
INCOMPLETE_VAGUE_CLAIM = """
Something bad happened last week and I need insurance to pay for it. I lost a lot
of stuff and maybe there was damage at my place. I don't remember the exact date.
Please just open the claim.
"""
DEMO_PROMPTS = {
"basement_flood_with_photos": BASEMENT_FLOOD_WITH_PHOTOS,
"car_accident_with_injuries": CAR_ACCIDENT_WITH_INJURIES,
"stolen_laptop_no_police_report": STOLEN_LAPTOP_NO_POLICE_REPORT,
"travel_cancellation_storm": TRAVEL_CANCELLATION_STORM,
"incomplete_vague_claim": INCOMPLETE_VAGUE_CLAIM,
}
@@ -0,0 +1,667 @@
const transcriptEl = document.querySelector("#transcript");
const claimFieldsEl = document.querySelector("#claimFields");
const timelineEl = document.querySelector("#timeline");
const handoffEl = document.querySelector("#handoff");
const packetMarkdownEl = document.querySelector("#packetMarkdown");
const packetDialog = document.querySelector("#packetDialog");
const routePill = document.querySelector("#routePill");
const claimState = document.querySelector("#claimState");
const packetProgress = document.querySelector("#packetProgress");
const callStatus = document.querySelector("#callStatus");
const micButton = document.querySelector("#micButton");
const newIntakeButton = document.querySelector("#newIntakeButton");
const resetButton = document.querySelector("#resetButton");
const textForm = document.querySelector("#textForm");
const textInput = document.querySelector("#textInput");
const modelLabel = document.querySelector("#modelLabel");
const API_ORIGIN = "http://127.0.0.1:4177";
const WS_ORIGIN = "ws://127.0.0.1:4177";
if (window.location.protocol === "file:") {
window.location.replace(`${API_ORIGIN}/index.html`);
}
let liveSocket = null;
let audioContext = null;
let inputProcessor = null;
let inputSource = null;
let audioStream = null;
let isRecording = false;
let nextPlaybackTime = 0;
let sessionId = null;
let state = null;
let closeAfterAgentTurn = false;
const initialFields = [
{ title: "Identity", fields: [["claimant", "Claimant name"], ["policy", "Policy number"], ["contact", "Contact method"]] },
{ title: "Loss", fields: [["type", "Claim type"], ["date", "Date of loss"], ["time", "Reported date"], ["location", "Location"], ["description", "Loss description"]] },
{ title: "Safety", fields: [["injuries", "Injuries"], ["hazards", "Hazards present"], ["medical", "Medical attention"]] },
{ title: "Evidence", fields: [["police", "Report number"], ["photos", "Evidence available"], ["tow", "Tow info"], ["otherDriver", "Other driver info"]] },
];
const emptyState = {
route: "needs_docs",
progress: 0,
fields: Object.fromEntries(
initialFields.flatMap((group) =>
group.fields.map(([id, label]) => [
id,
{ label, value: `Missing: ${label.toLowerCase()}`, status: "missing", source: "-" },
])
)
),
transcript: [{ speaker: "Agent", text: `Connecting to the live intake backend at ${API_ORIGIN}...` }],
events: [{ tone: "warning", title: "Connecting", detail: `Waiting for ${API_ORIGIN}/api/sessions.`, rule: "API-000" }],
handoff: {
Summary: "Backend session not started yet.",
Priority: "Pending",
"Required actions": "Start a live intake session.",
Attachments: "None",
"Next best action": "Connect to the backend API.",
},
packet_markdown: "# Initial Adjuster Handoff\n\nWaiting for backend session.",
};
const routeLabels = {
emergency_escalation: "Emergency escalation",
needs_docs: "Needs documents",
special_investigation: "Special investigation",
ready_for_adjuster: "Ready for adjuster",
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function now() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function setState(nextState) {
state = nextState || emptyState;
if (state.model) {
modelLabel.innerHTML = `<span class="dot ok"></span> ${escapeHtml(state.model)}`;
}
render();
}
function render() {
renderTranscript();
renderFields();
renderTimeline();
renderHandoff();
renderRoute();
renderPacket();
}
function renderTranscript() {
transcriptEl.innerHTML = state.transcript
.map((turn) => {
const safeText = escapeHtml(turn.text).replace(/passenger has neck pain/gi, '<span class="highlight">$&</span>');
const initials = turn.speaker === "Agent" ? "AI" : "CL";
return `
<article class="turn ${turn.speaker === "Agent" ? "agent" : "claimant"}">
<div class="speaker-icon">${initials}</div>
<div class="bubble">
<strong>${escapeHtml(turn.speaker)}</strong><time>${escapeHtml(turn.time || now())}</time>
<p>${safeText}</p>
</div>
</article>
`;
})
.join("");
transcriptEl.scrollTop = transcriptEl.scrollHeight;
}
function renderFields() {
claimFieldsEl.innerHTML = initialFields
.map((group) => {
const rows = group.fields
.map(([id, label]) => {
const field = state.fields[id] || { label, value: `Missing: ${label.toLowerCase()}`, status: "missing", source: "-" };
return `
<div class="field-row">
<div class="field-label">${escapeHtml(field.label)}</div>
<div class="field-value ${escapeHtml(field.status)}">${escapeHtml(field.value)}</div>
<div class="field-source">${escapeHtml(field.source)}</div>
</div>
`;
})
.join("");
return `
<section class="field-group">
<div class="group-title"><h3>${escapeHtml(group.title)}</h3><span class="status-line">Live</span></div>
${rows}
</section>
`;
})
.join("");
}
function renderTimeline() {
const guidance = buildOperatorGuidance();
timelineEl.innerHTML = `
<section class="decision-card ${escapeHtml(guidance.routeTone)}">
<div class="decision-label">Current disposition</div>
<strong>${escapeHtml(guidance.routeLabel)}</strong>
<p>${escapeHtml(guidance.priority)}</p>
</section>
<section class="operator-card ask-card">
<div class="operator-card-label">Ask or confirm next</div>
<p>${escapeHtml(guidance.nextAction)}</p>
</section>
<section class="operator-card">
<div class="operator-card-label">Blocking items</div>
<div class="missing-chips">
${guidance.missingItems.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}
</div>
</section>
<section class="operator-card">
<div class="operator-card-label">Handoff readiness</div>
<div class="readiness-meter" aria-label="Packet completion ${escapeHtml(String(state.progress || 0))}%">
<div style="width: ${Math.max(0, Math.min(100, Number(state.progress || 0)))}%"></div>
</div>
<p class="readiness-copy">${escapeHtml(guidance.readinessCopy)}</p>
</section>
<details class="audit-details">
<summary>Audit trail</summary>
<div class="audit-list">
${guidance.auditEvents
.map(
(event) => `
<article class="event ${escapeHtml(event.tone || "")}">
<div class="event-time">${escapeHtml(event.time || now())}</div>
<div class="event-body">
<div class="event-title">${escapeHtml(event.title)}</div>
<div class="event-detail">${escapeHtml(event.detail)}</div>
</div>
<div class="rule-id">${escapeHtml(event.rule || "")}</div>
</article>
`
)
.join("")}
</div>
</details>
`;
timelineEl.scrollTop = timelineEl.scrollHeight;
}
function buildOperatorGuidance() {
const route = state.route || "needs_docs";
const handoff = state.handoff || {};
const missingEvent = [...(state.events || [])].reverse().find((event) => event.rule === "INTAKE-001" && event.title === "Missing intake facts");
const missingItems = missingEvent?.detail
? missingEvent.detail.split(",").map((item) => item.trim()).filter(Boolean)
: Object.values(state.fields || {})
.filter((field) => field.status === "missing")
.map((field) => field.label)
.slice(0, 6);
const requiredActions = splitList(handoff["Required actions"]);
const routeTone = route === "emergency_escalation" ? "danger" : route === "ready_for_adjuster" ? "success" : "warning";
const readinessCopy =
route === "ready_for_adjuster"
? "Core intake is ready for assignment."
: requiredActions.length
? `Collect or confirm: ${requiredActions.slice(0, 2).join(", ")}.`
: "Continue collecting the highlighted intake facts.";
return {
routeLabel: routeLabels[route] || route,
routeTone,
priority: handoff.Priority || "Waiting for claim facts.",
nextAction: handoff["Next best action"] || "Ask for the next missing intake fact.",
missingItems: missingItems.length ? missingItems : ["No blocking intake items"],
readinessCopy,
auditEvents: state.events || [],
};
}
function splitList(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function renderHandoff() {
handoffEl.innerHTML = Object.entries(state.handoff)
.map(([label, value]) => `<div class="handoff-row"><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`)
.join("");
packetProgress.textContent = `${state.progress}%`;
}
function renderRoute() {
if (!routePill || !claimState) return;
const route = state.route || "needs_docs";
routePill.className = "route-pill";
if (route === "emergency_escalation") routePill.classList.add("emergency");
if (route === "needs_docs" || route === "special_investigation") routePill.classList.add("docs");
if (route === "ready_for_adjuster") routePill.classList.add("ready");
routePill.textContent = routeLabels[route] || route;
claimState.textContent = `Claim state: ${(routeLabels[route] || route).toLowerCase()}`;
}
function renderPacket() {
packetMarkdownEl.textContent = state.packet_markdown || "# Initial Adjuster Handoff\n\nNo packet generated yet.";
}
function appendLocalError(message) {
setState({
...state,
transcript: [...state.transcript, { speaker: "Agent", text: message, time: now() }],
events: [...state.events, { tone: "danger", title: "Backend API error", detail: message, rule: "API-ERR", time: now() }],
});
}
function sameTranscriptTurn(left, right) {
const leftText = String(left?.text || "").trim();
const rightText = String(right?.text || "").trim();
if (!leftText || !rightText || left?.speaker !== right?.speaker) return false;
return leftText === rightText || leftText.includes(rightText) || rightText.includes(leftText);
}
function mergeLiveTranscript(authoritative, local) {
const merged = [...(authoritative || [])];
for (const turn of local || []) {
if (!turn.streaming || !String(turn.text || "").trim()) continue;
if (merged.some((item) => sameTranscriptTurn(item, turn))) continue;
merged.push(turn);
}
return merged;
}
function applyServerState(nextState) {
setState({
...nextState,
transcript: mergeLiveTranscript(nextState.transcript, state?.transcript),
});
}
function upsertStreamingTurn(speaker, text, final = false) {
if (!String(text || "").trim()) return;
const transcript = [...state.transcript];
const last = transcript[transcript.length - 1];
if (last && last.speaker === speaker && last.streaming) {
transcript[transcript.length - 1] = { speaker, text, time: last.time, streaming: !final };
} else if (final && last && !last.streaming && sameTranscriptTurn(last, { speaker, text })) {
return;
} else {
transcript.push({ speaker, text, time: now(), streaming: !final });
}
setState({ ...state, transcript });
}
function claimantAskedToClose(text) {
return /\b(that'?s all|nothing else|no,?\s*that'?s it|that is it|i'?m done|goodbye|bye)\b/i.test(text);
}
function agentClosedConversation(text) {
return /\b(have a good|adjuster will|will be in touch|claim packet|initial intake|next steps)\b/i.test(text);
}
function applyRealtimeHints(text) {
const lower = text.toLowerCase();
const nextFields = { ...state.fields };
const nextEvents = [...state.events];
let nextRoute = state.route;
let changed = false;
const update = (id, value, status = "pending", source = "live audio") => {
const current = nextFields[id];
if (!current || current.value === value) return;
nextFields[id] = { ...current, value, status, source };
changed = true;
};
const event = (rule, title, detail, tone = "warning") => {
const key = `${rule}:${title}`;
if (nextEvents.some((item) => `${item.rule}:${item.title}` === key)) return;
nextEvents.push({ rule, title, detail, tone, time: now() });
changed = true;
};
const phone = text.match(/\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/);
if (phone) {
update("contact", phone[0], "pending", "live transcript");
event("LIVE-INFO", "Contact detected", "Phone number heard in live audio.", "success");
}
const policy = text.match(/\b(?:policy|auto|home|claim)\s*(?:number|#|is|:)?\s*([A-Z0-9][A-Z0-9-]{3,})\b/i);
if (policy && /\d/.test(policy[1])) {
update("policy", policy[1].toUpperCase(), "pending", "live transcript");
event("LIVE-INFO", "Policy candidate detected", "Policy-like identifier heard in live audio.", "success");
}
const name = text.match(/\b(?:my name is|this is)\s+([a-z]+(?:\s+[a-z]+){0,1})(?=\s+(?:and|with|from|calling|phone|policy|$))/i)
|| text.match(/\b(?:i am|i'm)\s+([a-z]+(?:\s+[a-z]+){0,1})(?=\s+(?:and|with|from|calling|phone|policy|$))/i);
if (name) {
update("claimant", toTitleCase(name[1]), "pending", "live transcript");
event("LIVE-INFO", "Claimant name detected", "Name candidate heard in live audio.", "success");
}
if (/\b(hit|rear ended|rear-ended|crash|accident|collision|side[- ]?swiped)\b/.test(lower)) {
update("type", "auto collision", "pending", "live transcript");
update("description", compactText(text), "pending", "live transcript");
event("LIVE-CLASSIFY", "Auto collision signal", "Crash language detected before final extraction.", "warning");
} else if (/\b(flood|water|leak|pipe|basement|sump)\b/.test(lower)) {
update("type", "home water damage", "pending", "live transcript");
update("description", compactText(text), "pending", "live transcript");
event("LIVE-CLASSIFY", "Home water signal", "Water damage language detected before final extraction.", "warning");
} else if (/\b(stolen|theft|robbed|missing laptop|taken)\b/.test(lower)) {
update("type", "theft property loss", "pending", "live transcript");
update("description", compactText(text), "pending", "live transcript");
event("LIVE-CLASSIFY", "Theft signal", "Theft language detected before final extraction.", "warning");
}
const safetyText = stripNegatedSafety(lower);
if (/\b(neck pain|injur|hurt|ambulance|hospital|urgent care|bleeding|pain)\b/.test(safetyText)) {
update("injuries", compactText(text), "urgent", "live transcript");
event("SAFE-002", "Injury signal detected", "Live audio mentions injury or medical concern.", "danger");
nextRoute = "emergency_escalation";
changed = true;
} else if (safetyText !== lower) {
update("injuries", "No injuries reported", "pending", "live transcript");
event("SAFE-OK", "No injury reported", "Live audio negated injury or medical concern.", "success");
}
if (/\b(police|officer|report|case number|incident number)\b/.test(lower)) {
update("police", compactText(text), "pending", "live transcript");
event("DOC-001", "Police/report signal detected", "Police or report language heard in live audio.", "warning");
}
if (/\b(photo|photos|picture|video|receipt|estimate|tow|towed|storage)\b/.test(lower)) {
update("photos", compactText(text), "pending", "live transcript");
if (/\b(tow|towed|storage)\b/.test(lower)) {
update("tow", compactText(text), "pending", "live transcript");
}
event("DOC-001", "Evidence signal detected", "Document or evidence language heard in live audio.", "warning");
}
const location = text.match(/\b(?:on|at|near)\s+([A-Z0-9][A-Za-z0-9 .'-]*(?:street|st|road|rd|avenue|ave|highway|hwy|i-\d+|mile marker \d+|intersection|exit \d+))/i);
if (location) {
update("location", location[1].trim(), "pending", "live transcript");
event("LIVE-INFO", "Location candidate detected", "Location heard in live audio.", "success");
}
if (/\b(today|yesterday|last night|this morning|minutes ago|\d{1,2}:\d{2})\b/.test(lower)) {
update("date", compactText(text), "pending", "live transcript");
event("LIVE-INFO", "Loss timing detected", "Date or time language heard in live audio.", "success");
}
if (changed) {
const completed = Object.values(nextFields).filter((field) => field.status === "complete" || field.status === "urgent" || field.status === "pending").length;
setState({
...state,
route: nextRoute,
fields: nextFields,
events: nextEvents.slice(-14),
progress: Math.max(state.progress, Math.round((completed / Object.keys(nextFields).length) * 100)),
});
}
}
function stripNegatedSafety(text) {
return text
.replace(/\b(?:no|not|none|without|denies|denied)\s+(?:one\s+)?(?:was\s+)?(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\b/gi, " ")
.replace(/\b(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\s+(?:was|were|is|are)?\s*(?:reported\s+)?(?:no|none|not reported|denied)\b/gi, " ");
}
function compactText(text) {
const trimmed = text.trim().replace(/\s+/g, " ");
return trimmed.length > 96 ? `${trimmed.slice(0, 93)}...` : trimmed;
}
function toTitleCase(value) {
return value
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
}
async function api(path, options = {}) {
const response = await fetch(`${API_ORIGIN}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail || `Request failed with status ${response.status}`);
}
return payload;
}
async function createSession() {
stopLiveVoice(false);
callStatus.textContent = "Connecting to backend";
textInput.disabled = true;
setState(emptyState);
try {
const payload = await api("/api/sessions", { method: "POST" });
sessionId = payload.session_id;
setState(payload.state);
modelLabel.innerHTML = `<span class="dot ok"></span> ${escapeHtml(payload.model)}`;
callStatus.textContent = payload.has_api_key ? "Active - API connected" : "API key required";
textInput.disabled = false;
textInput.focus();
} catch (error) {
callStatus.textContent = "Backend unavailable";
appendLocalError(error.message);
}
}
async function sendClaimantTurn(text) {
if (liveSocket && liveSocket.readyState === WebSocket.OPEN) {
liveSocket.send(JSON.stringify({ type: "text", text }));
upsertStreamingTurn("Claimant", text, true);
return;
}
if (!sessionId) {
appendLocalError("No backend session is active. Start a new intake first.");
return;
}
upsertStreamingTurn("Claimant", text, true);
callStatus.textContent = "Processing with Gemini";
textInput.disabled = true;
try {
const payload = await api("/api/message", {
method: "POST",
body: JSON.stringify({ session_id: sessionId, text }),
});
setState(payload.state);
modelLabel.innerHTML = `<span class="dot ok"></span> ${escapeHtml(payload.model)}`;
callStatus.textContent = "Active - API connected";
} catch (error) {
callStatus.textContent = "API error";
appendLocalError(error.message);
} finally {
textInput.disabled = false;
textInput.focus();
}
}
async function startLiveVoice() {
if (!navigator.mediaDevices?.getUserMedia || !window.AudioContext) {
appendLocalError("This browser cannot capture microphone audio. Type the claimant turn instead.");
return;
}
try {
await connectLiveVoice();
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = audioContext || new AudioContext();
await audioContext.resume();
inputSource = audioContext.createMediaStreamSource(audioStream);
inputProcessor = audioContext.createScriptProcessor(4096, 1, 1);
inputProcessor.onaudioprocess = (event) => {
event.outputBuffer.getChannelData(0).fill(0);
if (!liveSocket || liveSocket.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
const pcm16 = resampleToPcm16(input, audioContext.sampleRate, 16000);
liveSocket.send(JSON.stringify({ type: "audio", data: arrayBufferToBase64(pcm16.buffer) }));
};
inputSource.connect(inputProcessor);
inputProcessor.connect(audioContext.destination);
isRecording = true;
micButton.classList.add("recording");
micButton.setAttribute("aria-label", "Stop live voice");
callStatus.textContent = "Live voice streaming";
} catch (error) {
const denied = error.name === "NotAllowedError" || /denied|permission/i.test(error.message);
appendLocalError(
denied
? "Microphone access was denied by the browser or macOS. Allow microphone access for http://127.0.0.1:4177, or use the text box for this turn."
: `Live voice failed: ${error.message}`
);
stopLiveVoice(false);
}
}
function stopLiveVoice(sendClose = true) {
isRecording = false;
closeAfterAgentTurn = false;
micButton.classList.remove("recording");
micButton.setAttribute("aria-label", "Start live voice");
inputProcessor?.disconnect();
inputSource?.disconnect();
audioStream?.getTracks().forEach((track) => track.stop());
inputProcessor = null;
inputSource = null;
audioStream = null;
if (sendClose) {
liveSocket?.send(JSON.stringify({ type: "close" }));
}
liveSocket?.close();
liveSocket = null;
if (sendClose) callStatus.textContent = "Live voice stopped";
}
async function connectLiveVoice() {
if (liveSocket && liveSocket.readyState === WebSocket.OPEN) return;
liveSocket = new WebSocket(`${WS_ORIGIN}/ws/live`);
liveSocket.onopen = () => {
callStatus.textContent = "Gemini Live connected";
modelLabel.innerHTML = '<span class="dot ok"></span> Gemini Live connecting';
};
liveSocket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "session") {
sessionId = message.session_id;
modelLabel.innerHTML = `<span class="dot ok"></span> ${escapeHtml(message.model)}`;
} else if (message.type === "transcript") {
upsertStreamingTurn(message.speaker, message.text, message.final);
if (message.speaker === "Claimant") {
applyRealtimeHints(message.text);
if (message.final && claimantAskedToClose(message.text)) {
closeAfterAgentTurn = true;
}
}
if (message.speaker === "Agent" && message.final && closeAfterAgentTurn && agentClosedConversation(message.text)) {
window.setTimeout(() => stopLiveVoice(), 800);
}
} else if (message.type === "audio") {
playPcm24(message.data);
} else if (message.type === "state") {
applyServerState(message.state);
} else if (message.type === "interrupted") {
nextPlaybackTime = audioContext?.currentTime || 0;
} else if (message.type === "error") {
appendLocalError(message.message);
}
};
liveSocket.onclose = () => {
if (isRecording) callStatus.textContent = "Live voice disconnected";
};
liveSocket.onerror = () => appendLocalError("Live voice WebSocket failed. Check the backend server.");
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Live voice connection timed out.")), 8000);
liveSocket.addEventListener("open", () => {
clearTimeout(timeout);
resolve();
}, { once: true });
liveSocket.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error("Live voice connection failed."));
}, { once: true });
});
}
function resampleToPcm16(input, inputRate, outputRate) {
const ratio = inputRate / outputRate;
const outputLength = Math.floor(input.length / ratio);
const output = new Float32Array(outputLength);
for (let i = 0; i < outputLength; i += 1) {
const index = i * ratio;
const before = Math.floor(index);
const after = Math.min(before + 1, input.length - 1);
const weight = index - before;
output[i] = input[before] * (1 - weight) + input[after] * weight;
}
return floatToPcm16(output);
}
function floatToPcm16(float32) {
const pcm = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i += 1) {
const sample = Math.max(-1, Math.min(1, float32[i]));
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
return pcm;
}
function arrayBufferToBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
function base64ToInt16Array(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int16Array(bytes.buffer);
}
function playPcm24(base64) {
audioContext = audioContext || new AudioContext();
const pcm = base64ToInt16Array(base64);
const audioBuffer = audioContext.createBuffer(1, pcm.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm.length; i += 1) channel[i] = pcm[i] / 32768;
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
const startAt = Math.max(audioContext.currentTime, nextPlaybackTime);
source.start(startAt);
nextPlaybackTime = startAt + audioBuffer.duration;
}
micButton.addEventListener("click", () => {
if (isRecording) stopLiveVoice();
else startLiveVoice();
});
newIntakeButton.addEventListener("click", createSession);
resetButton.addEventListener("click", createSession);
textForm.addEventListener("submit", (event) => {
event.preventDefault();
const value = textInput.value.trim();
if (!value) return;
textInput.value = "";
sendClaimantTurn(value);
});
document.querySelector("#openPacket").addEventListener("click", () => packetDialog.showModal());
document.querySelector("#closePacket").addEventListener("click", () => packetDialog.close());
createSession();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Insurance Claim Live Agent Team</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css?v=dark-editorial-1" />
</head>
<body>
<div class="app-shell">
<main class="workspace">
<header class="topbar">
<div class="title-group">
<h1>Insurance Claim Live Agent Team</h1>
<span id="modelLabel" class="live-model"><span class="dot ok"></span> Connecting to Gemini API</span>
</div>
</header>
<section class="dashboard" aria-label="Live intake cockpit">
<section class="panel call-panel">
<div class="panel-header">
<div>
<p class="panel-kicker">Live call</p>
<h2>Voice and transcript</h2>
</div>
<span id="callStatus" class="status-pill active">Active - listening</span>
</div>
<div class="voice-stage">
<div class="wave left" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<button id="micButton" class="mic-button" type="button" aria-label="Start voice recording">
<span class="mic-icon" aria-hidden="true"></span>
</button>
<div class="wave right" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
</div>
<div class="call-actions">
<button id="newIntakeButton" class="primary-action" type="button">New live intake</button>
<button id="resetButton" class="secondary-action" type="button">Reset</button>
</div>
<div class="transcript-header">
<h3>Live transcript</h3>
<span id="streamingLabel">Streaming</span>
</div>
<div id="transcript" class="transcript" aria-live="polite"></div>
<form id="textForm" class="text-entry">
<input
id="textInput"
type="text"
autocomplete="off"
placeholder="Type a claimant response..."
aria-label="Type a claimant response"
/>
<button type="submit" aria-label="Send response">Send</button>
</form>
</section>
<section class="panel claim-panel">
<div class="panel-header">
<div>
<p class="panel-kicker">Claim packet live</p>
<h2>Claim state</h2>
</div>
<span id="autosave" class="status-line"><span class="dot ok"></span> Auto-saving</span>
</div>
<div id="claimFields" class="claim-sections"></div>
</section>
<section class="right-rail">
<section class="panel timeline-panel">
<div class="panel-header compact">
<div>
<p class="panel-kicker">Operator guidance</p>
<h2>Next best action</h2>
</div>
<span class="status-line"><span class="dot neutral"></span> Live</span>
</div>
<div id="timeline" class="operator-workbench" aria-live="polite"></div>
</section>
<section class="panel handoff-panel">
<div class="panel-header compact">
<div>
<p class="panel-kicker">Human handoff</p>
<h2>Adjuster packet forming</h2>
</div>
<div id="packetProgress" class="progress-ring" aria-label="Packet completion">32%</div>
</div>
<dl id="handoff" class="handoff-list"></dl>
<button id="openPacket" class="packet-button" type="button">Open packet preview</button>
</section>
</section>
</section>
</main>
</div>
<dialog id="packetDialog" class="packet-dialog">
<div class="dialog-header">
<h2>Initial Adjuster Handoff</h2>
<button id="closePacket" type="button" aria-label="Close packet">Close</button>
</div>
<pre id="packetMarkdown"></pre>
</dialog>
<script src="./app.js?v=gemini-live-clean-shell-1"></script>
</body>
</html>
@@ -0,0 +1,963 @@
"""FastAPI backend for the Insurance Claim Live Agent Team UI.
The browser sends claimant turns here. This backend calls Gemini structured
output for language-heavy extraction/classification, then runs the existing
deterministic policy gates from policies.py.
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
APP_DIR = Path(__file__).resolve().parents[1]
DEMO_DIR = Path(__file__).resolve().parent
if str(APP_DIR) not in sys.path:
sys.path.insert(0, str(APP_DIR))
from policies import ( # noqa: E402
apply_coverage_and_evidence_rules,
build_claim_intake_packet,
fraud_signal_and_safety_gate,
generate_document_checklist,
validate_required_claim_fields,
)
from schemas import ClaimClassification, ClaimNarrative # noqa: E402
MODEL = os.getenv("FNOL_GEMINI_MODEL", "gemini-3-flash-preview")
LIVE_MODEL = os.getenv("FNOL_GEMINI_LIVE_MODEL", "gemini-3.1-flash-live-preview")
GENAI_CLIENT = None
class MessageRequest(BaseModel):
session_id: str
text: str
class SessionResponse(BaseModel):
session_id: str
model: str
has_api_key: bool
state: dict[str, Any]
class AgentReply(BaseModel):
response_text: str = Field(
description=(
"Natural claimant-facing response. Ask the next needed question or "
"confirm the handoff status. Do not promise coverage, payment, or liability."
)
)
@dataclass
class IntakeSession:
session_id: str
transcript: list[dict[str, str]] = field(default_factory=list)
normalized_claim: dict[str, Any] | None = None
classification: dict[str, Any] | None = None
route: str = "needs_docs"
sessions: dict[str, IntakeSession] = {}
app = FastAPI(title="Insurance Claim Live Agent Team API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def _load_dotenv() -> None:
env_path = APP_DIR / ".env"
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
_load_dotenv()
def _has_api_key() -> bool:
return bool(os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY"))
def _client():
global GENAI_CLIENT
if not _has_api_key():
raise HTTPException(
status_code=503,
detail=(
"Missing GOOGLE_API_KEY. Add it to "
f"{APP_DIR / '.env'} and restart the live intake backend."
),
)
if os.getenv("GEMINI_API_KEY") and not os.getenv("GOOGLE_API_KEY"):
os.environ["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]
try:
from google import genai
except ImportError as exc:
raise HTTPException(
status_code=503,
detail="Missing google-genai package. Run pip install -r requirements.txt.",
) from exc
if GENAI_CLIENT is None:
GENAI_CLIENT = genai.Client()
return GENAI_CLIENT
def _blank_claim() -> dict[str, Any]:
return {
"policyholder_name": "not specified",
"policy_number": "not specified",
"contact_method": "not specified",
"date_of_loss": "not specified",
"reported_date": "not specified",
"loss_location": "not specified",
"loss_description": "not specified",
"estimated_loss_usd": None,
"injuries_or_safety_concerns": [],
"parties_involved": [],
"evidence_available": [],
"documents_mentioned": [],
"missing_or_uncertain_facts": [],
"raw_narrative_summary": "not specified",
"assumptions": [],
}
def _claim_from_session(session: IntakeSession) -> dict[str, Any]:
return session.normalized_claim or _blank_claim()
def _claimant_text(session: IntakeSession) -> str:
return "\n".join(
turn["text"] for turn in session.transcript if turn["speaker"] == "Claimant"
)
def _generate_structured(prompt: str, schema: type[BaseModel]) -> dict[str, Any]:
try:
response = _client().models.generate_content(
model=MODEL,
contents=prompt,
config={
"response_mime_type": "application/json",
"response_json_schema": schema.model_json_schema(),
},
)
return schema.model_validate_json(response.text).model_dump(exclude_none=True)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Gemini request failed: {exc}") from exc
async def _generate_structured_async(prompt: str, schema: type[BaseModel]) -> dict[str, Any]:
return await asyncio.to_thread(_generate_structured, prompt, schema)
def _transcribe_audio(audio_bytes: bytes, mime_type: str) -> str:
try:
from google.genai import types
except ImportError as exc:
raise HTTPException(
status_code=503,
detail="Missing google-genai package. Run pip install -r requirements.txt.",
) from exc
try:
response = _client().models.generate_content(
model=MODEL,
contents=[
types.Part.from_bytes(data=audio_bytes, mime_type=mime_type),
(
"Transcribe this claimant audio as plain text only. "
"Preserve names, phone numbers, policy numbers, locations, dates, "
"injuries, documents, and evidence exactly when audible."
),
],
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Gemini audio transcription failed: {exc}") from exc
text = (response.text or "").strip()
if not text:
raise HTTPException(status_code=422, detail="No speech was transcribed from the audio.")
return text
def _normalize_claim(session: IntakeSession) -> dict[str, Any]:
current = _claim_from_session(session)
prompt = f"""
You are an insurance FNOL intake extraction service.
Use the full claimant transcript to produce one complete ClaimNarrative.
Preserve known facts from the current claim state unless the transcript corrects them.
Do not invent policy numbers, contacts, dates, locations, evidence, or dollar amounts.
Use "not specified" for unknown string fields.
Current claim state:
{current}
Claimant transcript:
{_claimant_text(session)}
"""
return _generate_structured(prompt, ClaimNarrative)
def _classify_claim(
claim: dict[str, Any],
validation: dict[str, Any],
) -> dict[str, Any]:
prompt = f"""
Classify this insurance FNOL intake for operational routing.
Return a ClaimClassification only. Do not decide coverage or payment.
Normalized claim:
{claim}
Field validation:
{validation}
Severity rubric:
- low: complete, low-dollar, no injury/safety issue, routine documentation.
- medium: missing documents or moderate complexity.
- high: high estimated loss, unclear liability, missing core facts, or specialized handling likely.
- urgent: injury, unsafe living condition, emergency medical/safety concern, or time-sensitive mitigation.
"""
return _generate_structured(prompt, ClaimClassification)
def _status(value: Any, urgent: bool = False) -> str:
text = str(value or "").strip().lower()
if urgent:
return "urgent"
if text in {"", "unknown", "not specified", "unspecified", "n/a", "none", "not provided"}:
return "missing"
return "complete"
def _without_negated_safety_mentions(text: str) -> str:
cleaned = str(text or "")
for pattern in [
r"\b(?:no|not|none|without|denies|denied)\s+(?:one\s+)?(?:was\s+)?(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\b",
r"\b(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\s+(?:was|were|is|are)?\s*(?:reported\s+)?(?:no|none|not reported|denied)\b",
]:
cleaned = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE)
return cleaned
def _has_negated_safety_mention(text: str) -> bool:
return _without_negated_safety_mentions(text) != str(text or "")
def _positive_safety_items(items: list[str]) -> list[str]:
patterns = [
r"\binjur",
r"\bhurt\b",
r"\bneck pain\b",
r"\bhospital\b",
r"\burgent care\b",
r"\bambulance\b",
r"\bunsafe\b",
r"\bhazard",
r"\bdanger\b",
]
return [
item
for item in items
if any(re.search(pattern, _without_negated_safety_mentions(item), flags=re.IGNORECASE) for pattern in patterns)
]
def _join(items: list[str], fallback: str) -> str:
return ", ".join(items) if items else fallback
def _field(label: str, value: Any, source: str = "Gemini extraction", urgent: bool = False) -> dict[str, str]:
status = _status(value, urgent=urgent)
display = value if status != "missing" else f"Missing: {label.lower()}"
return {
"label": label,
"value": str(display),
"status": status,
"source": "-" if status == "missing" else source,
}
def _events(
session: IntakeSession,
validation: dict[str, Any],
coverage: dict[str, Any],
fraud_gate: dict[str, Any],
) -> list[dict[str, str]]:
events: list[dict[str, str]] = [
{
"tone": "success",
"title": "Gemini extraction complete",
"detail": f"Updated structured claim facts using {MODEL}.",
"rule": "LLM-001",
}
]
if validation.get("missing_fields"):
events.append(
{
"tone": "warning",
"title": "Missing intake facts",
"detail": ", ".join(validation["missing_fields"]),
"rule": "INTAKE-001",
}
)
for finding in coverage.get("findings", []):
tone = "danger" if finding["required_action"] == "emergency_escalation" else "warning"
if finding["required_action"] == "adjuster_review":
tone = "success"
events.append(
{
"tone": tone,
"title": finding["message"],
"detail": f"Required action: {finding['required_action']}.",
"rule": finding["rule_id"],
}
)
for signal in fraud_gate.get("signals", []):
tone = "danger" if signal.get("route_to_emergency") else "warning"
events.append(
{
"tone": tone,
"title": signal["message"],
"detail": "Deterministic fraud/safety gate signal.",
"rule": signal["signal_id"],
}
)
route = fraud_gate.get("final_routing_decision", coverage.get("routing_decision"))
if route != session.route:
events.append(
{
"tone": "danger" if route == "emergency_escalation" else "success",
"title": "Routing changed",
"detail": f"{session.route} -> {route}.",
"rule": "ROUTE-001",
}
)
return events
def _next_question(
validation: dict[str, Any],
coverage: dict[str, Any],
fraud_gate: dict[str, Any],
) -> str:
route = fraud_gate.get("final_routing_decision", coverage.get("routing_decision"))
signals = fraud_gate.get("signals", [])
if route == "emergency_escalation" and any(
signal.get("route_to_emergency") for signal in signals
):
return (
"Because you mentioned an injury or safety concern, are you and everyone "
"else currently safe, and has anyone needed emergency medical care?"
)
missing = validation.get("missing_fields", [])
question_map = {
"policyholder_name": "What is your full name as it appears on the policy?",
"contact_method": "What is the best phone number or email for the adjuster to reach you?",
"date_of_loss": "When did the loss happen?",
"loss_location": "Where did the loss happen?",
"loss_description": "Can you briefly describe what happened?",
}
for field_name in missing:
if field_name in question_map:
return question_map[field_name]
required_docs = coverage.get("required_documents", [])
if required_docs:
return f"Do you already have this document or evidence available: {required_docs[0]}?"
return "I have enough for the initial intake packet. Is there anything important the adjuster should know before handoff?"
def _agent_reply(
session: IntakeSession,
validation: dict[str, Any],
coverage: dict[str, Any],
checklist: dict[str, Any],
fraud_gate: dict[str, Any],
packet: dict[str, Any],
) -> str:
next_action = _next_question(validation, coverage, fraud_gate)
route = fraud_gate.get("final_routing_decision", coverage.get("routing_decision"))
prompt = f"""
You are the voice/text insurance FNOL intake agent speaking directly to the claimant.
Generate the next response for the claimant based on the latest turn, structured claim state,
deterministic rules, and next best action. Keep it concise, empathetic, and operational.
Hard constraints:
- Do not promise coverage, payment, liability, benefits, or claim approval.
- If injury, unsafe living condition, or immediate danger is present, prioritize safety and human review.
- Ask only one or two focused follow-up questions.
- Acknowledge facts already captured without repeating the full packet.
- Do not reveal hidden reasoning. You may reference that the intake packet was updated.
Transcript:
{session.transcript}
Normalized claim:
{session.normalized_claim}
Validation:
{validation}
Coverage/evidence rules:
{coverage}
Checklist:
{checklist}
Fraud/safety gate:
{fraud_gate}
Current routing decision:
{route}
Next best action:
{next_action}
Current handoff packet summary:
{packet.get("adjuster_handoff_summary")}
"""
reply = _generate_structured(prompt, AgentReply)
return reply["response_text"]
def _ui_state(
session: IntakeSession,
validation: dict[str, Any],
coverage: dict[str, Any],
checklist: dict[str, Any],
fraud_gate: dict[str, Any],
packet: dict[str, Any],
events: list[dict[str, str]],
) -> dict[str, Any]:
claim = ClaimNarrative.model_validate(_claim_from_session(session))
classification = ClaimClassification.model_validate(session.classification)
route = fraud_gate["final_routing_decision"]
completed = 0
def counted(field: dict[str, str]) -> dict[str, str]:
nonlocal completed
if field["status"] in {"complete", "urgent"}:
completed += 1
return field
positive_safety_items = _positive_safety_items(claim.injuries_or_safety_concerns)
safety_text = " ".join(
[
claim.loss_description,
claim.raw_narrative_summary,
_claimant_text(session),
*claim.injuries_or_safety_concerns,
]
)
injury_text = _join(claim.injuries_or_safety_concerns, "Unknown")
if not positive_safety_items and _has_negated_safety_mention(safety_text):
injury_text = "No injuries reported"
evidence_text = _join(claim.evidence_available, "Not captured yet")
docs_text = _join(claim.documents_mentioned, "Not captured yet")
required_doc_names = [item["item"] for item in checklist.get("items", [])]
fields = {
"claimant": counted(_field("Claimant name", claim.policyholder_name)),
"policy": counted(_field("Policy number", claim.policy_number)),
"contact": counted(_field("Contact method", claim.contact_method)),
"type": counted(_field("Claim type", classification.claim_type.replace("_", " "))),
"date": counted(_field("Date of loss", claim.date_of_loss)),
"time": counted(_field("Reported date", claim.reported_date)),
"location": counted(_field("Location", claim.loss_location)),
"description": counted(_field("Loss description", claim.loss_description)),
"injuries": counted(
_field(
"Injuries",
injury_text,
source="Gemini extraction + safety gate",
urgent=bool(positive_safety_items),
)
),
"hazards": counted(_field("Hazards present", _join([item for item in claim.injuries_or_safety_concerns if "hazard" in item.lower() or "unsafe" in item.lower()], "Unknown"))),
"medical": counted(_field("Medical attention", _join([item for item in claim.injuries_or_safety_concerns if "medical" in item.lower() or "care" in item.lower() or "hospital" in item.lower()], "Unknown"))),
"police": counted(_field("Report number", _find_report(claim))),
"photos": counted(_field("Evidence available", evidence_text)),
"tow": counted(_field("Tow info", _find_text(claim, ["tow", "storage"]))),
"otherDriver": counted(_field("Other driver info", _find_text(claim, ["other driver", "driver", "plate", "witness"]))),
}
progress = max(12, round(completed / len(fields) * 100))
return {
"route": route,
"progress": progress,
"fields": fields,
"transcript": session.transcript,
"events": events,
"handoff": {
"Summary": packet["adjuster_handoff_summary"],
"Priority": f"{classification.severity.title()} - {classification.severity_rationale}",
"Required actions": _join(required_doc_names, "No additional documents identified by current rules."),
"Attachments": evidence_text,
"Next best action": _next_question(validation, coverage, fraud_gate),
},
"packet_markdown": packet["markdown"],
"model": MODEL,
}
def _find_text(claim: ClaimNarrative, needles: list[str]) -> str:
text = " | ".join(
[claim.loss_description, *claim.evidence_available, *claim.documents_mentioned, *claim.parties_involved]
)
lower = text.lower()
if any(needle in lower for needle in needles):
return text
return "not specified"
def _find_report(claim: ClaimNarrative) -> str:
text = " | ".join([*claim.evidence_available, *claim.documents_mentioned, claim.loss_description])
lower = text.lower()
if any(term in lower for term in ["police", "report", "case number", "incident"]):
return text
return "not specified"
def _process(session: IntakeSession) -> dict[str, Any]:
session.normalized_claim = _normalize_claim(session)
validation = validate_required_claim_fields(session.normalized_claim)
session.classification = _classify_claim(session.normalized_claim, validation)
coverage = apply_coverage_and_evidence_rules(
session.normalized_claim,
validation,
session.classification,
)
checklist = generate_document_checklist(
session.normalized_claim,
session.classification,
coverage,
)
fraud_gate = fraud_signal_and_safety_gate(
session.normalized_claim,
validation,
session.classification,
coverage,
)
events = _events(session, validation, coverage, fraud_gate)
session.route = fraud_gate["final_routing_decision"]
packet = build_claim_intake_packet(
session.normalized_claim,
validation,
session.classification,
coverage,
checklist,
fraud_gate,
)
reply = _agent_reply(session, validation, coverage, checklist, fraud_gate, packet)
session.transcript.append({"speaker": "Agent", "text": reply})
return _ui_state(session, validation, coverage, checklist, fraud_gate, packet, events)
async def _process_live_state(session: IntakeSession) -> dict[str, Any]:
session.normalized_claim = await _generate_structured_async(
f"""
You are an insurance FNOL intake extraction service.
Use the full claimant transcript to produce one complete ClaimNarrative.
Preserve known facts from the current claim state unless the transcript corrects them.
Do not invent policy numbers, contacts, dates, locations, evidence, or dollar amounts.
Use "not specified" for unknown string fields.
Current claim state:
{_claim_from_session(session)}
Claimant transcript:
{_claimant_text(session)}
""",
ClaimNarrative,
)
validation = validate_required_claim_fields(session.normalized_claim)
session.classification = await _generate_structured_async(
f"""
Classify this insurance FNOL intake for operational routing.
Return a ClaimClassification only. Do not decide coverage or payment.
Normalized claim:
{session.normalized_claim}
Field validation:
{validation}
""",
ClaimClassification,
)
coverage = apply_coverage_and_evidence_rules(
session.normalized_claim,
validation,
session.classification,
)
checklist = generate_document_checklist(
session.normalized_claim,
session.classification,
coverage,
)
fraud_gate = fraud_signal_and_safety_gate(
session.normalized_claim,
validation,
session.classification,
coverage,
)
events = _events(session, validation, coverage, fraud_gate)
session.route = fraud_gate["final_routing_decision"]
packet = build_claim_intake_packet(
session.normalized_claim,
validation,
session.classification,
coverage,
checklist,
fraud_gate,
)
return _ui_state(session, validation, coverage, checklist, fraud_gate, packet, events)
@app.get("/api/health")
def health() -> dict[str, Any]:
return {"ok": True, "model": MODEL, "has_api_key": _has_api_key()}
@app.post("/api/sessions", response_model=SessionResponse)
def create_session() -> SessionResponse:
session = IntakeSession(session_id=str(uuid.uuid4()))
session.transcript.append(
{
"speaker": "Agent",
"text": "I can start the claim while we talk. First, are you and everyone else in a safe place?",
}
)
sessions[session.session_id] = session
validation = validate_required_claim_fields(_blank_claim())
classification = {
"claim_type": "other",
"severity": "medium",
"severity_rationale": "Waiting for claimant facts.",
"likely_policy_line": "unknown",
"loss_drivers": [],
"claimant_needs": ["Provide initial loss facts."],
}
session.normalized_claim = _blank_claim()
session.classification = classification
coverage = apply_coverage_and_evidence_rules(session.normalized_claim, validation, classification)
checklist = generate_document_checklist(session.normalized_claim, classification, coverage)
fraud_gate = fraud_signal_and_safety_gate(
session.normalized_claim, validation, classification, coverage
)
packet = build_claim_intake_packet(
session.normalized_claim,
validation,
classification,
coverage,
checklist,
fraud_gate,
)
state = _ui_state(
session,
validation,
coverage,
checklist,
fraud_gate,
packet,
[
{
"tone": "warning",
"title": "Waiting for claimant facts",
"detail": "The backend session is open and ready for Gemini extraction.",
"rule": "SESSION-001",
}
],
)
return SessionResponse(
session_id=session.session_id,
model=MODEL,
has_api_key=_has_api_key(),
state=state,
)
@app.post("/api/message", response_model=SessionResponse)
def message(request: MessageRequest) -> SessionResponse:
session = sessions.get(request.session_id)
if session is None:
raise HTTPException(status_code=404, detail="Unknown intake session.")
text = request.text.strip()
if not text:
raise HTTPException(status_code=400, detail="Message text is required.")
session.transcript.append({"speaker": "Claimant", "text": text})
state = _process(session)
return SessionResponse(
session_id=session.session_id,
model=MODEL,
has_api_key=_has_api_key(),
state=state,
)
@app.post("/api/audio", response_model=SessionResponse)
async def audio_message(
session_id: str = Form(...),
audio: UploadFile = File(...),
) -> SessionResponse:
session = sessions.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Unknown intake session.")
audio_bytes = await audio.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="Audio file is empty.")
mime_type = audio.content_type or "audio/webm"
text = _transcribe_audio(audio_bytes, mime_type)
session.transcript.append({"speaker": "Claimant", "text": text})
state = _process(session)
return SessionResponse(
session_id=session.session_id,
model=MODEL,
has_api_key=_has_api_key(),
state=state,
)
@app.websocket("/ws/live")
async def live_voice(websocket: WebSocket) -> None:
await websocket.accept()
session_id = str(uuid.uuid4())
session = IntakeSession(session_id=session_id)
session.transcript.append(
{
"speaker": "Agent",
"text": "I can start the claim while we talk. First, are you and everyone else in a safe place?",
}
)
sessions[session_id] = session
try:
from google.genai import types
except ImportError:
await websocket.send_json(
{"type": "error", "message": "Missing google-genai package. Run pip install -r requirements.txt."}
)
await websocket.close()
return
if not _has_api_key():
await websocket.send_json(
{
"type": "error",
"message": f"Missing GOOGLE_API_KEY. Add it to {APP_DIR / '.env'} and restart the backend.",
}
)
await websocket.close()
return
await websocket.send_json(
{
"type": "session",
"session_id": session_id,
"model": LIVE_MODEL,
"message": "Gemini Live voice session connected.",
}
)
config = types.LiveConnectConfig(
response_modalities=["AUDIO"],
system_instruction=(
"You are a voice insurance FNOL intake agent. Speak naturally and briefly. "
"Collect claim facts one step at a time. If injury, unsafe housing, or immediate "
"danger is mentioned, prioritize safety and human escalation. Do not promise "
"coverage, payment, liability, benefits, or approval. Ask only one or two focused "
"follow-up questions at a time."
),
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Kore")
)
),
input_audio_transcription=types.AudioTranscriptionConfig(),
output_audio_transcription=types.AudioTranscriptionConfig(),
)
state_lock = asyncio.Lock()
async def update_claim_state(text: str) -> None:
if not text.strip():
return
async with state_lock:
session.transcript.append({"speaker": "Claimant", "text": text.strip()})
try:
state = await _process_live_state(session)
await websocket.send_json({"type": "state", "state": state})
except Exception as exc:
await websocket.send_json({"type": "error", "message": f"Claim state update failed: {exc}"})
try:
async with _client().aio.live.connect(model=LIVE_MODEL, config=config) as live_session:
async def client_to_gemini() -> None:
while True:
message = await websocket.receive_json()
msg_type = message.get("type")
if msg_type == "audio":
data = base64.b64decode(message["data"])
await live_session.send_realtime_input(
audio=types.Blob(data=data, mime_type="audio/pcm;rate=16000")
)
elif msg_type == "text":
text = str(message.get("text", "")).strip()
if text:
session.transcript.append({"speaker": "Claimant", "text": text})
await live_session.send(input=text, end_of_turn=True)
state = await _process_live_state(session)
await websocket.send_json({"type": "state", "state": state})
elif msg_type == "close":
await websocket.close()
return
async def gemini_to_client() -> None:
pending_input = ""
pending_output = ""
async def finalize_input(reason: str) -> None:
nonlocal pending_input
finished = pending_input.strip()
if not finished:
return
pending_input = ""
await websocket.send_json(
{
"type": "transcript",
"speaker": "Claimant",
"text": finished,
"final": True,
"reason": reason,
}
)
asyncio.create_task(update_claim_state(finished))
async def finalize_output(reason: str) -> None:
nonlocal pending_output
finished = pending_output.strip()
if not finished:
return
pending_output = ""
session.transcript.append({"speaker": "Agent", "text": finished})
await websocket.send_json(
{
"type": "transcript",
"speaker": "Agent",
"text": finished,
"final": True,
"reason": reason,
}
)
while True:
turn = live_session.receive()
async for response in turn:
server_content = response.server_content
if not server_content:
continue
if server_content.input_transcription and server_content.input_transcription.text:
text = server_content.input_transcription.text
pending_input += text
await websocket.send_json(
{
"type": "transcript",
"speaker": "Claimant",
"text": pending_input,
"delta": text,
"final": bool(getattr(server_content.input_transcription, "finished", False)),
}
)
if getattr(server_content.input_transcription, "finished", False):
await finalize_input("input_transcription_finished")
if server_content.output_transcription and server_content.output_transcription.text:
await finalize_input("model_started_response")
text = server_content.output_transcription.text
pending_output += text
await websocket.send_json(
{
"type": "transcript",
"speaker": "Agent",
"text": pending_output,
"delta": text,
"final": bool(getattr(server_content.output_transcription, "finished", False)),
}
)
if getattr(server_content.output_transcription, "finished", False):
await finalize_output("output_transcription_finished")
if server_content.model_turn:
await finalize_input("model_audio_started")
for part in server_content.model_turn.parts or []:
if part.inline_data and isinstance(part.inline_data.data, bytes):
await websocket.send_json(
{
"type": "audio",
"data": base64.b64encode(part.inline_data.data).decode("ascii"),
"mime_type": part.inline_data.mime_type or "audio/pcm;rate=24000",
}
)
if server_content.interrupted:
pending_output = ""
await websocket.send_json({"type": "interrupted"})
if (
getattr(server_content, "generation_complete", False)
or getattr(server_content, "turn_complete", False)
or getattr(server_content, "waiting_for_input", False)
):
await finalize_output("live_turn_complete")
await asyncio.gather(client_to_gemini(), gemini_to_client())
except WebSocketDisconnect:
return
except Exception as exc:
print(f"Gemini Live session failed: {type(exc).__name__}: {exc}", flush=True)
try:
await websocket.send_json({"type": "error", "message": f"Gemini Live session failed: {exc}"})
except Exception:
pass
@app.get("/")
def index() -> FileResponse:
return FileResponse(DEMO_DIR / "index.html")
app.mount("/", StaticFiles(directory=DEMO_DIR, html=True), name="static")
@@ -0,0 +1,921 @@
:root {
--canvas: #0c0a09;
--canvas-soft: #14110f;
--panel: #1b1714;
--panel-soft: #211d19;
--panel-strong: #292524;
--ink: #f8f3ea;
--body: #d8d0c7;
--muted: #a8a29e;
--muted-soft: #78716c;
--hairline: rgba(245, 245, 244, 0.11);
--hairline-strong: rgba(245, 245, 244, 0.2);
--success: #86efac;
--success-bg: rgba(22, 163, 74, 0.14);
--warning: #f8d49b;
--warning-bg: rgba(244, 197, 168, 0.14);
--danger: #fca5a5;
--danger-bg: rgba(220, 38, 38, 0.14);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
--radius: 8px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1180px;
background:
linear-gradient(180deg, rgba(245, 245, 244, 0.04), rgba(12, 10, 9, 0) 220px),
var(--canvas);
color: var(--ink);
}
button,
input {
font: inherit;
}
button {
transition:
border-color 160ms ease,
background 160ms ease,
transform 160ms ease;
}
button:hover {
transform: translateY(-1px);
}
.app-shell {
min-height: 100vh;
}
.workspace {
min-width: 0;
}
.topbar {
height: 78px;
display: flex;
align-items: center;
gap: 24px;
padding: 0 26px;
background: rgba(12, 10, 9, 0.86);
border-bottom: 1px solid var(--hairline);
backdrop-filter: blur(20px);
}
.title-group {
display: flex;
align-items: baseline;
gap: 22px;
flex: 1;
min-width: 0;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-family: "EB Garamond", Georgia, "Times New Roman", serif;
font-size: 36px;
line-height: 1;
font-weight: 400;
letter-spacing: 0;
color: var(--ink);
}
h2 {
font-size: 17px;
line-height: 1.25;
font-weight: 500;
letter-spacing: 0;
color: var(--ink);
}
h3 {
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
color: var(--ink);
}
.live-model,
.status-line,
.state-pill {
color: var(--muted);
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.dot {
width: 7px;
height: 7px;
border-radius: 99px;
display: inline-block;
}
.dot.ok {
background: var(--success);
box-shadow: 0 0 18px rgba(134, 239, 172, 0.36);
}
.dot.neutral {
background: var(--muted-soft);
}
.route-pill,
.status-pill {
border-radius: 999px;
font-size: 12px;
font-weight: 600;
padding: 8px 12px;
text-transform: uppercase;
letter-spacing: 0;
}
.operator {
display: flex;
align-items: center;
gap: 12px;
}
.headset {
width: 22px;
height: 22px;
border: 2px solid var(--body);
border-bottom: 0;
border-radius: 14px 14px 4px 4px;
position: relative;
opacity: 0.9;
}
.headset::before,
.headset::after {
content: "";
position: absolute;
top: 10px;
width: 5px;
height: 9px;
background: var(--body);
border-radius: 3px;
}
.headset::before {
left: -4px;
}
.headset::after {
right: -4px;
}
.avatar {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--panel-strong);
border: 1px solid var(--hairline-strong);
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.dashboard {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(470px, 1.23fr) minmax(370px, 0.96fr);
gap: 16px;
padding: 18px;
height: calc(100vh - 78px);
}
.panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)), var(--panel);
border: 1px solid var(--hairline);
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 20px;
border-bottom: 1px solid var(--hairline);
}
.panel-header.compact {
padding: 18px 20px;
}
.panel-kicker {
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
}
.status-pill.active {
color: var(--success);
background: var(--success-bg);
border: 1px solid rgba(134, 239, 172, 0.24);
}
.call-panel,
.claim-panel,
.right-rail {
min-height: 0;
}
.call-panel,
.claim-panel {
display: flex;
flex-direction: column;
}
.voice-stage {
height: 214px;
display: flex;
align-items: center;
justify-content: center;
gap: 30px;
border-bottom: 1px solid var(--hairline);
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 28%, transparent 72%, rgba(255, 255, 255, 0.03)),
var(--canvas-soft);
position: relative;
overflow: hidden;
}
.voice-stage::before,
.voice-stage::after {
content: "";
position: absolute;
inset: 24px auto 24px 24px;
width: 1px;
background: var(--hairline);
}
.voice-stage::after {
left: auto;
right: 24px;
}
.mic-button {
width: 126px;
height: 126px;
border-radius: 50%;
border: 1px solid var(--hairline-strong);
outline: 14px solid rgba(245, 245, 244, 0.055);
background: var(--ink);
box-shadow:
0 0 0 28px rgba(245, 245, 244, 0.025),
0 24px 60px rgba(0, 0, 0, 0.42);
display: grid;
place-items: center;
cursor: pointer;
position: relative;
z-index: 1;
}
.mic-button.recording {
background: #f8f3ea;
animation: pulse 1.35s infinite;
}
.mic-icon {
width: 24px;
height: 38px;
border: 4px solid var(--canvas);
border-radius: 16px;
position: relative;
}
.mic-icon::after {
content: "";
position: absolute;
left: 50%;
bottom: -18px;
width: 4px;
height: 16px;
background: var(--canvas);
transform: translateX(-50%);
}
.mic-icon::before {
content: "";
position: absolute;
left: 50%;
bottom: -24px;
width: 30px;
height: 4px;
background: var(--canvas);
border-radius: 4px;
transform: translateX(-50%);
}
@keyframes pulse {
0% {
box-shadow:
0 0 0 28px rgba(245, 245, 244, 0.025),
0 0 0 0 rgba(248, 243, 234, 0.18);
}
100% {
box-shadow:
0 0 0 28px rgba(245, 245, 244, 0.025),
0 0 0 24px rgba(248, 243, 234, 0);
}
}
.wave {
display: flex;
gap: 5px;
align-items: center;
height: 58px;
}
.wave span {
width: 3px;
border-radius: 4px;
background: rgba(248, 243, 234, 0.72);
animation: wave 1s ease-in-out infinite alternate;
}
.wave span:nth-child(1) { height: 10px; }
.wave span:nth-child(2) { height: 22px; animation-delay: 0.1s; }
.wave span:nth-child(3) { height: 36px; animation-delay: 0.2s; }
.wave span:nth-child(4) { height: 54px; animation-delay: 0.3s; }
.wave span:nth-child(5) { height: 28px; animation-delay: 0.4s; }
.wave span:nth-child(6) { height: 14px; animation-delay: 0.5s; }
@keyframes wave {
to {
transform: scaleY(0.48);
opacity: 0.48;
}
}
.call-actions {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 16px 20px 0;
}
.primary-action,
.secondary-action,
.packet-button,
.text-entry button,
.dialog-header button {
border: 1px solid var(--hairline-strong);
border-radius: 999px;
background: transparent;
color: var(--ink);
font-size: 13px;
font-weight: 600;
padding: 11px 16px;
cursor: pointer;
}
.primary-action,
.text-entry button,
.packet-button {
background: var(--ink);
color: var(--canvas);
border-color: var(--ink);
}
.secondary-action,
.dialog-header button {
background: rgba(255, 255, 255, 0.03);
}
.transcript-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 10px;
}
.transcript-header span {
color: var(--muted);
font-size: 12px;
}
.transcript {
flex: 1;
min-height: 230px;
overflow: auto;
padding: 0 20px 12px;
}
.turn {
display: grid;
grid-template-columns: 32px 1fr;
gap: 11px;
margin-bottom: 12px;
}
.speaker-icon {
width: 29px;
height: 29px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--panel-strong);
border: 1px solid var(--hairline-strong);
color: var(--ink);
font-size: 11px;
font-weight: 700;
}
.turn.agent .speaker-icon {
background: rgba(245, 245, 244, 0.1);
}
.bubble {
border-radius: 8px;
background: rgba(245, 245, 244, 0.04);
padding: 12px 13px;
border: 1px solid var(--hairline);
}
.bubble strong {
font-size: 12px;
margin-right: 8px;
color: var(--ink);
}
.bubble time {
color: var(--muted-soft);
font-size: 11px;
}
.bubble p {
margin-top: 8px;
color: var(--body);
font-size: 14px;
line-height: 1.48;
overflow-wrap: anywhere;
}
.highlight {
color: var(--danger);
background: var(--danger-bg);
border-radius: 4px;
padding: 1px 3px;
}
.text-entry {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding: 16px 20px 20px;
}
.text-entry input {
height: 44px;
border: 1px solid var(--hairline-strong);
border-radius: 999px;
padding: 0 16px;
color: var(--ink);
background: rgba(255, 255, 255, 0.035);
outline: none;
}
.text-entry input::placeholder {
color: var(--muted-soft);
}
.text-entry input:focus {
border-color: var(--ink);
box-shadow: 0 0 0 3px rgba(248, 243, 234, 0.08);
}
.claim-sections {
overflow: auto;
padding: 0 20px 20px;
}
.field-group {
padding: 19px 0 17px;
border-bottom: 1px solid var(--hairline);
}
.field-group:last-child {
border-bottom: 0;
}
.group-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 13px;
}
.group-title h3 {
font-size: 15px;
}
.field-row {
display: grid;
grid-template-columns: minmax(120px, 0.72fr) minmax(180px, 1.12fr) minmax(120px, 0.86fr);
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.field-label {
color: var(--body);
font-size: 13px;
}
.field-value,
.field-source {
min-height: 36px;
display: flex;
align-items: center;
border: 1px solid var(--hairline);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.25;
background: rgba(255, 255, 255, 0.035);
color: var(--body);
}
.field-source {
color: var(--muted);
background: rgba(255, 255, 255, 0.025);
}
.field-value.complete {
color: var(--success);
border-color: rgba(134, 239, 172, 0.24);
background: var(--success-bg);
}
.field-value.pending,
.field-value.missing {
color: var(--warning);
border-color: rgba(248, 212, 155, 0.24);
background: var(--warning-bg);
}
.field-value.urgent {
color: var(--danger);
border-color: rgba(252, 165, 165, 0.26);
background: var(--danger-bg);
}
.right-rail {
display: grid;
grid-template-rows: minmax(380px, 1fr) minmax(260px, 0.78fr);
gap: 16px;
min-height: 0;
}
.timeline-panel,
.handoff-panel {
min-height: 0;
overflow: hidden;
}
.operator-workbench {
overflow: auto;
height: calc(100% - 73px);
padding: 16px 20px 20px;
}
.decision-card,
.operator-card,
.audit-details {
border: 1px solid var(--hairline);
border-radius: 8px;
background: rgba(245, 245, 244, 0.04);
padding: 15px;
margin-bottom: 12px;
}
.decision-card {
border-color: rgba(248, 212, 155, 0.23);
background: var(--warning-bg);
}
.decision-card.success {
border-color: rgba(134, 239, 172, 0.24);
background: var(--success-bg);
}
.decision-card.danger {
border-color: rgba(252, 165, 165, 0.26);
background: var(--danger-bg);
}
.decision-label,
.operator-card-label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
}
.decision-card strong {
display: block;
margin-top: 8px;
font-family: "EB Garamond", Georgia, "Times New Roman", serif;
font-size: 28px;
font-weight: 400;
line-height: 1.05;
color: var(--ink);
}
.decision-card p,
.operator-card p {
margin-top: 9px;
color: var(--body);
font-size: 13px;
line-height: 1.45;
}
.ask-card p {
font-size: 16px;
line-height: 1.4;
color: var(--ink);
}
.missing-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.missing-chips span {
display: inline-flex;
align-items: center;
min-height: 30px;
border-radius: 999px;
border: 1px solid rgba(248, 212, 155, 0.24);
background: rgba(248, 212, 155, 0.1);
color: var(--warning);
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
}
.readiness-meter {
height: 9px;
overflow: hidden;
border-radius: 999px;
background: rgba(245, 245, 244, 0.09);
margin-top: 12px;
}
.readiness-meter div {
height: 100%;
border-radius: inherit;
background: var(--ink);
}
.readiness-copy {
color: var(--muted);
}
.audit-details {
background: rgba(245, 245, 244, 0.025);
}
.audit-details summary {
cursor: pointer;
color: var(--body);
font-size: 13px;
font-weight: 600;
}
.audit-list {
margin-top: 14px;
}
.event {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 10px;
padding: 0 0 17px;
position: relative;
}
.event::before {
content: "";
position: absolute;
left: 80px;
top: 17px;
bottom: 0;
width: 1px;
background: var(--hairline);
}
.event:last-child::before {
display: none;
}
.event-time {
color: var(--muted-soft);
font-size: 12px;
}
.event-body {
position: relative;
padding-left: 18px;
}
.event-body::before {
content: "";
position: absolute;
left: -2px;
top: 4px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
border: 2px solid var(--panel);
box-shadow: 0 0 0 1px var(--hairline-strong);
}
.event.warning .event-body::before { background: var(--warning); }
.event.danger .event-body::before { background: var(--danger); }
.event.success .event-body::before { background: var(--success); }
.event-title {
color: var(--ink);
font-size: 13px;
font-weight: 600;
line-height: 1.25;
}
.event-detail {
color: var(--muted);
font-size: 12px;
line-height: 1.38;
margin-top: 4px;
overflow-wrap: anywhere;
}
.rule-id {
color: var(--muted-soft);
font-size: 11px;
font-weight: 700;
}
.handoff-list {
margin: 0;
padding: 12px 20px 10px;
overflow: auto;
max-height: calc(100% - 124px);
}
.handoff-row {
display: grid;
grid-template-columns: 112px 1fr;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--hairline);
}
.handoff-row dt {
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.handoff-row dd {
margin: 0;
color: var(--body);
font-size: 12px;
line-height: 1.45;
}
.progress-ring {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 5px solid rgba(245, 245, 244, 0.1);
border-right-color: var(--ink);
border-radius: 50%;
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.packet-button {
width: calc(100% - 40px);
margin: 0 20px 20px;
}
.packet-dialog {
width: min(860px, calc(100vw - 48px));
border: 1px solid var(--hairline-strong);
border-radius: 8px;
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.62);
padding: 0;
background: var(--panel);
color: var(--ink);
}
.packet-dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px;
border-bottom: 1px solid var(--hairline);
}
.packet-dialog pre {
white-space: pre-wrap;
margin: 0;
padding: 20px;
color: var(--body);
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
max-height: 68vh;
overflow: auto;
}
@media (max-width: 980px) {
body {
min-width: 0;
}
.topbar {
height: auto;
min-height: 72px;
flex-wrap: wrap;
padding: 16px;
}
.title-group {
min-width: 0;
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
h1 {
font-size: 32px;
}
.dashboard {
grid-template-columns: 1fr;
height: auto;
padding: 12px;
}
.right-rail {
grid-template-rows: auto auto;
}
.call-panel,
.claim-panel,
.timeline-panel,
.handoff-panel {
min-height: 520px;
}
.field-row {
grid-template-columns: 1fr;
gap: 6px;
margin-bottom: 14px;
}
.field-value,
.field-source {
width: 100%;
}
.handoff-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
@@ -0,0 +1,660 @@
"""Deterministic policy, evidence, routing, and packet builders."""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
try:
from .schemas import (
ClaimClassification,
ClaimIntakePacket,
ClaimNarrative,
CoverageEvidenceDecision,
DocumentChecklist,
DocumentChecklistItem,
EvidenceRuleFinding,
FieldValidation,
FraudSafetyGate,
FraudSafetySignal,
)
except ImportError:
from schemas import (
ClaimClassification,
ClaimIntakePacket,
ClaimNarrative,
CoverageEvidenceDecision,
DocumentChecklist,
DocumentChecklistItem,
EvidenceRuleFinding,
FieldValidation,
FraudSafetyGate,
FraudSafetySignal,
)
SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "urgent": 3}
TYPE_REQUIRED_DOCS: dict[str, list[tuple[str, str]]] = {
"home_water_damage": [
("Photos or video of damaged areas before cleanup", "Documents the loss condition."),
("Mitigation or drying invoice", "Shows steps taken to prevent additional damage."),
("Repair estimate or contractor assessment", "Supports the estimated repair cost."),
("Receipts for damaged personal property", "Supports contents reimbursement."),
],
"auto_collision": [
("Photos of vehicles and scene", "Documents impact location and visible damage."),
("Police report or incident exchange form", "Confirms crash facts and involved parties."),
("Repair estimate or tow/storage invoice", "Supports vehicle damage costs."),
("Medical documentation for any injuries", "Supports injury-related escalation and benefits."),
("Other driver and witness information", "Helps liability review."),
],
"theft_property_loss": [
("Police report number or theft report", "Most theft claims require a filed report."),
("Receipts, serial numbers, or ownership proof", "Supports ownership and value."),
("Photos of the item or packaging if available", "Helps item identification."),
("Location timeline and access details", "Helps verify circumstances of loss."),
],
"health_medical_reimbursement": [
("Itemized provider bill", "Shows services, dates, and charged amounts."),
("Explanation of benefits or denial notice", "Shows what was paid or denied."),
("Proof of payment", "Supports reimbursement amount."),
("Provider name and diagnosis or treatment summary", "Supports eligibility review."),
],
"travel_delay_cancellation": [
("Carrier cancellation or delay notice", "Confirms the covered travel disruption."),
("Original itinerary and booking confirmation", "Shows trip dates and route."),
("Receipts for prepaid nonrefundable expenses", "Supports claimed loss amount."),
("Refund, voucher, or credit documentation", "Prevents duplicate recovery."),
("Weather, emergency, or event documentation if available", "Supports cause of disruption."),
],
"other": [
("Photos or available proof of loss", "Documents what happened."),
("Receipts, estimates, or invoices", "Supports the claimed amount."),
("Any third-party report or confirmation", "Helps verify loss facts."),
],
}
def _as_model(model_type, value):
if isinstance(value, model_type):
return value
if value is None:
return model_type()
if isinstance(value, str):
return model_type.model_validate_json(value)
return model_type.model_validate(value)
def _blank(value: Any) -> bool:
text = str(value or "").strip().lower()
return text in {"", "unknown", "not specified", "unspecified", "n/a", "none", "not provided"}
def _dedupe(items: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for item in items:
normalized = item.strip()
key = normalized.lower()
if normalized and key not in seen:
seen.add(key)
result.append(normalized)
return result
def _all_evidence_text(claim: ClaimNarrative) -> str:
fields = [
claim.loss_description,
claim.raw_narrative_summary,
" ".join(claim.evidence_available),
" ".join(claim.documents_mentioned),
]
return "\n".join(fields).lower()
def _has_any(text: str, patterns: list[str]) -> bool:
return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns)
def _without_negated_safety_mentions(text: str) -> str:
"""Remove phrases like "no injuries" before positive safety regex checks."""
negated_patterns = [
r"\b(?:no|not|none|without|denies|denied)\s+(?:one\s+)?(?:was\s+)?(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\b",
r"\b(?:injur\w*|hurt|pain|medical attention|ambulance|hospital|unsafe|hazard\w*|danger)\s+(?:was|were|is|are)?\s*(?:reported\s+)?(?:no|none|not reported|denied)\b",
]
cleaned = text
for pattern in negated_patterns:
cleaned = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE)
return cleaned
def _positive_safety_concerns(claim: ClaimNarrative) -> list[str]:
return [item for item in claim.injuries_or_safety_concerns if _has_positive_safety_language(item)]
def _has_positive_safety_language(text: str) -> bool:
cleaned = _without_negated_safety_mentions(text)
return _has_any(
cleaned,
[r"\binjur", r"\bhurt\b", r"\bneck pain\b", r"\bhospital\b", r"\burgent care\b", r"\bambulance\b"],
)
def _document_provided(document: str, claim: ClaimNarrative) -> bool:
evidence = _all_evidence_text(claim)
doc = document.lower()
keyword_groups = [
["photo", "picture", "video"],
["police", "report number", "incident report"],
["receipt", "invoice", "proof of payment", "credit card"],
["estimate", "contractor", "repair"],
["medical", "urgent care", "hospital", "provider", "bill"],
["airline", "carrier", "cancellation", "delay notice"],
["itinerary", "booking", "confirmation"],
["serial", "ownership", "purchase"],
["tow", "storage"],
]
for keywords in keyword_groups:
if any(keyword in doc for keyword in keywords):
return any(keyword in evidence for keyword in keywords)
return any(word in evidence for word in doc.split()[:3])
def _parse_date(value: str) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
cleaned = re.sub(r"(\d+)(st|nd|rd|th)", r"\1", text, flags=re.IGNORECASE)
candidates = [cleaned[:10], cleaned]
formats = [
"%Y-%m-%d",
"%m/%d/%Y",
"%m-%d-%Y",
"%B %d, %Y",
"%b %d, %Y",
"%B %d %Y",
"%b %d %Y",
]
for candidate in candidates:
for fmt in formats:
try:
return datetime.strptime(candidate, fmt)
except ValueError:
continue
return None
def validate_required_claim_fields(claim_value: Any) -> dict[str, Any]:
"""Validate minimum intake facts before coverage and evidence rules run."""
claim = _as_model(ClaimNarrative, claim_value)
missing: list[str] = []
warnings: list[str] = []
required_fields = {
"policyholder_name": claim.policyholder_name,
"contact_method": claim.contact_method,
"date_of_loss": claim.date_of_loss,
"loss_location": claim.loss_location,
"loss_description": claim.loss_description,
}
for field_name, value in required_fields.items():
if _blank(value):
missing.append(field_name)
if _blank(claim.policy_number):
warnings.append("Policy number was not supplied; adjuster may need identity verification.")
if claim.estimated_loss_usd is None:
warnings.append("Estimated loss amount was not supplied.")
missing.extend(claim.missing_or_uncertain_facts)
missing = _dedupe(missing)
validation = FieldValidation(
intake_status="missing_info" if missing else "valid",
missing_fields=missing,
warnings=_dedupe(warnings),
ready_for_policy_review=not missing,
)
return validation.model_dump(exclude_none=True)
def apply_coverage_and_evidence_rules(
claim_value: Any,
validation_value: Any,
classification_value: Any,
) -> dict[str, Any]:
"""Apply deterministic coverage, evidence, and first-pass routing rules."""
claim = _as_model(ClaimNarrative, claim_value)
validation = _as_model(FieldValidation, validation_value)
classification = _as_model(ClaimClassification, classification_value)
findings: list[EvidenceRuleFinding] = []
coverage_notes: list[str] = []
required_docs: list[str] = []
evidence_text = _all_evidence_text(claim)
def add(
rule_id: str,
severity: str,
message: str,
required_action: str,
document: str | None = None,
) -> None:
findings.append(
EvidenceRuleFinding(
rule_id=rule_id,
severity=severity,
message=message,
required_action=required_action,
document=document,
)
)
if document:
required_docs.append(document)
if validation.missing_fields:
add(
"INTAKE-001",
"medium",
"Required intake facts are missing before the claim can be fully assigned.",
"collect_info",
)
for document, reason in TYPE_REQUIRED_DOCS.get(classification.claim_type, TYPE_REQUIRED_DOCS["other"]):
if not _document_provided(document, claim):
add(
"DOC-001",
"medium",
f"Missing or unconfirmed document: {document}. {reason}",
"collect_document",
document,
)
if claim.estimated_loss_usd is not None and claim.estimated_loss_usd >= 25000:
add(
"LOSS-001",
"high",
"Estimated loss is high enough to require prompt human adjuster review.",
"adjuster_review",
)
if classification.claim_type == "home_water_damage":
coverage_notes.extend(
[
"Water damage review usually turns on source of water, suddenness, mitigation, exclusions, and whether flood coverage is separate.",
"Do not promise coverage until policy forms, endorsements, cause of loss, and mitigation facts are reviewed.",
]
)
if _has_any(evidence_text, [r"\bunsafe\b", r"\belectrical\b", r"\bsewage\b", r"\bmold\b", r"\bno place to live\b"]):
add(
"SAFE-001",
"urgent",
"Unsafe living condition or potential health hazard was mentioned.",
"emergency_escalation",
)
elif classification.claim_type == "auto_collision":
coverage_notes.extend(
[
"Auto collision review usually depends on liability facts, coverage type, deductibles, police report, photos, and damage estimate.",
"Injury claims require immediate human handling and no medical coverage promises in the intake response.",
]
)
if _positive_safety_concerns(claim) or _has_positive_safety_language(evidence_text):
add("SAFE-002", "urgent", "Injury or medical attention was mentioned.", "emergency_escalation")
elif classification.claim_type == "theft_property_loss":
coverage_notes.extend(
[
"Theft/property loss review usually requires proof of ownership, loss location, access details, and a police or incident report.",
"High-value electronics or jewelry may have sublimits or scheduled-property requirements.",
]
)
if not _has_any(evidence_text, [r"\bpolice\b", r"\breport number\b", r"\bcase number\b"]):
add(
"THEFT-001",
"medium",
"Theft claim lacks a police or incident report.",
"collect_document",
"Police report number or theft report",
)
elif classification.claim_type == "health_medical_reimbursement":
coverage_notes.extend(
[
"Educational/demo note: medical reimbursement review depends on plan terms, eligibility, dates of service, itemized bills, EOBs, and proof of payment.",
"This app does not provide medical, benefits, or coverage advice.",
]
)
elif classification.claim_type == "travel_delay_cancellation":
coverage_notes.extend(
[
"Travel claim review usually depends on covered reason, carrier confirmation, trip dates, nonrefundable amounts, and refunds or credits received.",
"Weather-related disruption may require carrier notices and documentation of unused prepaid expenses.",
]
)
else:
coverage_notes.append(
"Claim type is unclear; route for human triage after collecting minimum loss facts and proof of loss."
)
if any(f.required_action == "emergency_escalation" for f in findings):
route = "emergency_escalation"
elif any(f.required_action == "siu_review" for f in findings):
route = "special_investigation"
elif validation.missing_fields or any(f.required_action == "collect_document" for f in findings):
route = "needs_docs"
else:
route = "ready_for_adjuster"
decision = CoverageEvidenceDecision(
routing_decision=route,
provisional_coverage_considerations=_dedupe(coverage_notes),
required_documents=_dedupe(required_docs),
findings=findings,
audit_trail=[
"Validated minimum claim intake fields.",
f"Classified claim as {classification.claim_type} with {classification.severity} severity.",
"Applied deterministic evidence, document, high-loss, injury, and safety routing gates.",
f"Initial route selected: {route}.",
],
)
return decision.model_dump(exclude_none=True)
def generate_document_checklist(
claim_value: Any,
classification_value: Any,
evidence_decision_value: Any,
) -> dict[str, Any]:
"""Generate a claimant-facing checklist from deterministic document rules."""
claim = _as_model(ClaimNarrative, claim_value)
classification = _as_model(ClaimClassification, classification_value)
evidence_decision = _as_model(CoverageEvidenceDecision, evidence_decision_value)
required = set(evidence_decision.required_documents)
items: list[DocumentChecklistItem] = []
for document, reason in TYPE_REQUIRED_DOCS.get(classification.claim_type, TYPE_REQUIRED_DOCS["other"]):
provided = _document_provided(document, claim)
priority = "required" if document in required else "recommended"
items.append(
DocumentChecklistItem(
item=document,
reason=reason,
priority=priority,
already_provided=provided,
)
)
if classification.claim_type == "auto_collision" and _positive_safety_concerns(claim):
items.append(
DocumentChecklistItem(
item="Names of injured people and treatment locations",
reason="Supports urgent injury claim assignment.",
priority="required",
already_provided=_has_any(_all_evidence_text(claim), [r"\burgent care\b", r"\bhospital\b"]),
)
)
checklist = DocumentChecklist(
items=items,
claimant_tip=(
"Upload clear copies when available. If a document is not available yet, explain why "
"and provide the expected date."
),
)
return checklist.model_dump(exclude_none=True)
def fraud_signal_and_safety_gate(
claim_value: Any,
validation_value: Any,
classification_value: Any,
evidence_decision_value: Any,
) -> dict[str, Any]:
"""Apply deterministic SIU, fraud-pattern, timing, and safety gates."""
claim = _as_model(ClaimNarrative, claim_value)
validation = _as_model(FieldValidation, validation_value)
classification = _as_model(ClaimClassification, classification_value)
evidence_decision = _as_model(CoverageEvidenceDecision, evidence_decision_value)
signals: list[FraudSafetySignal] = []
evidence_text = _all_evidence_text(claim)
def signal(
signal_id: str,
severity: str,
message: str,
route_to_siu: bool = False,
route_to_emergency: bool = False,
) -> None:
signals.append(
FraudSafetySignal(
signal_id=signal_id,
severity=severity,
message=message,
route_to_siu=route_to_siu,
route_to_emergency=route_to_emergency,
)
)
loss_date = _parse_date(claim.date_of_loss)
report_date = _parse_date(claim.reported_date)
if loss_date and report_date and report_date < loss_date:
signal(
"TIMING-001",
"high",
"Reported date appears to be before the loss date.",
route_to_siu=True,
)
if loss_date and report_date and (report_date - loss_date).days > 90:
signal(
"TIMING-002",
"medium",
"Claim appears to be reported more than 90 days after the loss.",
route_to_siu=True,
)
if _has_any(evidence_text, [r"\bnot sure\b", r"\bdon'?t remember\b", r"\bmaybe\b", r"\bexact date\b"]):
signal(
"FACTS-001",
"medium",
"Narrative contains uncertain or vague key facts that need follow-up.",
route_to_siu=False,
)
if (
claim.estimated_loss_usd is not None
and claim.estimated_loss_usd >= 10000
and not claim.evidence_available
and not claim.documents_mentioned
):
signal(
"EVID-001",
"high",
"High estimated loss was submitted without supporting evidence.",
route_to_siu=True,
)
if classification.claim_type == "theft_property_loss" and _has_any(
evidence_text,
[r"\bno police\b", r"\bhave not filed\b", r"\bdidn'?t file\b"],
):
signal(
"THEFT-002",
"medium",
"Theft claim states that no police report has been filed yet.",
route_to_siu=False,
)
if evidence_decision.routing_decision == "emergency_escalation" or any(
f.required_action == "emergency_escalation" for f in evidence_decision.findings
):
signal(
"SAFETY-001",
"urgent",
"Safety, injury, or habitability issue requires immediate human review.",
route_to_emergency=True,
)
if any(item in validation.missing_fields for item in ["date_of_loss", "loss_location", "loss_description"]):
signal(
"INTAKE-002",
"medium",
"Core loss facts are missing, so routing should remain follow-up oriented.",
)
if any(s.route_to_emergency for s in signals):
final_route = "emergency_escalation"
elif any(s.route_to_siu for s in signals):
final_route = "special_investigation"
else:
final_route = evidence_decision.routing_decision
gate = FraudSafetyGate(
final_routing_decision=final_route,
signals=signals,
audit_trail=evidence_decision.audit_trail
+ [
"Applied deterministic fraud, suspicious timing, vague facts, and safety gates.",
f"Final route selected: {final_route}.",
],
)
return gate.model_dump(exclude_none=True)
def build_claim_intake_packet(
claim_value: Any,
validation_value: Any,
classification_value: Any,
evidence_decision_value: Any,
checklist_value: Any,
fraud_gate_value: Any,
) -> dict[str, Any]:
"""Build the final Markdown claim intake packet for ADK Web."""
claim = _as_model(ClaimNarrative, claim_value)
validation = _as_model(FieldValidation, validation_value)
classification = _as_model(ClaimClassification, classification_value)
evidence_decision = _as_model(CoverageEvidenceDecision, evidence_decision_value)
checklist = _as_model(DocumentChecklist, checklist_value)
fraud_gate = _as_model(FraudSafetyGate, fraud_gate_value)
missing = _dedupe(validation.missing_fields)
route = fraud_gate.final_routing_decision
route_label = route.replace("_", " ").title()
checklist_lines = []
for item in checklist.items:
status = "already provided" if item.already_provided else item.priority
checklist_lines.append(f"- [{status}] **{item.item}** - {item.reason}")
if not checklist_lines:
checklist_lines.append("- No additional documents identified by current rules.")
missing_lines = [f"- {field}" for field in missing] or ["- No required intake fields are missing."]
coverage_lines = [
f"- {note}" for note in evidence_decision.provisional_coverage_considerations
] or ["- Coverage review requires policy forms, endorsements, loss facts, and adjuster analysis."]
signal_lines = [
f"- `{signal.signal_id}` [{signal.severity}] {signal.message}"
for signal in fraud_gate.signals
] or ["- No deterministic fraud or emergency signal was triggered."]
finding_lines = [
f"- `{finding.rule_id}` [{finding.severity}] {finding.message}"
for finding in evidence_decision.findings
] or ["- No deterministic evidence findings were generated."]
handoff = (
f"{claim.policyholder_name or 'Unknown claimant'} reported a "
f"{classification.claim_type.replace('_', ' ')} loss at {claim.loss_location or 'an unknown location'} "
f"on {claim.date_of_loss or 'an unknown date'}. "
f"Summary: {claim.raw_narrative_summary or claim.loss_description}. "
f"Estimated loss: {claim.estimated_loss_usd if claim.estimated_loss_usd is not None else 'not supplied'}."
)
if route == "emergency_escalation":
claimant_next = (
"Your claim mentions injury, safety, or habitability concerns. A human representative "
"should review this immediately. If anyone is in danger, contact local emergency services first."
)
elif route == "special_investigation":
claimant_next = (
"We can open the intake packet, but some timing or evidence details require specialized "
"human review. Please provide the requested documents and keep originals available."
)
elif route == "needs_docs":
claimant_next = (
"Your intake packet is started. Please provide the missing information and checklist items "
"so an adjuster can evaluate the claim without delay."
)
else:
claimant_next = (
"Your intake packet has the core information needed for adjuster assignment. Keep copies of "
"all receipts, photos, reports, and communications related to the loss."
)
audit_lines = [f"{idx}. {entry}" for idx, entry in enumerate(fraud_gate.audit_trail, start=1)]
markdown = f"""# Insurance Claim Intake Packet
**Claim type:** {classification.claim_type.replace("_", " ").title()}
**Intake status:** {validation.intake_status.replace("_", " ").title()}
**Severity:** {classification.severity.title()}
**Routing decision:** {route_label}
## Missing Information
{chr(10).join(missing_lines)}
## Required Documents Checklist
{chr(10).join(checklist_lines)}
## Coverage Considerations and Disclaimer
{chr(10).join(coverage_lines)}
This is an intake triage packet for demo and educational use. It does not confirm coverage, benefits, liability, payment, or legal rights. A licensed adjuster or qualified human reviewer must evaluate the applicable policy, endorsements, exclusions, deductibles, documentation, and governing law.
## Adjuster Handoff Summary
{handoff}
## Claimant-Friendly Next Message
{claimant_next}
## Deterministic Findings
{chr(10).join(finding_lines)}
## Fraud, Timing, and Safety Signals
{chr(10).join(signal_lines)}
## Audit Trail
{chr(10).join(audit_lines)}
"""
packet = ClaimIntakePacket(
claim_type=classification.claim_type,
intake_status=validation.intake_status,
severity=classification.severity,
routing_decision=route,
missing_information=missing,
required_documents=checklist.items,
coverage_considerations=evidence_decision.provisional_coverage_considerations,
adjuster_handoff_summary=handoff,
claimant_next_message=claimant_next,
audit_trail=fraud_gate.audit_trail,
markdown=markdown,
)
return packet.model_dump(exclude_none=True)
@@ -0,0 +1,7 @@
google-adk>=2.0.0a1
google-genai>=1.0.0
fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30
pydantic>=2.7
typing-extensions>=4.12
@@ -0,0 +1,145 @@
"""Structured data contracts for the insurance claim intake workflow."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field
ClaimType = Literal[
"home_water_damage",
"auto_collision",
"theft_property_loss",
"health_medical_reimbursement",
"travel_delay_cancellation",
"other",
]
Severity = Literal["low", "medium", "high", "urgent"]
IntakeStatus = Literal["valid", "missing_info"]
RoutingDecision = Literal[
"ready_for_adjuster",
"needs_docs",
"special_investigation",
"emergency_escalation",
]
class ClaimNarrative(BaseModel):
"""Normalized facts extracted from a messy claim narrative."""
policyholder_name: str = Field(description="Name of the policyholder or claimant.")
policy_number: str = Field(description="Policy or member number if supplied.")
contact_method: str = Field(description="Best available phone, email, or mailing contact.")
date_of_loss: str = Field(description="Date or date range when the loss occurred.")
reported_date: str = Field(description="Date the claimant says they are reporting, if supplied.")
loss_location: str = Field(description="City, address, intersection, facility, or travel route.")
loss_description: str = Field(description="Plain-language description of what happened.")
estimated_loss_usd: Optional[float] = Field(
default=None,
description="Estimated financial loss in USD when supplied.",
)
injuries_or_safety_concerns: list[str] = Field(default_factory=list)
parties_involved: list[str] = Field(default_factory=list)
evidence_available: list[str] = Field(default_factory=list)
documents_mentioned: list[str] = Field(default_factory=list)
missing_or_uncertain_facts: list[str] = Field(default_factory=list)
raw_narrative_summary: str = Field(description="Short factual summary of the source narrative.")
assumptions: list[str] = Field(default_factory=list)
class FieldValidation(BaseModel):
"""Deterministic validation of minimum claim intake information."""
intake_status: IntakeStatus
missing_fields: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
ready_for_policy_review: bool
class ClaimClassification(BaseModel):
"""LLM classification of claim type and operational severity."""
claim_type: ClaimType
severity: Severity
severity_rationale: str
likely_policy_line: str
loss_drivers: list[str] = Field(default_factory=list)
claimant_needs: list[str] = Field(default_factory=list)
class EvidenceRuleFinding(BaseModel):
"""Deterministic finding generated by coverage, evidence, or routing rules."""
rule_id: str
severity: Severity
message: str
required_action: Literal[
"collect_info",
"collect_document",
"adjuster_review",
"siu_review",
"emergency_escalation",
]
document: Optional[str] = None
class CoverageEvidenceDecision(BaseModel):
"""Deterministic routing output after coverage and evidence gates."""
routing_decision: RoutingDecision
provisional_coverage_considerations: list[str] = Field(default_factory=list)
required_documents: list[str] = Field(default_factory=list)
findings: list[EvidenceRuleFinding] = Field(default_factory=list)
audit_trail: list[str] = Field(default_factory=list)
class DocumentChecklistItem(BaseModel):
"""One claimant-facing checklist item."""
item: str
reason: str
priority: Literal["required", "recommended", "conditional"]
already_provided: bool = False
class DocumentChecklist(BaseModel):
"""Generated document checklist for the claim packet."""
items: list[DocumentChecklistItem] = Field(default_factory=list)
claimant_tip: str
class FraudSafetySignal(BaseModel):
"""Deterministic SIU, fraud-pattern, and safety signal."""
signal_id: str
severity: Severity
message: str
route_to_siu: bool = False
route_to_emergency: bool = False
class FraudSafetyGate(BaseModel):
"""Final deterministic safety and fraud routing gate."""
final_routing_decision: RoutingDecision
signals: list[FraudSafetySignal] = Field(default_factory=list)
audit_trail: list[str] = Field(default_factory=list)
class ClaimIntakePacket(BaseModel):
"""Final polished packet returned to ADK Web."""
claim_type: ClaimType
intake_status: IntakeStatus
severity: Severity
routing_decision: RoutingDecision
missing_information: list[str] = Field(default_factory=list)
required_documents: list[DocumentChecklistItem] = Field(default_factory=list)
coverage_considerations: list[str] = Field(default_factory=list)
adjuster_handoff_summary: str
claimant_next_message: str
audit_trail: list[str] = Field(default_factory=list)
markdown: str