Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
269ef61
feat(data): synthetic auto-claim generator with ground-truth labels
AttiR Jun 26, 2026
20bc301
fix merge conflicts
AttiR Jun 26, 2026
9d2dc93
fixes langfuse version
AttiR Jun 26, 2026
051b3be
Merge pull request #6 from AttiR/feat/phase-1-data-generator
AttiR Jun 26, 2026
68f1d93
feat(index): multimodal embeddings + pinecone store
AttiR Jun 27, 2026
8ef5c02
fix(ci): lowercase workflow extension so GitHub Actions detects it
AttiR Jun 27, 2026
9585c3f
style: fix ruff lint errors across data generator and services
AttiR Jun 27, 2026
0034ae5
ci: make pytest runnable in CI
AttiR Jun 27, 2026
1256089
ci: install deps and make pytest runnable; fix ruff lints
AttiR Jun 27, 2026
6a24ff1
Merge pull request #12 from AttiR/feat/phase-2-embeddings-store
AttiR Jun 27, 2026
9095bcd
feat(index): build Pinecone index from generated claims
AttiR Jun 27, 2026
ce8996b
Merge pull request #13 from AttiR/feat/phase-2-build-index
AttiR Jun 27, 2026
fd774f7
feat(index): dual-vector precedent retrieval + leakage tests
AttiR Jun 27, 2026
34abda5
test(index): add dimension/split/retrieval tests
AttiR Jun 27, 2026
9d58f43
fix(index): lazy-init embed clients so tests import without credentials
AttiR Jun 27, 2026
e08eac3
Merge pull request #14 from AttiR/feat/phase-2-retrieval
AttiR Jun 27, 2026
f7640fe
feat(mcp): precedent server exposing find_precedents over MCP (#15)(#16)
AttiR Jun 27, 2026
7e0305d
Merge pull request #21 from AttiR/feat/phase-3-precedent-server
AttiR Jun 27, 2026
e31a428
feat(mcp): add policy, cost, and fraud MCP servers
AttiR Jun 29, 2026
29f2714
style(mcp): split inline if-statements in fraud server (ruff E701)
AttiR Jun 29, 2026
eddc7b6
Merge pull request #22 from AttiR/feat/phase-3-servers
AttiR Jun 29, 2026
7b8c833
feat(mcp): host discovers 4 servers, Claude-driven tool calls
AttiR Jun 29, 2026
ca9b0c0
style(mcp): split semicolon statement in demo (ruff E702)
AttiR Jun 29, 2026
8a08367
test(mcp): add server unit tests; pin mcp dependency
AttiR Jun 29, 2026
436e7de
Merge pull request #23 from AttiR/feat/phase-3-host
AttiR Jun 29, 2026
a34043a
feat(agents): claim state, MCP tool bridge, and specialist nodes
AttiR Jun 29, 2026
129643c
Merge pull request #28 from AttiR/feat/phase-4-state-specialists
AttiR Jun 29, 2026
541596e
feat(agents): synthesiser node producing a validated Recommendation
AttiR Jun 29, 2026
38f4233
Merge pull request #29 from AttiR/feat/phase-4-synthesiser
AttiR Jun 29, 2026
2bbbc97
feat(agents): LangGraph recommender + human gate + audit
AttiR Jun 29, 2026
090499f
style(agents): split multi-imports and semicolons; drop unused import…
AttiR Jun 29, 2026
d43612c
Merge pull request #30 from AttiR/feat/phase-4-graph-human-audit
AttiR Jun 29, 2026
cc0e696
fix(data): deny below-deductible claims instead of approving for $0
AttiR Jun 29, 2026
91d2bd3
Merge pull request #31 from AttiR/feat/phase-4-graph-human-audit
AttiR Jun 29, 2026
1d11519
feat(eval): eval-set loader with integrity guards; ignore runtime db
AttiR Jun 29, 2026
8b3388a
Merge pull request #36 from AttiR/feat/phase-5-integrity-dataset
AttiR Jun 29, 2026
9f172aa
feat(eval): scoring metrics for the claims pipeline
AttiR Jun 29, 2026
6a7de7d
Merge pull request #37 from AttiR/feat/phase-5-metrics
AttiR Jun 29, 2026
f3a927d
fix(agents): correct synthesiser decision logic
AttiR Jun 30, 2026
bc85e0c
ci(eval): gate committed results; fix lint and deps
AttiR Jun 30, 2026
93b7c1c
Merge pull request #38 from AttiR/feat/phase-5-runner-gate
AttiR Jun 30, 2026
aaffc1a
feat(api): FastAPI app with shared MCP host + claim store
AttiR Jun 30, 2026
c0dd0d4
Merge pull request #43 from AttiR/feat/phase-6a-api-host
AttiR Jun 30, 2026
ff1dda5
feat(api): claim lifecycle endpoints + shared-host fix
AttiR Jun 30, 2026
50c5b9b
Merge pull request #44 from AttiR/feat/phase-6a-endpoints
AttiR Jun 30, 2026
d90587b
feat(api,ui): claim review console over the decision graph
AttiR Jun 30, 2026
70615d0
Merge pull request #45 from AttiR/feat/phase-6a-ui
AttiR Jun 30, 2026
01bab77
Containerize the API
AttiR Jul 1, 2026
2368360
Merge pull request #56 from AttiR/feat/phase-6b-docker
AttiR Jul 1, 2026
8dd841c
feat: Protect the expensive routes
AttiR Jul 2, 2026
c64b581
Merge pull request #57 from AttiR/feat/phase-6b-security
AttiR Jul 2, 2026
b51a283
feat(demo): pre-seed cached recommendations for zero-cost browsing
AttiR Jul 2, 2026
029ee86
chore: fix ruff lint errors in claims and seed_demo
AttiR Jul 2, 2026
12ffb97
Merge pull request #58 from AttiR/feat/phase-6b-demo
AttiR Jul 2, 2026
f72b11f
fixes conflicts
AttiR Jul 2, 2026
828e9a2
Merge branch 'main' into develop
AttiR Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.venv
__pycache__
*.pyc
.env
.git
web/
eval/.cache.jsonl
data/audit_log.jsonl
data/evincta.sqlite
7 changes: 6 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
ANTHROPIC_API_KEY=
VOYAGE_API_KEY=
VOYAGE_API_KEY= # evincta project key
PINECONE_API_KEY=
PINECONE_INDEX= # dedicated index name
PINECONE_CLOUD=aws
PINECONE_REGION= # match your Pinecone project's region
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=https://cloud.langfuse.com
EVINCTA_API_KEY= # required for write routes in production
ALLOWED_ORIGINS=http://localhost:5173
12 changes: 0 additions & 12 deletions .github/workflows/CI.YML

This file was deleted.

12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: CI
on: { pull_request: {}, push: { branches: [main, develop] } }
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt ruff pytest
- run: ruff check .
- run: pytest -q # integration tests skipped by default (see pyproject.toml)
17 changes: 17 additions & 0 deletions .github/workflows/eval.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Eval Gate
on:
pull_request:
workflow_dispatch: # also run on demand
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install pyyaml
# eval/results.json is generated locally (needs the synthetic dataset in
# data/generated/, which is gitignored, plus paid API calls) and committed.
# CI only gates that committed result against thresholds — deterministic,
# no secrets, no network. Regenerate locally with: python -m eval.run_eval
- run: python -m eval.gate # ← fails the job on regression
44 changes: 38 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
# secrets — never commit
# ── Secrets (never commit) ─────────────────────────────
.env
.env.*
!.env.example
*.key
*.pem

# data — keep samples, ignore the bulk generated set
# ── Python ─────────────────────────────────────────────
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/

# ── Data: keep the code + samples, ignore the bulk ─────
data/raw/
data/generated/
*.png
!data/samples/
!data/samples/**

# python / node / build
__pycache__/ *.pyc .venv/ venv/
node_modules/ dist/ .pytest_cache/ .ruff_cache/
# ── Node / React UI (Phase 6) ──────────────────────────
node_modules/
ui/dist/
ui/build/
*.local

# ── OS / editor noise ──────────────────────────────────
.DS_Store
.idea/
.vscode/
*.swp

# ── SQLite database ───────────────────────────────────
data/evincta.sqlite*

# ── Runtime audit log ─────────────────────────────────
data/audit_log.jsonl

# ── Runtime artifacts ──────────────────────────────────
data/audit_log.jsonl
data/claims_index.json
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.12-slim

# system deps for pillow etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY services/ services/
COPY api/ api/
# Demo claims (committed). Mount or bake data/generated/ for the full seeded set (issue #47).
COPY data/samples/ data/samples/

# NOTE: no .env, no secrets copied — injected at runtime by Render
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# exec makes uvicorn PID 1 so SIGINT/SIGTERM (Ctrl+C / docker stop) reach it
CMD ["sh", "-c", "exec uvicorn api.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ vision multimodal policy/cost orchestrator accuracy traced/
```

## Roadmap
- [ ] Phase 1 — Multimodal ingest (vision → structured JSON)
- [ ] Phase 2 — Multimodal vector DB + precedent retrieval
- [ ] Phase 3 — MCP servers (policy · cost · fraud · precedent)
- [ ] Phase 4 — Multi-agent recommender
- [ ] Phase 5 — Eval harness + CI accuracy gate
- [x] Phase 1 — Multimodal ingest (vision → structured JSON)
- [x] Phase 2 — Multimodal vector DB + precedent retrieval
- [x] Phase 3 — MCP servers (policy · cost · fraud · precedent)
- [x] Phase 4 — Multi-agent recommender
- [x] Phase 5 — Eval harness + CI accuracy gate
- [ ] Phase 6 — Production layer + deploy + review UI

## Stack
Expand All @@ -53,3 +53,32 @@ tuning.
Targets, gated in CI once Phase 5 lands: decision accuracy ≥ 0.85 ·
fraud recall ≥ 0.90 · extraction accuracy ≥ 0.90 · one traced,
cost-metered run per claim.

## Evaluation

Evincta is evaluated end-to-end on 18 held-out claims (never indexed, never
seen during retrieval), scored against ground truth with a CI gate that fails
the build on regression.

| Metric | Value |
|---|---|
| Decision accuracy (weighted) | 0.722 |
| Decision accuracy (exact) | 0.722 |
| Payout in range | 0.769 (n=13) |
| Extraction — severity | 0.889 |
| Extraction — incident | 0.833 |
| Latency p50 / p95 | 14.9s / 16.6s |

**Weighted decision accuracy** penalises dangerous errors (approving a claim
that should be denied) far more than cautious ones; weighted == exact here
means the system makes **no dangerous errors** on the eval set — when it is
wrong, it errs toward caution, not toward over-paying.

**Scope & honesty:** Fraud recall is reported separately (0.0, n=2). The
synthetic frauds are image-reuse type; reliable detection requires perceptual
image hashing across claims — a roadmapped capability, not yet implemented.
Every recommendation is gated by human approval regardless, so a missed fraud
flag is never an automatic payout.

Gated metrics and thresholds: [`eval/thresholds.yaml`](eval/thresholds.yaml).
Full results: [`eval/REPORT.md`](eval/REPORT.md).
Empty file added api/__init__.py
Empty file.
9 changes: 9 additions & 0 deletions api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import os
from fastapi import Header, HTTPException

API_KEY = os.getenv("EVINCTA_API_KEY") # injected by Render


def require_key(x_api_key: str = Header(None)):
if not API_KEY or x_api_key != API_KEY:
raise HTTPException(401, "invalid or missing API key")
98 changes: 98 additions & 0 deletions api/claims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from services.agents.run import start_claim, resume_claim
from services.ingest.extractor import extract_claim
from fastapi import Depends
from .auth import require_key
from . import store
import json


router = APIRouter()


class ClaimIn(BaseModel):
claim_dir: str # path to a claim folder (6a). 6b: real upload.


@router.post("/claims", dependencies=[Depends(require_key)])
async def submit(body: ClaimIn):
def _run():
lbl = json.load(open(f"{body.claim_dir}/label.json"))
pol = lbl["policy"]
rec = extract_claim(body.claim_dir).model_dump()
state = {"claim_id": lbl["claim_id"],
"image_path": f"{body.claim_dir}/images/img_0.jpg",
"exclusions": pol["exclusions"], "record": {
"incident_type": rec["incident_type"], "severity": rec["severity"],
"damaged_parts": rec["damaged_parts"],
"notes_summary": rec["notes_summary"],
"deductible_usd": pol["deductible_usd"],
"coverage_limit_usd": pol["coverage_limit_usd"],
"image_inconsistency": rec["image_inconsistency"],
"visible_pre_existing_damage": rec["visible_pre_existing_damage"]}}
tid, recommendation = start_claim(state)
return lbl["claim_id"], tid, recommendation, rec
cid, tid, rec, extracted = await run_in_threadpool(_run)
store.upsert(cid, thread_id=tid, status="pending",
recommendation=rec, evidence=extracted)
return {"claim_id": cid, "status": "pending", "recommendation": rec}


@router.get("/claims")
def list_claims():
# compact rows for the queue view
return [{"claim_id": c["claim_id"], "status": c["status"],
"decision": c["recommendation"]["decision"],
"fraud_risk": c["recommendation"]["fraud_risk"]}

for c in store.all_claims()]


@router.get("/claims/{claim_id}")
def get_claim(claim_id: str):
c = store.get(claim_id)
if not c:
raise HTTPException(404, "claim not found")
return c # full detail: recommendation + evidence + status


class DecisionIn(BaseModel):
decision: str # "approve" | "override"
approver: str
override_to: str | None = None # required when decision == "override"


@router.post("/claims/{claim_id}/decision", dependencies=[Depends(require_key)])
async def decide(claim_id: str, body: DecisionIn):
c = store.get(claim_id)
if not c:
raise HTTPException(404, "claim not found")
if c["status"] != "pending":
raise HTTPException(409, "already decided")

# the human decision the audit records: the model's rec, or the override
final = (body.override_to if body.decision == "override"
else c["recommendation"]["decision"])

def _resume():
resume_claim(c["thread_id"], human_decision=final, approver=body.approver)
await run_in_threadpool(_resume)

store.upsert(claim_id, status="decided",
human_decision=final, approver=body.approver,
was_override=(body.decision == "override"))
return {"claim_id": claim_id, "status": "decided", "final_decision": final}


@router.post("/admin/reset", dependencies=[Depends(require_key)])
def reset_demo():
# restore every seeded claim to "pending" (clears human decisions)
d = store._read()
for cid, c in d.items():
c["status"] = "pending"
c.pop("human_decision", None)
c.pop("approver", None)
store._write(d)
return {"reset": len(d)}
36 changes: 36 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from services.mcp.host import Host
from services.agents.tools import set_shared_host
from .claims import router as claims_router


ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")


@asynccontextmanager
async def lifespan(app: FastAPI):
# startup: boot ONE MCP host, reused by every request. Pass the running
# loop so threadpool workers drive tool calls on the host's own loop.
app.state.host = await Host().connect()
set_shared_host(app.state.host, asyncio.get_running_loop())
yield
# shutdown: tear it down cleanly
set_shared_host(None, None)
await app.state.host.close()

app = FastAPI(title="Evincta API", lifespan=lifespan)

# dev CORS — locked down to the real origin in 6b
app.add_middleware(CORSMiddleware, allow_origins=ORIGINS,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "X-API-Key"])

app.include_router(claims_router)


@app.get("/health")
def health(): return {"status": "ok"}
27 changes: 27 additions & 0 deletions api/store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import json
from pathlib import Path
from threading import Lock

DB = Path("data/claims_index.json")
_lock = Lock()


def _read() -> dict:
return json.loads(DB.read_text()) if DB.exists() else {}


def _write(d): DB.write_text(json.dumps(d, indent=2))


def upsert(claim_id, **fields):
with _lock:
d = _read()
d.setdefault(claim_id, {})
d[claim_id].update(fields)
d[claim_id]["claim_id"] = claim_id
_write(d)
return d[claim_id]


def get(claim_id): return _read().get(claim_id)
def all_claims(): return list(_read().values())
19 changes: 19 additions & 0 deletions data/SCHEMA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"claim_id": "claim_0001",
"split": "index", // "index" (~60) | "eval" (~20)
"ground_truth": {
"incident_type": "collision", // collision|theft|weather|vandalism|other
"severity": "moderate", // minor|moderate|severe|total
"decision": "approve", // approve|investigate|deny
"payout_usd": 3200.0,
"is_fraud": false,
"fraud_type": null // image_reuse|incident_mismatch|pre_existing
},
"policy": {
"policy_number": "AUTO-48213",
"vehicle_value_usd": 18000,
"deductible_usd": 500,
"coverage_limit_usd": 18000,
"exclusions": ["flood"]
}
}
Empty file added data/generator/__init__.py
Empty file.
Loading
Loading