InsuranceBot / tools /audit /run_audit.py
rohitsar567's picture
refactor: KI-047 β€” bucket reorg (Option A safe subset)
e2d09f9
Raw
History Blame Contribute Delete
9.79 kB
"""Run the audit β€” walks each persona's 30-turn flow against the live API.
Design notes:
- Resumable: completed personas land in 80-audit/<run_id>/transcripts/
as soon as they finish; if re-run, already-saved IDs are skipped.
- Rate-limited: NIM's 40 req/min cap is the bottleneck. Each /api/chat
triggers ~1-3 NIM calls (brain + sometimes judge + sometimes cross-check).
We use a serial loop with adaptive sleep, plus a soft retry on 5xx.
- Concurrent: WORKERS workers pull personas off a shared queue. The global
NIM cap is enforced via the per-request sleep so concurrency Γ— dispatch
rate stays under the cap.
- Each turn captures: reply_text, brain_used, intent, citations,
profile_updates, faithfulness_passed, blocked, latency_ms.
Usage:
python tools/audit/run_audit.py [--max-personas N] [--workers W] [--base URL]
"""
from __future__ import annotations
import argparse
import asyncio
import json
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
DEFAULT_BASE = "https://rohitsar567-insurancebot.hf.space"
ROOT = Path(__file__).resolve().parent
RESULTS_ROOT = ROOT.parent.parent / "audit_results"
async def post_chat(
client: httpx.AsyncClient,
base: str,
user_text: str,
session_id: str,
chat_history: list[dict],
sema: asyncio.Semaphore,
delay_s: float,
) -> dict[str, Any]:
"""One /api/chat call. Returns raw response dict + latency_ms.
On HTTP 5xx, retries up to 3x with exponential backoff (3s, 8s, 18s).
"""
payload = {
"user_text": user_text,
"session_id": session_id,
"chat_history": chat_history,
"profile": {},
"return_audio": False,
"tts_language_code": "en-IN",
}
backoff = [3.0, 8.0, 18.0]
for attempt in range(len(backoff) + 1):
async with sema:
await asyncio.sleep(delay_s) # global rate-limit token
t0 = time.monotonic()
try:
resp = await client.post(f"{base}/api/chat", json=payload, timeout=60.0)
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError) as e:
if attempt < len(backoff):
await asyncio.sleep(backoff[attempt])
continue
return {"error": f"{type(e).__name__}: {e}", "latency_ms": int((time.monotonic() - t0) * 1000)}
elapsed = int((time.monotonic() - t0) * 1000)
if resp.status_code >= 500 and attempt < len(backoff):
await asyncio.sleep(backoff[attempt])
continue
if resp.status_code != 200:
return {
"error": f"http_{resp.status_code}",
"body": resp.text[:500],
"latency_ms": elapsed,
}
data = resp.json()
data["_latency_ms"] = elapsed
return data
return {"error": "retries_exhausted"}
async def run_persona(
client: httpx.AsyncClient,
base: str,
persona: dict[str, Any],
flow: list[str],
out_dir: Path,
sema: asyncio.Semaphore,
delay_s: float,
log_lock: asyncio.Lock,
) -> None:
out_file = out_dir / f"{persona['persona_id']}.json"
partial_file = out_dir / f"{persona['persona_id']}.partial.json"
if out_file.exists():
async with log_lock:
print(f" skip {persona['persona_id']} (already done)", flush=True)
return
session_id = f"audit_{persona['persona_id']}_{uuid.uuid4().hex[:6]}"
history: list[dict] = []
transcript: list[dict] = []
t_persona_start = time.monotonic()
for turn_idx, user_text in enumerate(flow, 1):
resp = await post_chat(client, base, user_text, session_id, history, sema, delay_s)
if "error" in resp:
transcript.append({
"turn": turn_idx,
"user_text": user_text,
"error": resp.get("error"),
"body": resp.get("body"),
"latency_ms": resp.get("latency_ms") or resp.get("_latency_ms"),
})
async with log_lock:
print(f" ERR {persona['persona_id']} t{turn_idx:02d}: {resp.get('error')}", flush=True)
continue
reply_text = resp.get("reply_text", "")
transcript.append({
"turn": turn_idx,
"user_text": user_text,
"reply_text": reply_text,
"brain_used": resp.get("brain_used"),
"intent": resp.get("intent"),
"language": resp.get("language"),
"citations": resp.get("citations", []),
"profile_updates": resp.get("profile_updates", {}),
"faithfulness_passed": resp.get("faithfulness_passed"),
"faithfulness_reasons": resp.get("faithfulness_reasons", []),
"blocked": resp.get("blocked"),
"latency_ms": resp.get("_latency_ms"),
"session_id": resp.get("session_id"),
})
history.append({"role": "user", "content": user_text})
history.append({"role": "assistant", "content": reply_text})
# Save partial every 5 turns so a kill doesn't lose all work.
if turn_idx % 5 == 0:
partial_file.write_text(json.dumps({
"persona": persona,
"session_id": session_id,
"turns": transcript,
"in_progress": True,
}, indent=2, ensure_ascii=False))
async with log_lock:
blk = sum(1 for t in transcript if t.get("blocked"))
elapsed = time.monotonic() - t_persona_start
print(f" ... {persona['persona_id']} t{turn_idx:02d}/30 "
f"({elapsed:.0f}s, refusals={blk}, last_brain={resp.get('brain_used')})",
flush=True)
payload = {
"persona": persona,
"session_id": session_id,
"turns": transcript,
"completed_turns": len(transcript),
"errors": sum(1 for t in transcript if t.get("error")),
}
out_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
if partial_file.exists():
partial_file.unlink()
async with log_lock:
ok = sum(1 for t in transcript if not t.get("error"))
elapsed = time.monotonic() - t_persona_start
print(f" done {persona['persona_id']:<5} | {ok}/{len(transcript)} ok | "
f"refusals={sum(1 for t in transcript if t.get('blocked'))} | "
f"{elapsed:.0f}s | out={out_file.name}", flush=True)
async def worker(
worker_id: int,
queue: asyncio.Queue,
base: str,
out_dir: Path,
sema: asyncio.Semaphore,
delay_s: float,
log_lock: asyncio.Lock,
) -> None:
async with httpx.AsyncClient() as client:
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
persona, flow = item
try:
await run_persona(client, base, persona, flow, out_dir, sema, delay_s, log_lock)
except Exception as e:
async with log_lock:
print(f" ERR {persona['persona_id']}: {type(e).__name__}: {e}")
queue.task_done()
async def main_async(args: argparse.Namespace) -> None:
personas = json.loads((ROOT / "personas.json").read_text())
flows = json.loads((ROOT / "flows.json").read_text())
if args.max_personas:
personas = personas[: args.max_personas]
run_id = args.run_id or time.strftime("%Y%m%d_%H%M%S")
out_dir = RESULTS_ROOT / run_id / "transcripts"
out_dir.mkdir(parents=True, exist_ok=True)
summary_file = RESULTS_ROOT / run_id / "summary.json"
print(f"=== audit run_id={run_id} ===")
print(f" base: {args.base}")
print(f" personas: {len(personas)}")
print(f" total turns: {sum(len(flows[p['persona_id']]) for p in personas)}")
print(f" workers: {args.workers}")
print(f" delay_s: {args.delay} (per dispatch)")
print(f" out: {out_dir}")
sema = asyncio.Semaphore(args.workers)
queue: asyncio.Queue = asyncio.Queue()
for p in personas:
await queue.put((p, flows[p["persona_id"]]))
# Sentinel to stop workers
for _ in range(args.workers):
await queue.put(None)
log_lock = asyncio.Lock()
started = time.time()
workers = [
asyncio.create_task(worker(i, queue, args.base, out_dir, sema, args.delay, log_lock))
for i in range(args.workers)
]
await queue.join()
await asyncio.gather(*workers)
elapsed = time.time() - started
completed = sorted(out_dir.glob("*.json"))
summary_file.write_text(json.dumps({
"run_id": run_id,
"base": args.base,
"personas_requested": len(personas),
"personas_completed": len(completed),
"elapsed_seconds": int(elapsed),
"workers": args.workers,
"delay_s": args.delay,
"transcripts_dir": str(out_dir),
}, indent=2))
print(f"\n=== done in {elapsed:.0f}s ===")
print(f" completed: {len(completed)} / {len(personas)} personas")
print(f" summary: {summary_file}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base", default=DEFAULT_BASE)
parser.add_argument("--max-personas", type=int, default=None)
parser.add_argument("--workers", type=int, default=4, help="concurrent personas in flight")
parser.add_argument("--delay", type=float, default=1.8, help="seconds between dispatches per worker")
parser.add_argument("--run-id", default=None)
args = parser.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()