Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - gaddyh/tami_one: WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables. · GitHub
Skip to content

Repository files navigation

Tami One

A WhatsApp-native assistant for a mortgage/loan advisor. It reads the advisor's real WhatsApp conversations (personal number, via Green API) and automatically extracts commitments — who owes whom what, and by when — so the advisor never has to re-read a thread to remember what's still open.

Separately, it can run as a 360dialog-based business bot that replies to customers directly with an OpenAI agent (text + voice notes). The two capabilities share one FastAPI app and one database but don't otherwise interact.

Status: active prototype, ~3 days old (first commit 2026-07-05). The commitment-extraction pipeline is validated against a 93-example eval set; it has not been run against real production traffic yet, and there are two known reliability gaps before it should be (see Known Limitations).


Contents


Why this exists

Advisors run their business out of dozens of parallel WhatsApp threads — clients, banks, lawyers. Nothing is ever explicitly "closed," so the mental load of tracking who's waiting on what falls entirely on the advisor. Tami One turns that implicit tracking into an explicit, queryable list, extracted automatically from ordinary conversation:

{
"committed_party": "me",
"required_action": "Send the material",
"deadline": "in one hour",
"status": "waiting",
"context": "Message: 'אני אשלח לך את החומר עוד שעה' ..."
}

Given a real, mixed-Hebrew/English WhatsApp thread, the pipeline correctly separates a fulfilled promise (status=done) from a still-waiting one (status=waiting), attributes each to the right party, and keeps a plain-language context trail for why. That's the core proof-of-concept the project has validated so far.

How a message becomes a commitment

  1. Ingest — Green API POSTs to /webhook/green-api. normalize_green_api_message_event() turns the raw payload into a MessageEvent (chat id, sender, direction, text, timestamp).
  2. Upsert + bufferupsert_contact_and_chat():
    • Creates a Contact row if this (tenant_id, chat_id) hasn't been seen before (insert-only; existing contacts are never updated).
    • Skips chats with more distinct senders than MAX_GROUP_PARTICIPANTS (this is a 1:1/small-group tool, not a broadcast-list reader).
    • Appends the event to an in-memoryMessageBuffer, keyed by (tenant_id, chat_id).
  3. Drain — a background asyncio task inside the running app process wakes up every 2 minutes, atomically drains the whole buffer, and hands each chat's batch of messages to the commitment extractor.
  4. Extractextract_commitments() sends the batch, plus that chat's currently-open commitments, through a DSPy CommitmentAgent (ExtractCommitments signature + dspy.Predict), configured at startup with dspy.JSONAdapter() so it behaves like a structured-output call while staying measurable and optimizable.
  5. PersistCommitmentItem rows are upserted into Postgres/SQLite, tagged with the source_message_ids that produced them.

The 360dialog path (/webhook/360dialog) is unrelated to this flow — it's a direct reply bot for business-number customers, using agents/core.py for in-memory per-thread conversation and services/transcription.py for voice notes.

Project layout

