Upload 4 files
Browse files- backend.py +88 -0
- eval_nlu.py +100 -0
- logging_util.py +71 -0
- requirements.txt +10 -8
backend.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PlotWeaver Voice Agent — Backend interface
|
| 3 |
+
==========================================
|
| 4 |
+
Separates *what the bot says* (dialogue.py) from *what actually happens*
|
| 5 |
+
(account lookups, card blocks, transfers, order status, ...).
|
| 6 |
+
|
| 7 |
+
How it wires in
|
| 8 |
+
---------------
|
| 9 |
+
A state spec in dialogue.py may carry an optional ``"action"`` key naming a
|
| 10 |
+
Backend method. When the FSM transitions *into* that state, it calls the
|
| 11 |
+
method with the current slots dict; the method returns a dict of values that
|
| 12 |
+
are merged back into slots, which the prompt templates then interpolate.
|
| 13 |
+
|
| 14 |
+
Contract
|
| 15 |
+
--------
|
| 16 |
+
Backends return ONLY language-neutral data — numbers, ids, names, dates the
|
| 17 |
+
caller supplied. They never return Hausa/English sentences: all phrasing lives
|
| 18 |
+
in dialogue.py prompts. This keeps a real integration (bank sandbox, telecom
|
| 19 |
+
API, logistics API) free of any localization concern.
|
| 20 |
+
|
| 21 |
+
MockBackend ships canned-but-input-aware data for the demo. To go live, drop in
|
| 22 |
+
an implementation satisfying the ``Backend`` protocol; dialogue.py is unchanged.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import random
|
| 27 |
+
import string
|
| 28 |
+
from typing import Protocol, runtime_checkable
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _ref(prefix: str, n: int = 6) -> str:
|
| 32 |
+
return prefix + "".join(random.choices(string.ascii_uppercase + string.digits, k=n))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@runtime_checkable
|
| 36 |
+
class Backend(Protocol):
|
| 37 |
+
# --- bank ---
|
| 38 |
+
def get_balance(self, slots: dict) -> dict: ...
|
| 39 |
+
def block_card(self, slots: dict) -> dict: ...
|
| 40 |
+
def transfer(self, slots: dict) -> dict: ...
|
| 41 |
+
# --- telecom ---
|
| 42 |
+
def buy_airtime(self, slots: dict) -> dict: ...
|
| 43 |
+
def buy_bundle(self, slots: dict) -> dict: ...
|
| 44 |
+
def file_complaint(self, slots: dict) -> dict: ...
|
| 45 |
+
# --- ecommerce ---
|
| 46 |
+
def check_order(self, slots: dict) -> dict: ...
|
| 47 |
+
def reschedule(self, slots: dict) -> dict: ...
|
| 48 |
+
def return_item(self, slots: dict) -> dict: ...
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class MockBackend:
|
| 52 |
+
"""Deterministic-enough mock. Echoes user-provided slots so confirmations
|
| 53 |
+
and receipts reflect what the caller actually said, and fabricates only the
|
| 54 |
+
values a real backend would return (balances, refs, etc.)."""
|
| 55 |
+
|
| 56 |
+
# --- bank ---
|
| 57 |
+
def get_balance(self, slots: dict) -> dict:
|
| 58 |
+
return {"balance": "245,000", "account_last4": slots.get("digits", "")}
|
| 59 |
+
|
| 60 |
+
def block_card(self, slots: dict) -> dict:
|
| 61 |
+
return {"card_eta_days": "3-5", "block_ref": _ref("BLK")}
|
| 62 |
+
|
| 63 |
+
def transfer(self, slots: dict) -> dict:
|
| 64 |
+
return {
|
| 65 |
+
"txn_ref": _ref("TXN"),
|
| 66 |
+
"amount": slots.get("amount", ""),
|
| 67 |
+
"name": slots.get("name", ""),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# --- telecom ---
|
| 71 |
+
def buy_airtime(self, slots: dict) -> dict:
|
| 72 |
+
return {"amount": slots.get("amount", ""), "airtime_balance": "1,500"}
|
| 73 |
+
|
| 74 |
+
def buy_bundle(self, slots: dict) -> dict:
|
| 75 |
+
return {"bundle": slots.get("bundle", "")}
|
| 76 |
+
|
| 77 |
+
def file_complaint(self, slots: dict) -> dict:
|
| 78 |
+
return {"ticket_id": _ref("TKT")}
|
| 79 |
+
|
| 80 |
+
# --- ecommerce ---
|
| 81 |
+
def check_order(self, slots: dict) -> dict:
|
| 82 |
+
return {"order_id": slots.get("digits", "")}
|
| 83 |
+
|
| 84 |
+
def reschedule(self, slots: dict) -> dict:
|
| 85 |
+
return {"order_id": slots.get("digits", ""), "date": slots.get("date", "")}
|
| 86 |
+
|
| 87 |
+
def return_item(self, slots: dict) -> dict:
|
| 88 |
+
return {"order_id": slots.get("digits", ""), "return_ref": _ref("RET")}
|
eval_nlu.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NLU eval harness — intent accuracy, threshold sweep, per-intent recall.
|
| 3 |
+
=======================================================================
|
| 4 |
+
Usage:
|
| 5 |
+
python eval_nlu.py # leave-one-out over INTENT_EXAMPLES
|
| 6 |
+
python eval_nlu.py labeled.csv # external set, columns: text,intent
|
| 7 |
+
|
| 8 |
+
Leave-one-out is a *sanity* tool: for each example phrase, it is held out of
|
| 9 |
+
its own intent's centroid, then classified. It tells you whether the centroids
|
| 10 |
+
are internally separable and lets you sweep CONFIDENCE_THRESHOLD. It does NOT
|
| 11 |
+
substitute for a held-out, human-labeled Hausa test set — collect that from the
|
| 12 |
+
turn logs (logging_util.py) and pass it as the CSV argument.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import csv
|
| 17 |
+
import sys
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
from sentence_transformers import SentenceTransformer
|
| 22 |
+
|
| 23 |
+
from nlu import INTENT_EXAMPLES, EMBEDDING_MODEL_ID, CONFIDENCE_THRESHOLD
|
| 24 |
+
|
| 25 |
+
THRESHOLDS = [0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.60]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _centroid(vecs, exclude=None):
|
| 29 |
+
if exclude is not None:
|
| 30 |
+
vecs = [v for i, v in enumerate(vecs) if i != exclude]
|
| 31 |
+
c = np.mean(vecs, axis=0)
|
| 32 |
+
return c / np.linalg.norm(c)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _build_records(encoder):
|
| 36 |
+
"""Returns list of (text, gold_intent, pred_intent, confidence)."""
|
| 37 |
+
emb = {it: encoder.encode(ph, normalize_embeddings=True)
|
| 38 |
+
for it, ph in INTENT_EXAMPLES.items()}
|
| 39 |
+
|
| 40 |
+
if len(sys.argv) > 1:
|
| 41 |
+
rows = list(csv.DictReader(open(sys.argv[1], encoding="utf-8")))
|
| 42 |
+
cents = {it: _centroid(list(v)) for it, v in emb.items()}
|
| 43 |
+
records = []
|
| 44 |
+
for r in rows:
|
| 45 |
+
q = encoder.encode(r["text"], normalize_embeddings=True)
|
| 46 |
+
scores = {it: float(np.dot(q, c)) for it, c in cents.items()}
|
| 47 |
+
best = max(scores, key=scores.get)
|
| 48 |
+
records.append((r["text"], r["intent"], best, scores[best]))
|
| 49 |
+
return records
|
| 50 |
+
|
| 51 |
+
# leave-one-out over the examples themselves
|
| 52 |
+
records = []
|
| 53 |
+
for it, vecs in emb.items():
|
| 54 |
+
vecs = list(vecs)
|
| 55 |
+
for i, q in enumerate(vecs):
|
| 56 |
+
cents = {jt: (_centroid(list(v), exclude=i) if jt == it else _centroid(list(v)))
|
| 57 |
+
for jt, v in emb.items()}
|
| 58 |
+
scores = {jt: float(np.dot(q, c)) for jt, c in cents.items()}
|
| 59 |
+
best = max(scores, key=scores.get)
|
| 60 |
+
records.append((INTENT_EXAMPLES[it][i], it, best, scores[best]))
|
| 61 |
+
return records
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def main():
|
| 65 |
+
print(f"Loading {EMBEDDING_MODEL_ID} …")
|
| 66 |
+
encoder = SentenceTransformer(EMBEDDING_MODEL_ID, device="cpu")
|
| 67 |
+
records = _build_records(encoder)
|
| 68 |
+
n = len(records)
|
| 69 |
+
mode = "external CSV" if len(sys.argv) > 1 else "leave-one-out"
|
| 70 |
+
print(f"Evaluating {n} utterances ({mode}).\n")
|
| 71 |
+
|
| 72 |
+
print("threshold accuracy coverage (below thresh → predicted 'unknown')")
|
| 73 |
+
for th in THRESHOLDS:
|
| 74 |
+
correct = cov = 0
|
| 75 |
+
for _, gold, pred, conf in records:
|
| 76 |
+
p = pred if conf >= th else "unknown"
|
| 77 |
+
if p != "unknown":
|
| 78 |
+
cov += 1
|
| 79 |
+
if p == gold:
|
| 80 |
+
correct += 1
|
| 81 |
+
marker = " <- current" if abs(th - CONFIDENCE_THRESHOLD) < 1e-9 else ""
|
| 82 |
+
print(f" {th:.2f} {correct/n:.3f} {cov/n:.3f}{marker}")
|
| 83 |
+
|
| 84 |
+
th = CONFIDENCE_THRESHOLD
|
| 85 |
+
confusion = defaultdict(lambda: defaultdict(int))
|
| 86 |
+
for _, gold, pred, conf in records:
|
| 87 |
+
p = pred if conf >= th else "unknown"
|
| 88 |
+
confusion[gold][p] += 1
|
| 89 |
+
|
| 90 |
+
print(f"\nPer-intent recall @ {th:.2f}:")
|
| 91 |
+
for gold in sorted(confusion):
|
| 92 |
+
total = sum(confusion[gold].values())
|
| 93 |
+
hit = confusion[gold][gold]
|
| 94 |
+
worst = sorted(((c, p) for p, c in confusion[gold].items() if p != gold), reverse=True)
|
| 95 |
+
leak = f" (most confused → {worst[0][1]}×{worst[0][0]})" if worst else ""
|
| 96 |
+
print(f" {gold:14s} {hit}/{total} recall={hit/total:.2f}{leak}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
logging_util.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PlotWeaver Voice Agent — turn-level logging
|
| 3 |
+
===========================================
|
| 4 |
+
The Spaces filesystem is ephemeral (wiped on restart / rebuild / sleep), so a
|
| 5 |
+
plain ``open("log.jsonl","a")`` loses everything. This module writes each turn
|
| 6 |
+
to a local JSONL file and, when configured, syncs it to a *private* HF Dataset
|
| 7 |
+
via ``CommitScheduler`` — giving both durable logs and a growing, weakly-labeled
|
| 8 |
+
Hausa corpus (utterance + ASR text + chosen intent + source) for later threshold
|
| 9 |
+
tuning and INTENT_EXAMPLES expansion.
|
| 10 |
+
|
| 11 |
+
Configuration (Space → Settings → Variables and secrets):
|
| 12 |
+
HF_TOKEN secret, write-scoped token
|
| 13 |
+
PW_LOG_DATASET e.g. "plotweaver/voice-agent-logs" (enables sync)
|
| 14 |
+
PW_LOG_DIR optional; defaults to /data if persistent storage is
|
| 15 |
+
attached, else ./logs
|
| 16 |
+
|
| 17 |
+
If PW_LOG_DATASET / HF_TOKEN are absent, logging degrades to local-file-only
|
| 18 |
+
(handy for dev) and never raises into the request path.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import threading
|
| 25 |
+
import uuid
|
| 26 |
+
from datetime import datetime, timezone
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
_HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 30 |
+
_LOG_DATASET = os.environ.get("PW_LOG_DATASET")
|
| 31 |
+
_default_dir = "/data/logs" if Path("/data").exists() else "logs"
|
| 32 |
+
_LOG_DIR = Path(os.environ.get("PW_LOG_DIR", _default_dir))
|
| 33 |
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
_LOG_FILE = _LOG_DIR / f"turns_{uuid.uuid4().hex[:8]}.jsonl"
|
| 35 |
+
|
| 36 |
+
_scheduler = None
|
| 37 |
+
_lock = threading.Lock()
|
| 38 |
+
|
| 39 |
+
if _LOG_DATASET and _HF_TOKEN:
|
| 40 |
+
try:
|
| 41 |
+
from huggingface_hub import CommitScheduler
|
| 42 |
+
|
| 43 |
+
_scheduler = CommitScheduler(
|
| 44 |
+
repo_id=_LOG_DATASET,
|
| 45 |
+
repo_type="dataset",
|
| 46 |
+
folder_path=_LOG_DIR,
|
| 47 |
+
path_in_repo="data",
|
| 48 |
+
every=5, # minutes between commits
|
| 49 |
+
token=_HF_TOKEN,
|
| 50 |
+
private=True,
|
| 51 |
+
squash_history=True,
|
| 52 |
+
)
|
| 53 |
+
print(f"[logging] syncing turns to dataset {_LOG_DATASET}")
|
| 54 |
+
except Exception as e: # never block startup over logging
|
| 55 |
+
print(f"[logging] dataset sync disabled ({e}); local file only")
|
| 56 |
+
else:
|
| 57 |
+
print(f"[logging] local file only at {_LOG_FILE}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def log_turn(record: dict) -> None:
|
| 61 |
+
"""Append one turn record. Safe to call from concurrent Gradio handlers."""
|
| 62 |
+
record = {"ts": datetime.now(timezone.utc).isoformat(), **record}
|
| 63 |
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
| 64 |
+
try:
|
| 65 |
+
# CommitScheduler exposes .lock to avoid committing mid-write
|
| 66 |
+
guard = _scheduler.lock if _scheduler is not None else _lock
|
| 67 |
+
with guard:
|
| 68 |
+
with _LOG_FILE.open("a", encoding="utf-8") as f:
|
| 69 |
+
f.write(line)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"[logging] write failed: {e}")
|
requirements.txt
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
# PlotWeaver Voice Agent — HF Space (Gradio SDK + ZeroGPU)
|
| 2 |
+
gradio>=6.0,<7.0
|
| 3 |
+
spaces
|
| 4 |
+
torch>=2.2
|
| 5 |
+
transformers>=4.44
|
| 6 |
+
sentence-transformers>=3.0
|
| 7 |
+
huggingface_hub>=0.24
|
| 8 |
+
accelerate
|
| 9 |
+
numpy
|
| 10 |
+
scipy
|