Download eval/chat_sim/run_chat.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/eval/chat_sim/run_chat.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@refs/pr/21/eval/chat_sim/run_chat.py
-
curl -L -o run_chat.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F21/eval/chat_sim/run_chat.py
21.7 kB
| """End-to-end chat simulator with full step transparency (in-process). | |
| Simulates a user chatting inside ONE analysis session, from creation onward, and | |
| prints what each step of the pipeline does: | |
| - the ROUTER decision (intent / rewritten query / confidence) | |
| - slow-path STATUS pings (Planningβ¦ / Running N stepsβ¦) | |
| - every TOOL call the slow path makes (check_data / retrieve_data / analyze_* β | |
| with result kind, row count, latency, error) | |
| - every LLM call (router / planner / assembler / chatbot / help) with | |
| input-token / output-token / latency, and a snippet of the raw model output | |
| - the streamed ANSWER + its SOURCES | |
| - a per-turn timing + token summary | |
| - finally, a REPORT generated from the slow-path report_inputs the run produced | |
| It calls `ChatHandler.handle()` IN-PROCESS (no server) so it can see the internal | |
| LLM outputs the SSE endpoint hides. Transparency is captured by injecting a custom | |
| tracer (`ScriptTracer`) into the exact seam the handler already threads its Langfuse | |
| callbacks + tool spans through β no source changes. | |
| Run as a module from the repo root (so `src` imports resolve): | |
| uv run python -m eval.chat_sim.run_chat # predefined Titanic convo + report | |
| uv run python -m eval.chat_sim.run_chat --interactive # you type the messages | |
| uv run python -m eval.chat_sim.run_chat --no-report # skip the report capstone | |
| uv run python -m eval.chat_sim.run_chat --no-bind # don't scope to Titanic (whole catalog) | |
| Needs a populated `.env` (Azure OpenAI + Postgres + Azure Blob for the Titanic | |
| Parquet). Writes to the DB the `.env` points at (analysis state + report_inputs + | |
| report) β point it at the playground DB. ENABLE_SLOW_PATH is forced on here. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import json | |
| import sys | |
| import time | |
| import uuid | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| # --- Windows: psycopg3 async needs the selector loop (mirrors run.py). Set BEFORE | |
| # anything touches asyncio. | |
| if sys.platform == "win32": | |
| asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) | |
| # Windows consoles default to cp1252 and choke on the box-drawing glyphs below. | |
| for _stream in (sys.stdout, sys.stderr): | |
| try: | |
| _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] | |
| except Exception: | |
| pass | |
| from langchain_core.callbacks import BaseCallbackHandler # noqa: E402 | |
| from langchain_core.messages import AIMessage, BaseMessage, HumanMessage # noqa: E402 | |
| from src.agents.chat_handler import ChatHandler # noqa: E402 | |
| # This user's catalog (verified in the playground DB): | |
| # tabular source 9b565bc8-β¦ = Titanic-Dataset.csv (891 rows) | |
| # schema source aaa0a4c6-β¦ = "dummy" postgres (orders/customers/products/β¦) | |
| DEFAULT_USER_ID = "4b5d1bac-7211-490f-9a3d-66fed0168d5a" | |
| TITANIC_SOURCE_ID = "9b565bc8-ccc4-4d10-9382-0bad416a091b" | |
| TITANIC_NAME = "Titanic-Dataset.csv" | |
| OBJECTIVE = "Understand what drove passenger survival on the Titanic β by sex, class, and fare." | |
| # Default scripted conversation. Chosen to exercise every router intent against the | |
| # real Titanic columns (Survived, Sex, Pclass, Age, Fare, Embarked). | |
| DEFAULT_TURNS = [ | |
| "What can you help me do in this analysis?", # -> help | |
| "What data do I have available here?", # -> check | |
| "What was the overall passenger survival rate, and how did it differ " | |
| "between male and female passengers?", # -> structured_flow | |
| "Did higher passenger class (Pclass) come with a higher average fare and " | |
| "a higher survival rate?", # -> structured_flow | |
| ] | |
| # ANSI (Windows Terminal / VS Code support it). Disable with --plain. | |
| _C = { | |
| "h": "\033[1;36m", "u": "\033[1;33m", "ai": "\033[1;32m", | |
| "dim": "\033[2m", "warn": "\033[1;31m", "r": "\033[0m", | |
| } | |
| def c(key: str, text: str) -> str: | |
| return f"{_C.get(key, '')}{text}{_C['r']}" if _C.get("_on", True) else text | |
| # βββββββββββββββββββββββββ transparency capture ββββββββββββββββββββββββββββββ | |
| class LlmCall: | |
| idx: int | |
| ms: int | None | |
| tin: int | |
| tout: int | |
| ttot: int | |
| prompt_preview: str | |
| output_preview: str | |
| masked: bool | |
| class ToolCall: | |
| tool: str | |
| arg_keys: list[str] | |
| kind: str | None | |
| rows: int | None | |
| error: str | None | |
| ms: int | |
| class Sink: | |
| """Per-turn collector shared by all StepLoggers + spans of that turn.""" | |
| llm: list[LlmCall] = field(default_factory=list) | |
| tools: list[ToolCall] = field(default_factory=list) | |
| def _usage(response: Any) -> tuple[int, int, int]: | |
| """Sum token usage off an LLMResult (usage_metadata, legacy fallback).""" | |
| tin = tout = ttot = 0 | |
| for gens in getattr(response, "generations", []) or []: | |
| for g in gens: | |
| msg = getattr(g, "message", None) | |
| um = getattr(msg, "usage_metadata", None) if msg else None | |
| if um: | |
| tin += um.get("input_tokens", 0) | |
| tout += um.get("output_tokens", 0) | |
| ttot += um.get("total_tokens", 0) | |
| if ttot == 0 and getattr(response, "llm_output", None): | |
| u = response.llm_output.get("token_usage") or {} | |
| tin += u.get("prompt_tokens", 0) | |
| tout += u.get("completion_tokens", 0) | |
| ttot += u.get("total_tokens", 0) | |
| return tin, tout, ttot | |
| def _out_text(response: Any) -> str: | |
| try: | |
| gens = response.generations | |
| g = gens[0][0] | |
| msg = getattr(g, "message", None) | |
| return (getattr(msg, "content", None) or getattr(g, "text", "") or "").strip() | |
| except Exception: | |
| return "" | |
| def _preview(text: str, n: int = 240) -> str: | |
| text = " ".join(str(text).split()) | |
| return text if len(text) <= n else text[: n - 1] + "β¦" | |
| class StepLogger(BaseCallbackHandler): | |
| """One per `tracer.callbacks()` call; all share the turn's Sink. | |
| Captures each LLM call's latency + tokens + a snippet of prompt/output. Matches | |
| start->end by run_id so concurrent/streamed calls don't cross wires. | |
| """ | |
| def __init__(self, sink: Sink, masked: bool = False) -> None: | |
| self.sink = sink | |
| self.masked = masked | |
| self._t0: dict[Any, float] = {} | |
| self._prompt: dict[Any, str] = {} | |
| def on_chat_model_start(self, serialized, messages, *, run_id=None, **kw): # type: ignore[override] | |
| self._t0[run_id] = time.perf_counter() | |
| try: | |
| flat = [m for grp in messages for m in grp] | |
| self._prompt[run_id] = _preview( | |
| next((getattr(m, "content", "") for m in flat | |
| if m.__class__.__name__.startswith("System")), "" | |
| ) or (flat[-1].content if flat else ""), 120 | |
| ) | |
| except Exception: | |
| self._prompt[run_id] = "" | |
| def on_llm_start(self, serialized, prompts, *, run_id=None, **kw): # type: ignore[override] | |
| self._t0[run_id] = time.perf_counter() | |
| self._prompt[run_id] = _preview(prompts[0] if prompts else "", 120) | |
| def on_llm_end(self, response, *, run_id=None, **kw): # type: ignore[override] | |
| t0 = self._t0.pop(run_id, None) | |
| ms = round((time.perf_counter() - t0) * 1000) if t0 else None | |
| tin, tout, ttot = _usage(response) | |
| self.sink.llm.append(LlmCall( | |
| idx=len(self.sink.llm) + 1, ms=ms, tin=tin, tout=tout, ttot=ttot, | |
| prompt_preview=self._prompt.pop(run_id, ""), | |
| output_preview=_preview(_out_text(response)), | |
| masked=self.masked, | |
| )) | |
| class ScriptSpan: | |
| """Mirrors tracing._ToolSpan: a metadata-only span around one slow-path tool call.""" | |
| def __init__(self, sink: Sink, tool: str, args: dict) -> None: | |
| self.sink = sink | |
| self.tool = tool | |
| self.args = args | |
| self.t0 = time.perf_counter() | |
| def end(self, out: Any) -> None: | |
| kind = getattr(out, "kind", None) | |
| rows = len(getattr(out, "rows", None) or []) if kind == "table" else None | |
| err = getattr(out, "error", None) | |
| self.sink.tools.append(ToolCall( | |
| tool=self.tool, | |
| arg_keys=sorted(self.args) if isinstance(self.args, dict) else [], | |
| kind=kind, rows=rows, | |
| error=_preview(err, 160) if err else None, | |
| ms=round((time.perf_counter() - self.t0) * 1000), | |
| )) | |
| class ScriptTracer: | |
| """Drop-in for RequestTracer/NullTracer. active=True so the slow path wraps its | |
| ToolInvoker in TracingToolInvoker and routes tool spans here.""" | |
| active = True | |
| def __init__(self, sink: Sink) -> None: | |
| self.sink = sink | |
| def callbacks(self, *, masked: bool = False) -> list: | |
| return [StepLogger(self.sink, masked)] | |
| def tool_span(self, tool: str, args: dict) -> Any: | |
| return ScriptSpan(self.sink, tool, args) | |
| def end(self, *, output: Any = None) -> None: | |
| return None | |
| class InstrumentedChatHandler(ChatHandler): | |
| """ChatHandler that emits our ScriptTracer instead of Langfuse/Null, so every | |
| LLM + tool step of a turn lands in `self.sink`.""" | |
| def __init__(self, *a, **k) -> None: | |
| super().__init__(*a, **k) | |
| self.sink = Sink() | |
| def _make_tracer(self, user_id: str, question: str) -> Any: # type: ignore[override] | |
| return ScriptTracer(self.sink) | |
| # βββββββββββββββββββββββββββββ pretty printing βββββββββββββββββββββββββββββββ | |
| def banner(text: str, ch: str = "β") -> None: | |
| print(f"\n{c('h', ch * 78)}\n{c('h', text)}\n{c('h', ch * 78)}") | |
| def _llm_labels(intent: str | None, n: int) -> list[str]: | |
| """Best-effort name per LLM call, by the path's known call order.""" | |
| seq = { | |
| "structured_flow": ["router", "planner", "assembler"], | |
| "help": ["router", "help"], | |
| "unstructured_flow": ["router", "chatbot"], | |
| "chat": ["router", "chatbot"], | |
| "check": ["router"], | |
| }.get(intent or "", ["router"]) | |
| out = [] | |
| for i in range(n): | |
| if i < len(seq) - 1: | |
| out.append(seq[i]) | |
| elif i == n - 1: | |
| out.append(seq[-1]) # last call = final author | |
| else: | |
| out.append(f"{seq[1] if len(seq) > 1 else 'llm'}Β·retry") | |
| return out | |
| def print_turn_steps(sink: Sink, intent: str | None, total_ms: int) -> None: | |
| if sink.tools: | |
| print(c("dim", "\n tool calls (slow path):")) | |
| for t in sink.tools: | |
| tag = c("warn", "ERROR") if t.error else (t.kind or "ok") | |
| extra = f" rows={t.rows}" if t.rows is not None else "" | |
| print(f" β’ {t.tool:<18} {tag:<7}{extra:<10} {t.ms:>5}ms" | |
| f" args={t.arg_keys}") | |
| if t.error: | |
| print(c("warn", f" β³ {t.error}")) | |
| if sink.llm: | |
| labels = _llm_labels(intent, len(sink.llm)) | |
| print(c("dim", "\n llm calls (output / tokens / latency):")) | |
| print(c("dim", f" {'#':<2} {'step':<14} {'in':>6} {'out':>6} {'tot':>6} {'ms':>6}")) | |
| for call, label in zip(sink.llm, labels): | |
| ms = f"{call.ms}" if call.ms is not None else "?" | |
| print(f" {call.idx:<2} {label:<14} {call.tin:>6} {call.tout:>6} " | |
| f"{call.ttot:>6} {ms:>6}") | |
| print(c("dim", f" prompt: {call.prompt_preview}")) | |
| # Local tool over your own data β show output regardless of the masked | |
| # flag (masking only matters for Langfuse Cloud). Note when it's a | |
| # cloud-masked call or has no text (structured / tool-call output). | |
| out = call.output_preview or "<no text content β structured / tool-call output>" | |
| tag = " (maskedβcloud)" if call.masked else "" | |
| print(c("dim", f" output{tag}: {out}")) | |
| tin = sum(c_.tin for c_ in sink.llm) | |
| tout = sum(c_.tout for c_ in sink.llm) | |
| print(c("dim", f"\n ββ turn: {total_ms}ms Β· {len(sink.llm)} llm call(s) Β· " | |
| f"{len(sink.tools)} tool call(s) Β· {tin}+{tout} tokens")) | |
| # βββββββββββββββββββββββββββββ setup / turns βββββββββββββββββββββββββββββββββ | |
| async def setup_analysis(user_id: str, bind_titanic: bool) -> str: | |
| """Create a fresh analysis session (state row) + optionally bind it to Titanic. | |
| Mirrors what `/analysis/create` does: a state row carrying the goal, plus an | |
| analysis-scope `data_catalog` row (B) restricting the analysis to one source, so | |
| structured_flow is scoped deterministically. Returns the analysis_id (== room_id). | |
| """ | |
| from src.agents.state_store import AnalysisStateStore | |
| analysis_id = str(uuid.uuid4()) | |
| await AnalysisStateStore().create( | |
| analysis_id=analysis_id, | |
| user_id=user_id, | |
| analysis_title="Titanic survival analysis (sim)", | |
| objective=OBJECTIVE, | |
| ) | |
| print(f" created analysis {c('h', analysis_id)}") | |
| print(f" objective: {OBJECTIVE}") | |
| if bind_titanic: | |
| try: | |
| from datetime import UTC, datetime | |
| from src.catalog.models import Catalog as CatalogModel | |
| from src.catalog.store import CatalogStore | |
| from src.db.postgres.connection import AsyncSessionLocal | |
| from src.db.postgres.models import Catalog as CatalogRow | |
| # Scope structured_flow by seeding the analysis-scope catalog (B): the | |
| # user's catalog restricted to Titanic. structured_flow reads this row via | |
| # CatalogStore.get_by_analysis (the data_sources binding table was removed; | |
| # in production Go materializes B from analyses.data_bind). | |
| user_cat = await CatalogStore().get(user_id) | |
| titanic = [ | |
| s for s in (user_cat.sources if user_cat else []) | |
| if s.source_id == TITANIC_SOURCE_ID | |
| ] | |
| if not titanic: | |
| print(c("warn", f" Titanic source {TITANIC_SOURCE_ID} not in user " | |
| "catalog β running unscoped (whole catalog)")) | |
| else: | |
| scoped = CatalogModel( | |
| user_id=user_id, generated_at=datetime.now(UTC), sources=titanic, | |
| ) | |
| async with AsyncSessionLocal() as s: | |
| s.add(CatalogRow( | |
| scope_type="analysis", user_id=user_id, analysis_id=analysis_id, | |
| catalog_payload=scoped.model_dump(mode="json"), | |
| )) | |
| await s.commit() | |
| print(f" bound source: {TITANIC_NAME} ({TITANIC_SOURCE_ID}) " | |
| f"{c('dim', 'β structured_flow scoped to Titanic (analysis catalog)')}") | |
| except Exception as e: # noqa: BLE001 β fail-open to whole catalog | |
| print(c("warn", f" binding skipped ({type(e).__name__}: {e}) β " | |
| f"fail-open to whole catalog")) | |
| else: | |
| print(c("dim", " no binding β structured_flow sees the whole catalog")) | |
| return analysis_id | |
| async def run_turn( | |
| handler: InstrumentedChatHandler, | |
| user_id: str, | |
| analysis_id: str, | |
| message: str, | |
| history: list[BaseMessage], | |
| ) -> None: | |
| handler.sink = Sink() | |
| banner(f"USER βΈ {message}", "β") | |
| answer = "" | |
| sources: list[dict] = [] | |
| intent: str | None = None | |
| t0 = time.perf_counter() | |
| async for ev in handler.handle(message, user_id, history, analysis_id=analysis_id): | |
| kind, data = ev["event"], ev["data"] | |
| if kind == "intent": | |
| try: | |
| d = json.loads(data) | |
| intent = d.get("intent") | |
| print(f" {c('h', 'ROUTER')} β intent={c('h', intent)} " | |
| f"confidence={d.get('confidence')}") | |
| rq = d.get("rewritten_query") | |
| if rq and rq != message: | |
| print(c("dim", f" rewritten: {rq}")) | |
| except Exception: | |
| pass | |
| elif kind == "status": | |
| print(c("dim", f" Β· {data}")) | |
| elif kind == "sources": | |
| try: | |
| sources = json.loads(data) or [] | |
| except Exception: | |
| sources = [] | |
| elif kind == "chunk": | |
| answer += data | |
| elif kind == "error": | |
| print(c("warn", f" ERROR: {data}")) | |
| total_ms = round((time.perf_counter() - t0) * 1000) | |
| print(f"\n {c('ai', 'ANSWER')} βΎ") | |
| for line in (answer or "(empty)").splitlines() or ["(empty)"]: | |
| print(f" {line}") | |
| if sources: | |
| print(c("dim", f"\n sources ({len(sources)}): " | |
| + ", ".join(s.get("filename") or s.get("document_id", "?") | |
| for s in sources))) | |
| print_turn_steps(handler.sink, intent, total_ms) | |
| history.append(HumanMessage(content=message)) | |
| history.append(AIMessage(content=answer)) | |
| async def generate_report(user_id: str, analysis_id: str) -> None: | |
| """Mirror POST /report: floor check β ReportGenerator β ReportStore β print.""" | |
| banner("REPORT βΈ generating from accumulated report_inputs") | |
| from src.agents.gate import stub_analysis_state | |
| from src.agents.report.generator import ReportGenerator | |
| from src.agents.report.readiness import report_floor | |
| from src.agents.report.schemas import ProblemStatement | |
| from src.agents.report.store import ReportStore | |
| from src.agents.state_store import AnalysisStateStore | |
| state = await AnalysisStateStore().get(analysis_id) | |
| missing, _ = await report_floor( | |
| analysis_id, state or stub_analysis_state(problem_validated=False) | |
| ) | |
| if missing: | |
| print(c("warn", f" floor not met (409 in the API): {', '.join(missing)}")) | |
| print(c("dim", " β need β₯1 successful slow-path analysis first " | |
| "(did the structured turns run analyze_* tools?)")) | |
| return | |
| objective = (getattr(state, "objective", "") or | |
| getattr(state, "problem_statement", "") or "") | |
| ps = ProblemStatement( | |
| objective=objective, | |
| business_questions=list(getattr(state, "business_questions", []) or []), | |
| ) | |
| t0 = time.perf_counter() | |
| report = await ReportGenerator().generate( | |
| analysis_id, user_id, problem_statement=ps, user_name=None | |
| ) | |
| saved = await ReportStore().save(report) | |
| print(f" generated v{saved.version} in {round((time.perf_counter()-t0)*1000)}ms " | |
| f"Β· report_id={saved.report_id} Β· built from {len(saved.record_ids)} record(s)\n") | |
| print(c("dim", " ββ rendered markdown ββ")) | |
| for line in saved.rendered_markdown.splitlines(): | |
| print(f" {line}") | |
| # ββββββββββββββββββββββββββββββββ main βββββββββββββββββββββββββββββββββββββββ | |
| async def amain(args: argparse.Namespace) -> None: | |
| if args.plain: | |
| _C["_on"] = False | |
| banner("DATA EYOND β end-to-end chat simulator (in-process)") | |
| print(f" user_id: {args.user_id}") | |
| print(f" slow_path: ON tracingβterminal: ON db: (from .env)") | |
| handler = InstrumentedChatHandler( | |
| enable_tracing=False, enable_gate=False | |
| ) | |
| analysis_id = await setup_analysis(args.user_id, bind_titanic=not args.no_bind) | |
| history: list[BaseMessage] = [] | |
| if args.interactive: | |
| print(c("dim", "\n interactive mode β type a message, 'report' to generate, " | |
| "'exit' to quit.\n")) | |
| loop = asyncio.get_event_loop() | |
| while True: | |
| try: | |
| msg = (await loop.run_in_executor(None, input, "you βΈ ")).strip() | |
| except (EOFError, KeyboardInterrupt): | |
| break | |
| if not msg: | |
| continue | |
| if msg.lower() in {"exit", "quit"}: | |
| break | |
| if msg.lower() == "report": | |
| await generate_report(args.user_id, analysis_id) | |
| continue | |
| await run_turn(handler, args.user_id, analysis_id, msg, history) | |
| else: | |
| turns = DEFAULT_TURNS[: args.max_turns] if args.max_turns else DEFAULT_TURNS | |
| for msg in turns: | |
| await run_turn(handler, args.user_id, analysis_id, msg, history) | |
| if not args.no_report: | |
| await generate_report(args.user_id, analysis_id) | |
| banner("DONE") | |
| print(f" analysis_id (== room_id): {analysis_id}") | |
| print(c("dim", " state, report_inputs, and report were written to the .env DB.")) | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description="End-to-end chat simulator with step transparency") | |
| p.add_argument("--user-id", default=DEFAULT_USER_ID) | |
| p.add_argument("--interactive", action="store_true", help="type messages yourself") | |
| p.add_argument("--no-report", action="store_true", help="skip the report capstone") | |
| p.add_argument("--no-bind", action="store_true", | |
| help="don't scope to Titanic (planner sees the whole catalog)") | |
| p.add_argument("--max-turns", type=int, default=0, | |
| help="run only the first N scripted turns (cheap smoke test)") | |
| p.add_argument("--plain", action="store_true", help="disable ANSI colors") | |
| asyncio.run(amain(p.parse_args())) | |
| if __name__ == "__main__": | |
| main() | |