app/
├── main.py # FastAPI app; startup wires init_db + load_cache + drain loop
├── config.py # Settings, loaded from env vars at import time
├── routers/
│ ├── green_api.py # Green API payload → normalized MessageEvent
│ ├── personal_webhook.py # POST /webhook/green-api, /admin/seed, /debug/settings
│ └── business_webhook.py # POST /webhook/360dialog — agent-driven customer replies
├── db/
│ ├── engine.py # SQLAlchemy engine (Postgres or SQLite)
│ ├── models.py # SQLModel tables
│ ├── cache.py # In-memory account/contact cache + MessageBuffer
│ ├── upsert.py # Insert-only Contact creation + buffering
│ └── seed.py # Demo data seeding
├── commitments/
│ ├── models.py # Canonical Commitment / CommitmentList schema
│ ├── commitments_agent.py # DSPy Signature + Module for commitment extraction
│ ├── extractor.py # Adapter: messages + existing commitments → DSPy agent
│ └── processor.py # Drains buffer, calls extractor, upserts CommitmentItem rows
├── agents/
│ ├── core.py # OpenAI agent used by the 360dialog business bot
│ └── memory.py # Per-thread in-memory conversation history
└── services/
├── whatsapp.py # 360dialog API client
└── transcription.py # Voice-note transcription via OpenAI
eval/
├── dataset.py # Example construction + devset loading
├── metrics.py # commitment_metric, act_vs_ignore_metric, token-F1, word-overlap
├── localize.py # Failure → root cause / subcause / repair-type / priority scoring
└── llm_judge.py # Optional LLM-as-judge for semantic field matching
scripts/
├── seed.py # CLI seed entry point
├── eval_runner.py # Local commitment-extraction eval runner (train/dev/test/challenge)
├── eval_inspector.py # Interactive CLI for examining eval failures (load or run fresh, then REPL)
└── compare_runs.py # Compare two eval runs side by side
tests/
├── evals/
│ ├── data/*.yaml # Hand-authored example definitions, 6 categories + challenge split
│ ├── generate_devset.py # YAML → train/dev/test JSON splits
│ ├── trainset.json / devset.json / testset.json / challenge_act_ignore.json
│ └── localize_cases/ # Fixtures for testing the localizer itself
└── test_*.py # upsert, webhook, commitment extraction/eval, localizer

Commitment extraction & evals

The extractor is a DSPy Signature + Module, not a one-off prompt string:

  • Commitment / CommitmentList (app/commitments/models.py) are the canonical domain schema.
  • ExtractCommitments(dspy.Signature) in app/commitments/commitments_agent.py encodes the extraction rules directly in the docstring — most importantly a set of act-vs-ignore rules ("we should do X" is an opinion, not a commitment; a request needs explicit acceptance; "started"/"almost done" are progress reports, not completions).
  • eval/metrics.py scores predictions against a hand-labeled devset: exact match on structured fields, token-F1 on required_action (so settle the invoice vs pay the invoice isn't an automatic fail), and word-overlap on context. Three semantic fields (required_action, deadline, context) can optionally use an LLM-as-judge fallback (eval/llm_judge.py) when the deterministic check fails — enabled with --llm-judge on the eval runner.
  • eval/dataset.py builds train/dev/test splits from YAML fixtures in tests/evals/data/, each tagged by category (act_vs_ignore, args_party, args_deadline, args_required_action, lifecycle_update_vs_new, lifecycle_completion), difficulty (easy/medium/hard), and a specific contrastive scenario name.

Current split sizes: train 40 · dev 22 · test 31 (93 total), generated with:

python tests/evals/generate_devset.py

Latest eval run

Model: gpt-5.4-mini, run 20260707_023551.

python scripts/eval_runner.py --all # deterministic metrics (default)
python scripts/eval_runner.py --all --llm-judge # with LLM-as-judge fallback

Runs are saved to runs/<run_id>/ by default (use --no-save to disable). Runs with --llm-judge get a -judge suffix in the run ID.

SplitNTPFPFNTNPrecisionRecallF1Act/Ignore Acc.Full-CommitmentOver-Extraction
Train40283450.900.880.890.8250.0% (16/32)38%
Dev22181120.950.950.950.9152.6% (10/19)33%
Test31250241.000.930.960.9448.1% (13/27)0%

Field accuracy, measured only on true-positive commitment detections:

FieldTrainDevTest
committed_party100%94%96%
required_action71%78%76%
deadline86%78%80%
context82%89%84%
status100%100%96%

Reading these numbers: the binary act-vs-ignore decision (should this message produce a commitment at all?) is solid across all three splits. The full-commitment metric is intentionally much stricter — it requires the right party, action, deadline, context, and status simultaneously — and sits around 50% everywhere, which is the real signal on where to invest next.

With --llm-judge enabled, the dev split's full-commitment metric rises from 52.6% to 68.4% — the gap is almost entirely semantic equivalence (reserve vs book, by Friday vs Friday) that the deterministic metric penalizes but a judge correctly accepts. This confirms the localizer's top-2 repair recommendation: fix required_action and context matching in the metric/postprocessing layer first.

Failure localization

eval/localize.py takes the failures.jsonl from an eval run and buckets every failure into a root cause, subcause, repair type, and a priority = impact × confidence / cost score, so effort goes to the highest-leverage fix first rather than the loudest one:

python scripts/eval_runner.py --all --save # writes runs/<run_id>/{train,dev,test,summary}.md + failures.jsonl
python -m eval.localize runs/<run_id>/failures.jsonl # rich table
python -m eval.localize runs/<run_id>/failures.jsonl --json # machine-readable

Across all 45 failures in the latest run:

Root CauseCountTop SubcauseRepair TypePriority
required_action_normalization15verb/object too specific vs. expectedmetric/postprocess6.8
context_metric_noise7paraphrase, same meaningmetric6.3
deadline_normalization7missing/extra by prefixmetric/postprocess3.1
under_extraction_policy6external party / group obligationsignature rule1.7
update_vs_new_matching4new commitment mismatched to existing onepostprocess1.5
over_extraction_policy2refusal read as acceptancesignature rule0.6
lifecycle_policy2"started"/"almost done" read as donesignature rule0.6
party_resolution2third-party obligation implied by rolesignature rule0.6

The top two repairs are both about normalization, not the model's judgmentrequired_action and context mismatches are usually semantically correct but worded differently than the reference (send over the docs vs send the documents), which the current exact/near-exact matching penalizes. That's cheap to fix in the metric or a postprocessing step, before touching the prompt at all.

One pattern worth calling out even though it scores lower on priority: several under_extraction_policy and party_resolution misses across all three splits are the same shape — a commitment implied by someone's role or an external party rather than stated directly ("I'm waiting for the bank to process the loan", "we need to send the documents", "the contractor needs to finish by next week"). These recur often enough that they're a real, cross-split gap even though today's failure count per category is small.

The localizer is validated with 16 deterministic tests (tests/test_localize.py) against fixed fake failures in tests/evals/localize_cases/, so its scoring logic isn't just eyeballed.

Comparing runs

scripts/compare_runs.py diffs two eval runs side by side — showing failures fixed, new failures, and persistent failures per scenario:

python scripts/compare_runs.py runs/<run_a> runs/<run_b>

Output is a rich console table (summary, per-category, per-scenario diff) saved as markdown to runs/compares/<run_a>_vs_<run_b>.md.

Interactive failure inspector

scripts/eval_inspector.py is an interactive CLI agent that either loads an existing run or runs a fresh eval, then drops into a REPL for examining each failure without leaving the terminal:

# Load an existing run (interactive picker lists recent runs)
python scripts/eval_inspector.py
# Load a specific run directly
python scripts/eval_inspector.py --run 20260707_183434-judge
# Run a fresh eval on a split, then enter inspector
python scripts/eval_inspector.py --split dev
python scripts/eval_inspector.py --split dev --limit 5 --llm-judge

When loading an existing run, the inspector joins failures.jsonl with predictions.jsonl (on split + category + scenario) to recover the original inputs (chat_id, current_datetime, existing_commitments_json) needed for re-runs.

REPL commands

CommandDescription
list / lsNumbered table of all failures (or filtered subset)
<N>Show full detail for failure #N: input messages, expected vs actual side-by-side diff, root-cause localization
next / prevNavigate to next/previous failure from detail view
filter <key>=<value>Filter by error_type, category, difficulty, split, or field
filter clearClear all filters
filtersShow active filters
localizeRun failure localization on the current (filtered) failure set — shows root-cause summary table + top suggested repairs
rerun <N>Re-run failure #N through a fresh CommitmentAgent — shows pass/fail, what was fixed, what's still wrong, and any new problems
summaryAggregate counts by error type, category, and difficulty
help / hShow available commands
quit / qExit

The detail view shows three sections: (1) the input messages and chat metadata, (2) a field-by-field expected vs actual diff table with ✓/✗/~ indicators (mismatches shown in red), and (3) root cause, subcause, repair type, confidence, and suggested repair text from the localizer.

Example inspector output (click to expand)
inspector>: 1
╭─────────────────────── Failure #6 — FIELD MISMATCH | args_deadline/deadline_by_end_of_week (medium) ────────────────────────╮
│ Messages: │
│ Gaddy: I will send the documents by end of week │
│ │
│ chat_id: 972501111111@c.us │
│ chat_name: Rachel │
│ current_datetime: 2025-01-06T10:00:00Z │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Expected vs Actual
┏━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┓
┃ # ┃ Field ┃ Expected ┃ Actual ┃ Match ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━┩
│ 0 │ id │ None │ None │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ committed_party │ Gaddy │ Gaddy │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ required_action │ Send the documents │ send the documents │ ~ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ deadline │ 2025-01-10 │ None │ ✗ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ context │ I will send the documents by │ Gaddy: I will send the │ ~ │
│ │ │ end of week │ documents by end of week │ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ status │ waiting │ waiting │ ✓ │
├─────┼────────────────────┼────────────────────────────────┼────────────────────────────────┼───────┤
│ │ notification │ none │ none │ ✓ │
└─────┴────────────────────┴────────────────────────────────┴────────────────────────────────┴───────┘
╭─────────────────────────────────────────────────────── Localization ────────────────────────────────────────────────────────╮
│ Root Cause: deadline_normalization │
│ Subcause: format_mismatch │
│ Repair Type: metric_or_postprocess │
│ Confidence: 0.90 │
│ Suggested Repair: Canonicalize deadline phrases │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The rerun command is useful after making code changes — it re-runs a single failing example through a fresh agent instance and shows whether the fix worked, without re-running the entire eval set.

Data model

TableStatusNotes
Tenantin usemulti-tenant root
WhatsAppAccountin useone row per Green API instance / 360dialog number; stores provider + provider_instance_id, not API credentials
Contactin useone row per (tenant_id, chat_id); kind field (client/bank/lawyer/internal/family/unknown) exists but nothing populates it yet
CommitmentItemin usethe actual product surface — party, action, deadline, status, context, source message ids
Chat, ChatMessagedefined, not wired upraw per-message history is not persisted anywhere; messages only exist transiently in the in-memory buffer until drained, then are discarded once extraction runs
WaitingStatus, WaitingParty, Urgency, WaitingItemStatus enumsdefined, no tablescaffolding for a more general waiting/urgency model beyond commitments

In-memory only (lost on process restart):

  • accounts_by_instance, contacts_by_tenant_chat_id — rebuilt from the DB on startup via load_cache(), so these are safe.
  • MessageBuffer._messages_by_chatnot rebuilt from anywhere. Any message ingested but not yet drained when the process restarts (deploy, crash, autoscale) is gone permanently, and there's no raw-message table to recover it from either. This is the single biggest reliability gap right now.

Also unwired:app/services/green_api_client.py defines a GreenApiClient (get chats / group data / chat history) but nothing in the app instantiates it, and WhatsAppAccount has no field to store the per-instance token it would need anyway — this is scaffolding for a future feature (e.g. backfilling chat history), not a bug in the current flow.

Setup

There's no requirements.txt — dependencies are pinned in pyproject.toml / uv.lock.

# recommended (uv.lock is committed, so this is reproducible)
uv sync
# or with plain pip
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp example.env .env

Configuration

Edit .env (example.env currently only lists a subset of these — worth syncing it up):

VarRequiredDefaultPurpose
D360_API_KEYyes — app raises on startup without itneeded even if you're only using the Green API path
D360_API_BASE_URLnohttps://waba-v2.360dialog.iouse the sandbox URL for testing
OPENAI_API_KEYyes, for either pipeline to functionused by both the commitment extractor and the 360dialog agent
OPENAI_MODELnogpt-5.4-minimodel for commitment extraction and agent replies
OPENAI_TRANSCRIBE_MODELnogpt-4o-transcribevoice-note transcription
WEBHOOK_AUTH_MODEnononenone / bearer / basic — protects /webhook/360dialog only, not /webhook/green-api
WEBHOOK_BEARER_TOKENif bearer mode
WEBHOOK_BASIC_USER / WEBHOOK_BASIC_PASSif basic mode
MAX_GROUP_PARTICIPANTSno3group chats with more distinct senders than this are skipped entirely
ALLOWED_CHAT_IDSnoempty (no filter)parsed into settings and exposed via /debug/settings, but not currently enforced in the Green API webhook handler
DATABASE_PATHnotami.dbSQLite file, used when DATABASE_URL is unset
DATABASE_URLnoPostgres URL; takes priority over DATABASE_PATH when set
LOG_LEVELnoINFO
LANGSMITH_TRACING_V2 / LANGSMITH_API_KEY / LANGSMITH_PROJECTnotracing offoptional observability
CONVERSATION_MAX_MESSAGESno20per-thread history cap for the 360dialog agent

GREEN_API_BASE_URL / GREEN_API_TOKEN currently appear in example.env but aren't read anywhere in the code — Green API credentials live in the WhatsAppAccount table lookup path, not a global env var. Safe to ignore or remove.

Run locally

uvicorn app.main:app --reload --port 8000
curl http://localhost:8000/health

Seed the database

# Local, against Postgres
DATABASE_URL="postgresql://user:password@host:5432/dbname" .venv/bin/python scripts/seed.py
# On Render, after first deploy
curl -X POST https://your-app.onrender.com/admin/seed

⚠️run_seed(overwrite=True) drops the entire public schema before recreating tables. The /admin/seed route currently calls it with overwrite=False, but the route has no auth of its own — don't leave it reachable on a public URL longer than you need to.

Tests

.venv/bin/python -m pytest tests/ -v

Covers Green API payload normalization, contact upsert + buffering, webhook handling, commitment extraction helpers, commitment-eval utilities, the failure localizer (16 deterministic tests), and the LLM judge (11 tests with mocked DSPy responses).

Run the commitment eval, optionally with LLM-as-judge, and regenerate the deterministic train/dev/test splits if the YAML fixtures change:

python scripts/eval_runner.py --all # deterministic (default)
python scripts/eval_runner.py --all --llm-judge # with LLM judge
python tests/evals/generate_devset.py

Deploy (Render)

  1. Connect the repo.
  2. Set env vars (D360_API_KEY, OPENAI_API_KEY, DATABASE_URL, etc. — see Configuration).
  3. Build: pip install -e .
  4. Start: uvicorn app.main:app --host 0.0.0.0 --port $PORT
  5. Seed once: curl -X POST https://your-app.onrender.com/admin/seed

Because the drain loop and message buffer live inside the app process, any redeploy or restart between ingestion and the 2-minute drain cycle loses buffered-but-undrained messages, and Render's free tier spinning an idle instance down has the same effect. This is the top item in the roadmap below.

Endpoints

EndpointProviderPurpose
POST /webhook/green-apiGreen APIIngest personal WhatsApp messages, buffer for commitment extraction
POST /webhook/360dialog360dialogBusiness WhatsApp — AI agent replies, text + voice
POST /admin/seedSeed demo data (unauthenticated)
GET /healthHealth check
GET /debug/settingsDev debug info (no secrets)

Known limitations / roadmap

Roughly in priority order:

  1. Durable ingestion. Persist ChatMessage rows on arrival (the table already exists) instead of relying solely on the in-memory buffer. This removes the "lose messages on restart" risk and gives a permanent audit trail independent of what the LLM does with them.
  2. Green API webhook auth is disabled (verify_green_api_authorization is defined but commented out in personal_webhook.py). Re-enable before this is exposed on a public URL for real.
  3. required_action and context wording drive most eval failures (15 + 7 of 45, see Failure localization) — mostly verb/object synonyms and paraphrasing that the current metric treats as wrong even when semantically correct. Highest-leverage fix available, and it's in the metric/postprocessing layer, not the prompt.
  4. deadline is free text ("in one hour", "by Friday"), not a resolved timestamp — 7 failures trace to missing/extra by-style phrasing. Needs normalizing before it can drive urgency sorting or scheduled digests.
  5. Commitment dedup/update matching is still partly model-driven (matching by id inside one extraction call); 4 failures are the model creating a near-duplicate instead of updating the existing row. Worth a deterministic fallback matcher.
  6. Under-extraction on implied/external-party commitments — "waiting on the bank," "the contractor needs to," "we need to" — recurs across all three splits. Loosening the act-vs-ignore rule for implied-party cases is the natural next prompt change.
  7. Contact.kind (client/bank/lawyer/…) is modeled but not populated. No classifier sets it yet, so nothing downstream can filter or route by relationship type.
  8. No digest job yet.CommitmentItem data is good enough to query; a scheduled per-tenant job rendering waiting commitments into a daily WhatsApp message is the natural next milestone.
  9. /admin/seed has no auth of its own — fine for a private dev deploy, worth gating before wider use.

About

WhatsApp-native commitment extraction for mortgage advisors: ingests real WhatsApp threads, detects who owes what by when, and evaluates extraction quality with DSPy, generated devsets, and rich failure tables.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages