overheard 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overheard/__init__.py +3 -0
- overheard/adapters/__init__.py +0 -0
- overheard/adapters/backend/__init__.py +0 -0
- overheard/adapters/backend/base.py +11 -0
- overheard/adapters/backend/mock.py +45 -0
- overheard/app.py +22 -0
- overheard/cli.py +48 -0
- overheard/config.py +21 -0
- overheard/data/__init__.py +0 -0
- overheard/data/db.py +50 -0
- overheard/data/events.py +23 -0
- overheard/data/generator.py +148 -0
- overheard/data/models.py +96 -0
- overheard/data/seed_today.json +1941 -0
- overheard/data/templates.py +50 -0
- overheard/static/css/app.css +23 -0
- overheard/static/index.html +27 -0
- overheard/static/js/core/store.js +17 -0
- overheard/static/js/main.js +15 -0
- overheard-0.0.2.dist-info/METADATA +48 -0
- overheard-0.0.2.dist-info/RECORD +23 -0
- overheard-0.0.2.dist-info/WHEEL +4 -0
- overheard-0.0.2.dist-info/entry_points.txt +2 -0
overheard/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The case-system seam: the mock reads the seeded database; a real system of record replaces it later."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from overheard.data.models import Case
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CaseBackend(Protocol):
|
|
9
|
+
def case(self, case_id: str) -> Case | None: ...
|
|
10
|
+
def by_phone(self, phone: str) -> list[Case]: ...
|
|
11
|
+
def search(self, text: str) -> list[Case]: ...
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The mock case system, over the seeded database."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from overheard.data.models import Case
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _digits(s: str) -> str:
|
|
11
|
+
return re.sub(r"\D", "", s or "")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MockCaseBackend:
|
|
15
|
+
def __init__(self, con: sqlite3.Connection):
|
|
16
|
+
self.con = con
|
|
17
|
+
|
|
18
|
+
def _case(self, row) -> Case:
|
|
19
|
+
return Case(id=row["id"], name=row["name"], phone=row["phone"], status=row["status"], region=row["region"],
|
|
20
|
+
lang=row["lang"], payments=json.loads(row["payments"]), docs=json.loads(row["docs"]),
|
|
21
|
+
lastAction=row["last_action"], team=row["team"])
|
|
22
|
+
|
|
23
|
+
def case(self, case_id: str) -> Case | None:
|
|
24
|
+
cid = case_id.upper().replace(" ", "")
|
|
25
|
+
if not cid.startswith("FBS-"):
|
|
26
|
+
cid = "FBS-" + _digits(cid)
|
|
27
|
+
row = self.con.execute("SELECT * FROM cases WHERE id = ?", (cid,)).fetchone()
|
|
28
|
+
return self._case(row) if row else None
|
|
29
|
+
|
|
30
|
+
def by_phone(self, phone: str) -> list[Case]:
|
|
31
|
+
want = _digits(phone)[-10:]
|
|
32
|
+
rows = self.con.execute("SELECT * FROM cases").fetchall() if want else []
|
|
33
|
+
return [self._case(r) for r in rows if _digits(r["phone"])[-10:] == want]
|
|
34
|
+
|
|
35
|
+
def search(self, text: str) -> list[Case]:
|
|
36
|
+
"""Name, phone or case number, the way an agent types into the lookup box."""
|
|
37
|
+
text = text.strip()
|
|
38
|
+
if not text:
|
|
39
|
+
return []
|
|
40
|
+
if _digits(text) and len(_digits(text)) >= 6:
|
|
41
|
+
found = self.by_phone(text) if len(_digits(text)) >= 10 else []
|
|
42
|
+
c = self.case(text)
|
|
43
|
+
return found + ([c] if c and c not in found else [])
|
|
44
|
+
rows = self.con.execute("SELECT * FROM cases WHERE name LIKE ? ORDER BY name LIMIT 20", (f"%{text}%",))
|
|
45
|
+
return [self._case(r) for r in rows]
|
overheard/app.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The FastAPI app: the API under /api, and the front end (static files) at /."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from overheard import __version__
|
|
9
|
+
from overheard.config import Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
13
|
+
settings = settings or Settings()
|
|
14
|
+
app = FastAPI(title="Overheard", version=__version__)
|
|
15
|
+
app.state.settings = settings
|
|
16
|
+
|
|
17
|
+
@app.get("/api/health")
|
|
18
|
+
async def health() -> dict:
|
|
19
|
+
return {"ok": True, "version": __version__, "llm_mode": settings.llm_mode}
|
|
20
|
+
|
|
21
|
+
app.mount("/", StaticFiles(directory=str(files("overheard") / "static"), html=True), name="static")
|
|
22
|
+
return app
|
overheard/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The `overheard` command: serve, seed, record, batch-label."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from overheard import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: list[str] | None = None) -> int:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="overheard", description="An AI voicemail console demo.")
|
|
11
|
+
parser.add_argument("--version", action="version", version=f"overheard {__version__}")
|
|
12
|
+
sub = parser.add_subparsers(dest="command")
|
|
13
|
+
serve = sub.add_parser("serve", help="run the console (API and front end) in one process")
|
|
14
|
+
serve.add_argument("--host")
|
|
15
|
+
serve.add_argument("--port", type=int)
|
|
16
|
+
sub.add_parser("seed", help="generate the synthetic day and archive into the database")
|
|
17
|
+
sub.add_parser("record", help="run the live LLM over the day and save its responses for replay")
|
|
18
|
+
sub.add_parser("batch-label", help="label a sample of the archive with the live LLM")
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
|
|
21
|
+
if args.command == "serve":
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
from overheard.app import create_app
|
|
25
|
+
from overheard.config import Settings
|
|
26
|
+
|
|
27
|
+
settings = Settings()
|
|
28
|
+
host, port = args.host or settings.host, args.port or settings.port
|
|
29
|
+
print(f"Overheard {__version__} on http://{host}:{port} (LLM mode: {settings.llm_mode})")
|
|
30
|
+
uvicorn.run(create_app(settings), host=host, port=port, log_level="warning")
|
|
31
|
+
return 0
|
|
32
|
+
if args.command == "seed":
|
|
33
|
+
from overheard.config import Settings
|
|
34
|
+
from overheard.data.generator import seed
|
|
35
|
+
|
|
36
|
+
settings = Settings()
|
|
37
|
+
counts = seed(settings.db_path, settings.seed)
|
|
38
|
+
print(f"seeded {settings.db_path} (seed {settings.seed}): " + ", ".join(f"{v:,} {k}" for k, v in counts.items()))
|
|
39
|
+
return 0
|
|
40
|
+
if args.command in ("record", "batch-label"):
|
|
41
|
+
print(f"overheard {args.command}: not built yet (this is an early release)", file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
parser.print_help()
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
overheard/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Settings, from OVERHEARD_* environment variables."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import SecretStr
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
model_config = SettingsConfigDict(env_prefix="OVERHEARD_")
|
|
12
|
+
|
|
13
|
+
llm_mode: Literal["mock", "live", "record", "replay"] = "mock"
|
|
14
|
+
llm_base_url: str | None = None
|
|
15
|
+
llm_api_key: SecretStr | None = None
|
|
16
|
+
llm_model: str | None = None
|
|
17
|
+
llm_timeout: float = 60.0
|
|
18
|
+
db_path: Path = Path("overheard.db")
|
|
19
|
+
seed: int = 2026
|
|
20
|
+
host: str = "127.0.0.1"
|
|
21
|
+
port: int = 8000
|
|
File without changes
|
overheard/data/db.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""SQLite: the seeded day, the case system, and the archive. Nested lists (payments, docs) are stored as JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SCHEMA = """
|
|
8
|
+
CREATE TABLE voicemails (id TEXT PRIMARY KEY, received_at TEXT, caller_id TEXT, duration_sec INTEGER, lang TEXT,
|
|
9
|
+
script TEXT, truth TEXT);
|
|
10
|
+
CREATE TABLE cases (id TEXT PRIMARY KEY, name TEXT, phone TEXT, status TEXT, region TEXT, lang TEXT, payments TEXT,
|
|
11
|
+
docs TEXT, last_action TEXT, team TEXT);
|
|
12
|
+
CREATE INDEX cases_phone ON cases (phone);
|
|
13
|
+
CREATE TABLE events (id TEXT PRIMARY KEY, date TEXT, type TEXT, name TEXT, description TEXT);
|
|
14
|
+
CREATE TABLE contacts (id TEXT PRIMARY KEY, case_id TEXT, at TEXT, channel TEXT, root_cause TEXT, lang TEXT, region TEXT,
|
|
15
|
+
handle_min REAL, resolved INTEGER, days_to_resolve INTEGER, text TEXT, promise TEXT);
|
|
16
|
+
CREATE INDEX contacts_case ON contacts (case_id);
|
|
17
|
+
CREATE INDEX contacts_cause ON contacts (root_cause, at);
|
|
18
|
+
CREATE TABLE kb_articles (id TEXT PRIMARY KEY, title TEXT, body TEXT, topics TEXT);
|
|
19
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def connect(path: Path | str) -> sqlite3.Connection:
|
|
24
|
+
con = sqlite3.connect(path)
|
|
25
|
+
con.row_factory = sqlite3.Row
|
|
26
|
+
return con
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create(path: Path | str) -> sqlite3.Connection:
|
|
30
|
+
"""A fresh database at `path` (an existing one is replaced)."""
|
|
31
|
+
path = Path(path)
|
|
32
|
+
if path.exists():
|
|
33
|
+
path.unlink()
|
|
34
|
+
con = connect(path)
|
|
35
|
+
con.executescript(SCHEMA)
|
|
36
|
+
return con
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dump_digest(con: sqlite3.Connection) -> str:
|
|
40
|
+
"""A digest of every row, for checking that the same seed gives the same data."""
|
|
41
|
+
import hashlib
|
|
42
|
+
|
|
43
|
+
h = hashlib.sha256()
|
|
44
|
+
for line in con.iterdump():
|
|
45
|
+
h.update(line.encode())
|
|
46
|
+
return h.hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def j(value) -> str:
|
|
50
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
overheard/data/events.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The events calendar that drives the archive's volume: each event adds calls about one driver, peaking at its
|
|
2
|
+
start and fading over `days`. The planted signals Mode 3 should recover are marked."""
|
|
3
|
+
|
|
4
|
+
# (id, date, type, name, description, driver it drives, extra contacts per day at the start, days it lasts)
|
|
5
|
+
EVENTS = [
|
|
6
|
+
("ev-portal-40", "2023-09-12", "release", "Portal release 4.0", "New sign-in page", "portal-sign-in-failure", 3.0, 5),
|
|
7
|
+
("ev-cola-24", "2024-01-08", "mailing", "Cost-of-living letters 2024", "Annual adjustment notices", "cola-letter", 4.0, 20),
|
|
8
|
+
("ev-income-rule", "2024-01-22", "policy", "Monthly income reporting rule", "Income must be reported monthly", "income-reporting-question", 3.0, 60),
|
|
9
|
+
("ev-portal-41", "2024-04-16", "release", "Portal release 4.1", "Document upload redesign", "portal-navigation", 3.0, 6),
|
|
10
|
+
# PLANTED: the confusing letter. Mailed twice a year; each mailing drives a large, slow-fading excess.
|
|
11
|
+
("ev-22b-1", "2024-06-03", "mailing", "Form 22-B re-verification letter (1st mailing)", "Re-verify address and income within 30 days", "form-22b-confusion", 9.0, 50),
|
|
12
|
+
("ev-1099-quiet", "2024-10-01", "policy", "Tax form vendor change", "1099s produced by a new vendor", "tax-form-1099", 0.25, 110),
|
|
13
|
+
("ev-22b-2", "2024-12-02", "mailing", "Form 22-B re-verification letter (2nd mailing)", "Re-verify address and income within 30 days", "form-22b-confusion", 9.0, 50),
|
|
14
|
+
("ev-cola-25", "2025-01-06", "mailing", "Cost-of-living letters 2025", "Annual adjustment notices", "cola-letter", 4.0, 20),
|
|
15
|
+
# PLANTED: the weak signal that grew: a trickle of missing-1099 calls from October, then a spike in late January.
|
|
16
|
+
("ev-1099-spike", "2025-01-27", "mailing", "1099 tax forms mailed", "Annual tax forms", "tax-form-1099", 8.0, 20),
|
|
17
|
+
# PLANTED: the big release that broke sign-in.
|
|
18
|
+
("ev-portal-42", "2025-05-06", "release", "Portal release 4.2", "Two-step verification for sign-in", "portal-sign-in-failure", 30.0, 9),
|
|
19
|
+
("ev-22b-3", "2025-06-02", "mailing", "Form 22-B re-verification letter (3rd mailing)", "Re-verify address and income within 30 days", "form-22b-confusion", 9.0, 50),
|
|
20
|
+
("ev-22b-4", "2025-12-01", "mailing", "Form 22-B re-verification letter (4th mailing)", "Re-verify address and income within 30 days", "form-22b-confusion", 9.0, 50),
|
|
21
|
+
("ev-cola-26", "2026-01-05", "mailing", "Cost-of-living letters 2026", "Annual adjustment notices", "cola-letter", 4.0, 20),
|
|
22
|
+
("ev-payday-12", "2026-02-27", "payment-shift", "Payment date moves to the 12th", "March payments move from the 3rd to the 12th", "payment-date-shift", 6.0, 14),
|
|
23
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""`overheard seed`: the hand-written day (seed_today.json) plus a three-year archive generated from a fixed seed.
|
|
2
|
+
Same seed, same data. The archive's planted signals are in events.py and in the knobs below."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from overheard.data import db
|
|
12
|
+
from overheard.data.events import EVENTS
|
|
13
|
+
from overheard.data.templates import DOCS, DRIVERS, LANGUAGES, MONTHS
|
|
14
|
+
|
|
15
|
+
START, END = date(2023, 3, 10), date(2026, 3, 9) # the archive: three years up to the day before "today"
|
|
16
|
+
LANGS = [("en", 0.80), ("es", 0.13), ("vi", 0.03), ("tl", 0.02), ("zh", 0.02)]
|
|
17
|
+
REGIONS = [("CA", 12), ("TX", 10), ("FL", 8), ("NY", 7), ("PA", 5), ("OH", 5), ("IL", 5), ("GA", 4), ("NC", 4), ("MI", 4),
|
|
18
|
+
("AZ", 3), ("WA", 3), ("TN", 3), ("KY", 2), ("MD", 2), ("NM", 2), ("NV", 2), ("OR", 2), ("MN", 2), ("ME", 1)]
|
|
19
|
+
VOLUME = 1.35 # scales every driver and event alike: about 15,000 archive contacts
|
|
20
|
+
LOOP_SHARE = 0.04 # PLANTED: callers stuck in a loop: 3-6 contacts on one issue, never resolved
|
|
21
|
+
SLOW_LANG = "vi" # PLANTED: slower, less often resolved service for one language
|
|
22
|
+
BROKEN = {"appeal-status": 0.62, "claim-status": 0.35, "identity-check-hold": 0.45} # PLANTED: promises broken
|
|
23
|
+
FIRST = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", "David", "Elizabeth", "Maria",
|
|
24
|
+
"Jose", "Thi", "Van", "Nguyen", "Rosa", "Carlos", "Wei", "Mei", "Ana", "Luis", "Grace", "Samuel", "Ruth"]
|
|
25
|
+
LAST = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez",
|
|
26
|
+
"Nguyen", "Tran", "Le", "Santos", "Reyes", "Cruz", "Chen", "Wang", "Kim", "Patel", "Okafor", "Kowalski"]
|
|
27
|
+
KB = [
|
|
28
|
+
("kb-payment-dates", "When payments arrive", "Payments arrive on the 3rd; from March 2026, on the 12th.", ["payment-status", "payment-date-shift"]),
|
|
29
|
+
("kb-direct-deposit", "Changing your bank account", "Send Form 14; changes take effect within 10 business days.", ["direct-deposit-change", "returned-deposit"]),
|
|
30
|
+
("kb-address", "Changing your address", "Send Form 9 or update it in the portal; processed in 5 business days.", ["address-change", "temporary-address"]),
|
|
31
|
+
("kb-portal-signin", "Signing in to the portal", "Use the code sent by text; codes expire after 10 minutes.", ["portal-sign-in-failure", "password-reset"]),
|
|
32
|
+
("kb-documents", "Sending documents", "Mail documents to the address on your letter.", ["document-submission-method", "document-received-confirmation"]),
|
|
33
|
+
("kb-cola", "Cost-of-living adjustments", "Amounts change each January; your letter shows the new amount.", ["cola-letter"]),
|
|
34
|
+
("kb-overpayment", "Overpayment notices", "You can ask for a waiver or a repayment plan within 30 days.", ["overpayment-notice"]),
|
|
35
|
+
("kb-appeals", "Appeals", "Appeals are decided in order received.", ["appeal-status"]),
|
|
36
|
+
# PLANTED gaps: no article explains Form 22-B re-verification, a fax number, or the 1099 vendor change.
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def poisson(rng: random.Random, lam: float) -> int:
|
|
41
|
+
if lam <= 0:
|
|
42
|
+
return 0
|
|
43
|
+
if lam > 30: # normal approximation for big days
|
|
44
|
+
return max(0, round(rng.gauss(lam, math.sqrt(lam))))
|
|
45
|
+
k, p, target = 0, 1.0, math.exp(-lam)
|
|
46
|
+
while True:
|
|
47
|
+
p *= rng.random()
|
|
48
|
+
if p <= target:
|
|
49
|
+
return k
|
|
50
|
+
k += 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pick(rng: random.Random, weighted) -> str:
|
|
54
|
+
total = sum(w for _, w in weighted)
|
|
55
|
+
r, acc = rng.random() * total, 0.0
|
|
56
|
+
for value, w in weighted:
|
|
57
|
+
acc += w
|
|
58
|
+
if r <= acc:
|
|
59
|
+
return value
|
|
60
|
+
return weighted[-1][0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def say(rng: random.Random, phrasings: list[str]) -> str:
|
|
64
|
+
text = rng.choice(phrasings)
|
|
65
|
+
return text.format(month=rng.choice(MONTHS), amount=f"${rng.randint(4, 120) * 10}",
|
|
66
|
+
doc=rng.choice(DOCS), language=rng.choice(list(LANGUAGES.values())))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def seed(path: Path | str, seed_value: int = 2026) -> dict:
|
|
70
|
+
"""Write the day and the archive to a fresh database at `path`. Returns counts."""
|
|
71
|
+
rng = random.Random(seed_value)
|
|
72
|
+
con = db.create(path)
|
|
73
|
+
today = json.loads((files("overheard.data") / "seed_today.json").read_text(encoding="utf-8"))
|
|
74
|
+
for v in today["voicemails"]:
|
|
75
|
+
con.execute("INSERT INTO voicemails VALUES (?,?,?,?,?,?,?)",
|
|
76
|
+
(v["id"], v["receivedAt"], v["callerId"], v["durationSec"], v["lang"], v["script"], db.j(v["truth"])))
|
|
77
|
+
for c in today["cases"]:
|
|
78
|
+
con.execute("INSERT INTO cases VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
79
|
+
(c["id"], c["name"], c["phone"], c["status"], c["region"], c["lang"], db.j(c["payments"]),
|
|
80
|
+
db.j(c["docs"]), c["lastAction"], c["team"]))
|
|
81
|
+
for e in EVENTS:
|
|
82
|
+
con.execute("INSERT INTO events VALUES (?,?,?,?,?)", e[:5])
|
|
83
|
+
for k in KB:
|
|
84
|
+
con.execute("INSERT INTO kb_articles VALUES (?,?,?,?)", (k[0], k[1], k[2], db.j(k[3])))
|
|
85
|
+
|
|
86
|
+
# The archive's callers: a pool of cases, each with a language and region
|
|
87
|
+
callers = []
|
|
88
|
+
for i in range(6000):
|
|
89
|
+
lang, region = pick(rng, LANGS), pick(rng, REGIONS)
|
|
90
|
+
cid = f"FBS-{500000 + i}"
|
|
91
|
+
name = f"{rng.choice(FIRST)} {rng.choice(LAST)}"
|
|
92
|
+
phone = f"+1-{rng.randint(201, 989)}-555-{rng.randint(0, 9999):04d}"
|
|
93
|
+
callers.append((cid, lang, region))
|
|
94
|
+
con.execute("INSERT INTO cases VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
95
|
+
(cid, name, phone, "active", region, lang, "[]", "[]", "archive", "general"))
|
|
96
|
+
drivers = {d[0]: d for d in DRIVERS}
|
|
97
|
+
events = [(date.fromisoformat(e[1]), e[5], e[6], e[7]) for e in EVENTS]
|
|
98
|
+
n, day = 0, START
|
|
99
|
+
while day <= END:
|
|
100
|
+
weekday = day.weekday()
|
|
101
|
+
if weekday < 5: # the archive is weekday contacts
|
|
102
|
+
for dname, _team, weekly, handle, resolve_p, phrasings in DRIVERS:
|
|
103
|
+
lam = weekly / 5
|
|
104
|
+
for start, driver, peak, days in events:
|
|
105
|
+
age = (day - start).days
|
|
106
|
+
if driver == dname and 0 <= age < days:
|
|
107
|
+
lam += peak * math.exp(-3.0 * age / days)
|
|
108
|
+
for _ in range(poisson(rng, lam * VOLUME)):
|
|
109
|
+
n += 1
|
|
110
|
+
cid, lang, region = rng.choice(callers)
|
|
111
|
+
slow = lang == SLOW_LANG
|
|
112
|
+
h = max(0.5, rng.gauss(handle * (1.35 if slow else 1.0), handle * 0.25))
|
|
113
|
+
resolved = rng.random() < resolve_p - (0.15 if slow else 0.0)
|
|
114
|
+
days_to = (rng.randint(1, 30) * (2 if slow else 1)) if resolved else None
|
|
115
|
+
promise = None
|
|
116
|
+
if rng.random() < 0.3:
|
|
117
|
+
due = day + timedelta(days=rng.choice([5, 10, 14, 30]))
|
|
118
|
+
kept = rng.random() >= BROKEN.get(dname, 0.08) if due <= END else None
|
|
119
|
+
promise = {"text": f"Call back with an answer by {due.isoformat()}", "dueAt": due.isoformat(),
|
|
120
|
+
"kept": kept}
|
|
121
|
+
at = f"{day.isoformat()}T{rng.randint(8, 16):02d}:{rng.randint(0, 59):02d}"
|
|
122
|
+
con.execute("INSERT INTO contacts VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
123
|
+
(f"c{n:06d}", cid, at, pick(rng, [("voicemail", 3), ("phone", 6), ("web", 1)]), dname,
|
|
124
|
+
lang, region, round(h, 1), int(resolved), days_to, say(rng, phrasings),
|
|
125
|
+
db.j(promise) if promise else None))
|
|
126
|
+
day += timedelta(days=1)
|
|
127
|
+
|
|
128
|
+
# PLANTED: callers stuck in loops: the same unresolved issue, again and again
|
|
129
|
+
loops = 0
|
|
130
|
+
for cid, lang, region in rng.sample(callers, int(len(callers) * LOOP_SHARE)):
|
|
131
|
+
dname = rng.choice(["identity-check-hold", "appeal-status", "overpayment-notice", "form-22b-confusion"])
|
|
132
|
+
d = drivers[dname]
|
|
133
|
+
first = START + timedelta(days=rng.randint(0, (END - START).days - 120))
|
|
134
|
+
for k in range(rng.randint(3, 6)):
|
|
135
|
+
n += 1
|
|
136
|
+
loops += 1
|
|
137
|
+
at = first + timedelta(days=k * rng.randint(7, 21))
|
|
138
|
+
con.execute("INSERT INTO contacts VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
139
|
+
(f"c{n:06d}", cid, f"{at.isoformat()}T{rng.randint(8, 16):02d}:{rng.randint(0, 59):02d}",
|
|
140
|
+
"phone", dname, lang, region, round(max(0.5, rng.gauss(d[3] * 1.2, 1.5)), 1), 0, None,
|
|
141
|
+
say(rng, d[5]), None))
|
|
142
|
+
con.execute("INSERT INTO meta VALUES ('seed', ?)", (str(seed_value),))
|
|
143
|
+
con.execute("INSERT INTO meta VALUES ('today', ?)", (today["date"],))
|
|
144
|
+
con.commit()
|
|
145
|
+
counts = {"voicemails": len(today["voicemails"]), "cases": len(today["cases"]) + len(callers),
|
|
146
|
+
"contacts": n, "loop_contacts": loops, "events": len(EVENTS)}
|
|
147
|
+
con.close()
|
|
148
|
+
return counts
|
overheard/data/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The data model, shared by the API and the pipeline. Raw input, ground truth and (later) AI output are kept
|
|
2
|
+
apart, so any mode can be re-run and any AI field scored against the truth."""
|
|
3
|
+
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
Obligation = Literal["congressional", "records", "legal", "welfare", "discrimination"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Truth(BaseModel):
|
|
12
|
+
intent: str
|
|
13
|
+
urgency: int
|
|
14
|
+
rootCause: str
|
|
15
|
+
team: str
|
|
16
|
+
callerName: str | None = None
|
|
17
|
+
callbackNumber: str
|
|
18
|
+
caseId: str | None = None
|
|
19
|
+
caseNumberSaid: bool = True
|
|
20
|
+
group: str | None = None
|
|
21
|
+
obligations: list[Obligation] = []
|
|
22
|
+
resolvableBy: str | None = None
|
|
23
|
+
linkedTo: list[str] = []
|
|
24
|
+
callbackWindow: str | None = None
|
|
25
|
+
beliefMismatch: str | None = None
|
|
26
|
+
englishGist: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Voicemail(BaseModel):
|
|
30
|
+
id: str
|
|
31
|
+
receivedAt: str
|
|
32
|
+
callerId: str
|
|
33
|
+
durationSec: int
|
|
34
|
+
lang: str
|
|
35
|
+
script: str
|
|
36
|
+
truth: Truth
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Payment(BaseModel):
|
|
40
|
+
date: str
|
|
41
|
+
amount: float
|
|
42
|
+
status: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Doc(BaseModel):
|
|
46
|
+
name: str
|
|
47
|
+
received: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Case(BaseModel):
|
|
51
|
+
id: str
|
|
52
|
+
name: str
|
|
53
|
+
phone: str
|
|
54
|
+
status: str
|
|
55
|
+
region: str
|
|
56
|
+
lang: str = "en"
|
|
57
|
+
payments: list[Payment] = []
|
|
58
|
+
docs: list[Doc] = []
|
|
59
|
+
lastAction: str
|
|
60
|
+
team: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Event(BaseModel):
|
|
64
|
+
id: str
|
|
65
|
+
date: str
|
|
66
|
+
type: Literal["mailing", "release", "policy", "payment-shift"]
|
|
67
|
+
name: str
|
|
68
|
+
description: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Promise(BaseModel):
|
|
72
|
+
text: str
|
|
73
|
+
dueAt: str
|
|
74
|
+
kept: bool | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Contact(BaseModel):
|
|
78
|
+
id: str
|
|
79
|
+
caseId: str
|
|
80
|
+
at: str
|
|
81
|
+
channel: Literal["voicemail", "phone", "web"]
|
|
82
|
+
rootCause: str
|
|
83
|
+
lang: str
|
|
84
|
+
region: str
|
|
85
|
+
handleMin: float
|
|
86
|
+
resolved: bool
|
|
87
|
+
daysToResolve: int | None = None
|
|
88
|
+
text: str
|
|
89
|
+
promise: Promise | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class KBArticle(BaseModel):
|
|
93
|
+
id: str
|
|
94
|
+
title: str
|
|
95
|
+
body: str
|
|
96
|
+
topics: list[str]
|