Toadoum commited on
Commit
3c8a5a9
·
verified ·
1 Parent(s): 7b36576

Upload 4 files

Browse files
Files changed (4) hide show
  1. backend.py +88 -0
  2. eval_nlu.py +100 -0
  3. logging_util.py +71 -0
  4. 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
- transformers==4.48.0
2
- torch==2.5.1
3
- accelerate==1.2.1
4
- numpy==2.1.3
5
- scipy==1.15.0
6
- sentencepiece==0.2.0
7
- audioop-lts==0.2.2
8
- sentence-transformers==3.3.1
 
 
 
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