mirror of
https://github.com/Shubhamsaboo/awesome-llm-apps.git
synced 2026-08-28 19:11:53 +08:00
feat: add insurance claim live agent team
This commit is contained in:
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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"]
|
||||
BIN
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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
BIN
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
|
||||
Reference in New Issue
Block a user