feat(chat): multi-turn Chat Session with Conversation history sidebar

Grounded Ask owns the product chat UI: persistent thread_id across a
Chat Session, New chat / Continue conversation, and a sidebar that lists
Conversations from the Conversation Record with formatted run metadata
(timing, tokens, evidence chunks).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 17:00:33 +03:30
commit e693b9bbfc
14 changed files with 2510 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Defaults used when creating a Tenant Profile (keys are never committed).
API_BASE_URL=http://127.0.0.1:8000

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
data/
.venv/
__pycache__/
*.pyc
.ruff_cache/
.streamlit/secrets.toml

11
.streamlit/config.toml Normal file
View File

@@ -0,0 +1,11 @@
[server]
headless = true
port = 8502
[theme]
base = "light"
primaryColor = "#2f5d9f"
backgroundColor = "#eef3f7"
secondaryBackgroundColor = "#f7f9fb"
textColor = "#0c1a2a"
font = "sans serif"

33
CONTEXT.md Normal file
View File

@@ -0,0 +1,33 @@
# Grounded Ask
A separate operator app for uploading a tenant’s docs, asking questions, and reading answers grounded in those docs — with source metadata.
## Language
**Grounded Ask**:
The operator product for a straight pipeline: choose a tenant, upload docs, ask a question, receive an answer grounded in that tenant’s corpus plus source metadata.
_Avoid_: Hybrid Console, chatbot, chat demo, Agent Workflow, pipeline (as a product name)
**Tenant Profile**:
A locally saved operator label for one tenant (display name + API base URL + API key). Selecting a Tenant Profile is how Grounded Ask switches tenants; the API still resolves the tenant only from the key.
_Avoid_: tenant dropdown from the server, login, account switcher, Connection Profile (Hybrid Console term)
**Source**:
One provenance row on a grounded answer: domain, file_id, point_id, source_filename, chunk_index, a short content excerpt, and retrieval score. v1 Sources are the chunks retrieved for that turn (the evidence set), not model-claimed quote citations.
_Avoid_: citation-as-model-claim, hit, search result, related chunk, full neighbor context
**Ask Page**:
The single top-to-bottom Grounded Ask screen: select Tenant Profile, upload docs, ask a question, read the answer and its Sources — no wizard and no separate panes.
_Avoid_: wizard, multipage console, chat sidebar layout
**Single-turn Ask**:
One question, one answer (+ Sources), with no conversation history carried into the next question. v1 Grounded Ask does not expose multi-turn threads in the UI.
_Avoid_: chat thread, follow-up conversation, Agent Workflow multi-turn
**Domain**:
A named corpus bucket inside a tenant. Every upload in Grounded Ask requires choosing (or creating) a Domain first; Sources report which Domain evidence came from.
_Avoid_: folder (as a synonym in the UI), category, tag
**Whole-tenant Ask**:
A Single-turn Ask that searches the selected tenant’s entire corpus (all Domains). v1 has no domain filter on the question box; provenance still appears per Source.
_Avoid_: domain-scoped ask, required domain on ask

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# Grounded Ask
Upload docs, ask one question, read the answer plus the passages it used.
Domain language: [`CONTEXT.md`](CONTEXT.md) · product ADR: [`docs/adr/0001-separate-app-tenant-profiles.md`](docs/adr/0001-separate-app-tenant-profiles.md) · API Sources: `tamasino-ai-api` ADR-0023
## Prerequisites
1. `tamasino-ai-api` running
2. A tenant API key with upload scopes **and** `threads:run`:
```bash
cd ../tamasino-ai-api
uv run python -m src.cli.provision_tenant --slug acme --domain fire \
--scopes files:write,domains:read,domains:write,points:read,points:write,retrieval:read,threads:run
```
## How to use (local)
1. Open the app (usually `http://localhost:8502`).
2. **Connect** — paste an API key that includes `files:write`, `domains:read`, `domains:write`, and `threads:run` (Hybrid Console’s key is fine if it has those).
3. **Add documents** — create or pick a topic (domain), upload CSV / Excel / Word, wait for “chunks indexed”.
4. **Ask** — type a question → read the answer and **Passages used**.
Each ask is single-turn (no chat history). Questions search the whole tenant corpus.
## Setup
```bash
cd tamasino-ai-grounded-ask
cp .env.example .env
uv sync
uv run streamlit run app.py
```
Connections are stored locally in `data/tenant_profiles.json` (gitignored).

357
app.py Normal file
View File

@@ -0,0 +1,357 @@
"""Grounded Ask — single Ask Page pipeline."""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import streamlit as st
from dotenv import load_dotenv
from src.api_client import ApiClient
from src.chat_view import conversation_button_label, render_transcript, render_turn_meta
from src.profiles import (
delete_profile,
get_profile,
list_profiles,
upsert_profile,
)
from src.theme import hero, inject, section
load_dotenv()
def _escape(text: str) -> str:
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
st.set_page_config(
page_title="Grounded Ask",
page_icon="▣",
layout="wide",
initial_sidebar_state="expanded",
)
inject()
hero()
# One thread_id per Chat Session (ADR-0025): held here until "New chat" is
# clicked, or an old Conversation is reactivated via "Continue this
# conversation" — never minted fresh per question.
st.session_state.setdefault("active_thread_id", None)
st.session_state.setdefault("chat_log", [])
st.session_state.setdefault("viewing_thread_id", None)
# --- Connect -----------------------------------------------------------------
profiles = list_profiles()
labels = [f"{p.label}" for p in profiles]
id_by_label = {p.label: p.id for p in profiles}
if not profiles:
section("Connect", "You need an API key from the chatbot API (same key as Hybrid Console).")
st.markdown(
'<div class="empty-card"><h3>First-time setup</h3>'
"<p>1. Copy an API key that can upload files and run the agent "
"(<code>files:write</code>, <code>domains:*</code>, <code>threads:run</code>).<br/>"
"2. Paste it below and click <strong>Save &amp; continue</strong>.</p></div>",
unsafe_allow_html=True,
)
default_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
label = st.text_input("Name for this connection", value="Local", key="pf_label")
base_url = st.text_input("API address", value=default_url, key="pf_url")
api_key = st.text_input(
"API key",
type="password",
key="pf_key",
placeholder="sk_…",
help="Paste the full key once. It stays on this computer only.",
)
if st.button(
"Save & continue",
type="primary",
disabled=not (label.strip() and api_key.strip()),
):
saved = upsert_profile(label=label, base_url=base_url, api_key=api_key)
st.session_state["profile_choice"] = saved.label
st.rerun()
st.stop()
section("Connect", "Which API account should this page use?")
choice = st.selectbox(
"Saved connection",
options=labels,
key="profile_choice",
label_visibility="collapsed",
)
active = get_profile(id_by_label[choice])
assert active is not None
st.markdown(
f'<div class="status-ok">Connected as <strong>{_escape(active.label)}</strong> '
f"→ <code>{_escape(active.base_url)}</code></div>",
unsafe_allow_html=True,
)
with st.expander("Change connection or add another"):
default_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
label = st.text_input("Name", value=active.label, key="pf_label")
base_url = st.text_input("API address", value=active.base_url or default_url, key="pf_url")
api_key = st.text_input(
"API key",
type="password",
key="pf_key",
placeholder="Leave blank to keep the current key",
)
c1, c2 = st.columns(2)
with c1:
if st.button("Save connection", type="primary"):
key = api_key.strip() or active.api_key
if not key:
st.error("API key is required.")
else:
saved = upsert_profile(
label=label,
base_url=base_url,
api_key=key,
profile_id=active.id,
)
st.session_state["profile_choice"] = saved.label
st.success("Saved")
st.rerun()
with c2:
if st.button("Remove this connection"):
delete_profile(active.id)
st.rerun()
client = ApiClient(active)
# --- Sidebar: New chat + Conversation history (ADR-0025) ---------------------
with st.sidebar:
st.markdown("### Conversations")
if st.button("+ New chat", type="primary", use_container_width=True):
st.session_state["active_thread_id"] = None
st.session_state["chat_log"] = []
st.session_state["viewing_thread_id"] = None
st.rerun()
st.divider()
conv_list = client.list_conversations(limit=30)
if conv_list.error:
st.caption(f"History unavailable ({conv_list.error}).")
elif not conv_list.ok:
st.caption(f"History unavailable (HTTP {conv_list.status_code}).")
else:
items: list[dict] = []
if isinstance(conv_list.body_json, dict):
items = conv_list.body_json.get("items") or []
if not items:
st.caption("No conversations yet — ask something to start one.")
for item in items:
thread_id = str(item.get("thread_id", ""))
is_active = thread_id == st.session_state.get("active_thread_id")
label = ("● " if is_active else "") + conversation_button_label(item)
if st.button(label, key=f"conv_{thread_id}", use_container_width=True):
st.session_state["viewing_thread_id"] = thread_id
st.rerun()
# --- Add documents -----------------------------------------------------------
section(
"Add documents",
"Put files into a topic folder (called a domain). Skip this if you already uploaded.",
)
domains_result = client.list_domains(include_disabled=False)
domain_names: list[str] = []
if domains_result.ok and isinstance(domains_result.body_json, dict):
domain_names = [
str(item.get("domain"))
for item in domains_result.body_json.get("domains", [])
if isinstance(item, dict) and item.get("domain")
]
elif domains_result.error or (domains_result.status_code and domains_result.status_code >= 400):
st.error(
"Could not reach the API or list topics. "
f"({domains_result.status_code or domains_result.error}). "
"Check that the API is running and this key has domains:read."
)
with st.expander("Upload a document", expanded=not domain_names):
domain_mode = st.radio(
"Topic folder",
options=["Use existing", "Create new"],
horizontal=True,
key="domain_mode",
)
if domain_mode == "Use existing":
domain = st.selectbox(
"Which topic?",
options=domain_names or ["(none yet — create one)"],
disabled=not domain_names,
key="upload_domain",
)
if not domain_names:
domain = ""
else:
domain = st.text_input(
"New topic id",
placeholder="fire",
help="Short id: lowercase letters, digits, _ or - (example: fire, life)",
key="new_domain",
)
display_name = st.text_input(
"Display name",
value="",
placeholder="Fire insurance",
key="new_domain_display",
)
if st.button("Create topic", disabled=not domain.strip()):
created = client.create_domain(
domain=domain.strip(),
display_name=(display_name or domain).strip(),
)
if created.ok:
st.success(f"Topic `{domain.strip()}` is ready — upload a file next.")
st.rerun()
else:
st.error(created.error or created.body_text or f"HTTP {created.status_code}")
uploaded = st.file_uploader(
"Choose a file",
type=["csv", "xlsx", "docx", "doc"],
key="upload_file",
help="CSV, Excel, or Word.",
)
can_upload = bool(domain and domain not in ("(none yet — create one)",) and uploaded is not None)
if st.button("Upload & index", type="primary", disabled=not can_upload):
assert uploaded is not None
with st.spinner("Uploading and indexing…"):
result = client.upload_file(
domain=str(domain).strip(),
filename=uploaded.name,
content=uploaded.getvalue(),
)
if result.ok:
body = result.body_json if isinstance(result.body_json, dict) else {}
st.success(
f"Done — {body.get('chunks_indexed', '?')} chunks indexed from "
f"**{uploaded.name}**."
)
else:
st.error(result.error or result.body_text or f"HTTP {result.status_code}")
if domain_names:
st.caption("Topics available: " + ", ".join(f"`{d}`" for d in domain_names))
# --- Chat (ADR-0025: multi-turn, plus this Conversation's own history) ------
section(
"Chat",
"Searches the whole tenant corpus. Remembers this conversation until you click New chat.",
)
viewing_thread_id = st.session_state.get("viewing_thread_id")
if viewing_thread_id and viewing_thread_id != st.session_state.get("active_thread_id"):
# Read-only: browsing a past Conversation opened from the sidebar.
detail = client.list_recorded_runs(viewing_thread_id)
if detail.error or not detail.ok:
st.error(detail.error or detail.body_text or f"HTTP {detail.status_code}")
else:
runs = detail.body_json.get("runs", []) if isinstance(detail.body_json, dict) else []
st.caption(f"Viewing a past conversation · `{viewing_thread_id}`")
render_transcript(runs)
if st.button("Continue this conversation", type="primary"):
chat_log: list[dict] = []
for run in runs:
chat_log.append({"role": "user", "content": run.get("user_message") or ""})
chat_log.append(
{
"role": "assistant",
"content": run.get("assistant_message") or "",
"meta": run,
}
)
st.session_state["active_thread_id"] = viewing_thread_id
st.session_state["chat_log"] = chat_log
st.session_state["viewing_thread_id"] = None
st.rerun()
else:
# Live chat on the active Chat Session's thread_id.
for msg in st.session_state["chat_log"]:
with st.chat_message(msg["role"]):
st.write(msg["content"] or ("—" if msg["role"] == "assistant" else ""))
if msg.get("meta"):
render_turn_meta(msg["meta"])
if not st.session_state["chat_log"]:
st.caption("Ask anything about the documents you've uploaded.")
question = st.chat_input("Ask a question…")
if question:
if st.session_state.get("active_thread_id") is None:
st.session_state["active_thread_id"] = str(uuid.uuid4())
thread_id = st.session_state["active_thread_id"]
user_id = "grounded-ask-operator"
st.session_state["chat_log"].append({"role": "user", "content": question})
with st.chat_message("user"):
st.write(question)
with st.chat_message("assistant"):
placeholder = st.empty()
accumulated: list[str] = []
def on_token(text: str) -> None:
accumulated.append(text)
placeholder.write("".join(accumulated))
with st.spinner("Thinking…"):
run = client.stream_run(
thread_id, message=question, user_id=user_id, on_token=on_token
)
meta: dict | None = None
if run.stream_error:
st.error(f"{run.stream_error.get('code')}: {run.stream_error.get('message')}")
content = "".join(accumulated)
elif run.error and not run.stream_result:
st.error(run.error)
content = "".join(accumulated)
else:
content = (run.stream_result or {}).get("message") or "".join(accumulated)
# A pure escalation turn can have empty prose (the model went
# straight to the `escalate` tool call) -- match the historical
# transcript's fallback so a live turn doesn't render as a blank
# bubble.
placeholder.write(content or "—")
# The Conversation Record write happens inside stream_run's own
# `finally`, before the SSE stream closes (ADR-0024) — so by the
# time this call returns, the just-finished Run's full metadata
# (tokens, timing, chunks) is already archived and readable.
runs_after = client.list_recorded_runs(thread_id)
if runs_after.ok and isinstance(runs_after.body_json, dict):
all_runs = runs_after.body_json.get("runs") or []
if all_runs:
meta = all_runs[-1]
if meta:
render_turn_meta(meta)
st.session_state["chat_log"].append(
{"role": "assistant", "content": content, "meta": meta}
)
st.rerun()

View File

@@ -0,0 +1,3 @@
# Grounded Ask is a separate operator app with local Tenant Profiles
Grounded Ask is not a Hybrid Console page. It is its own simple Ask Page: pick a locally saved Tenant Profile (label + base URL + API key), upload into a Domain, Single-turn / Whole-tenant Ask, then show the answer plus Sources. Tenant switching is profile selection — the API still has no tenant-list HTTP surface.

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "tamasino-ai-grounded-ask"
version = "0.1.0"
description = "Grounded Ask: upload tenant docs, ask once, read the answer with Sources"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28.1",
"python-dotenv>=1.1.0",
"streamlit>=1.45.0",
]
[dependency-groups]
dev = [
"ruff>=0.11.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E402"]

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@

327
src/api_client.py Normal file
View File

@@ -0,0 +1,327 @@
"""HTTP-only client for Grounded Ask (domains, upload, SSE run)."""
from __future__ import annotations
import json
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import httpx
from src.profiles import TenantProfile
_TIMEOUT = httpx.Timeout(connect=10.0, read=150.0, write=60.0, pool=10.0)
@dataclass
class SseEvent:
event: str
data: Any
raw_data: str
@dataclass
class CallResult:
method: str
url: str
status_code: int | None
elapsed_ms: float
body_text: str
body_json: Any | None
error: str | None = None
stream_events: list[SseEvent] | None = None
timestamp: float = field(default_factory=time.time)
@property
def ok(self) -> bool:
if self.status_code is None or not (200 <= self.status_code < 300):
return False
if self.stream_events is not None:
return self.stream_error is None and self.stream_result is not None
return True
@property
def stream_result(self) -> dict[str, Any] | None:
if not self.stream_events:
return None
for item in reversed(self.stream_events):
if item.event == "result" and isinstance(item.data, dict):
return item.data
return None
@property
def stream_error(self) -> dict[str, Any] | None:
if not self.stream_events:
return None
for item in reversed(self.stream_events):
if item.event == "error" and isinstance(item.data, dict):
return item.data
return None
@property
def stream_tokens_text(self) -> str:
if not self.stream_events:
return ""
parts: list[str] = []
for item in self.stream_events:
if item.event == "token" and isinstance(item.data, dict):
text = item.data.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
class ApiClient:
def __init__(self, profile: TenantProfile) -> None:
self.profile = profile
def _headers(self, *, accept: str = "application/json") -> dict[str, str]:
if not self.profile.has_api_key:
raise ValueError("API key is required")
return {
"Accept": accept,
"Authorization": f"Bearer {self.profile.api_key.strip()}",
}
def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any | None = None,
files: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> CallResult:
url = f"{self.profile.base_url}{path}"
try:
headers = self._headers()
except ValueError as exc:
return CallResult(
method=method.upper(),
url=url,
status_code=None,
elapsed_ms=0.0,
body_text="",
body_json=None,
error=str(exc),
)
if json_body is not None and files is None:
headers["Content-Type"] = "application/json"
started = time.perf_counter()
try:
with httpx.Client(timeout=_TIMEOUT) as client:
response = client.request(
method.upper(),
url,
headers=headers,
params=params,
json=json_body,
files=files,
data=data,
)
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
body_text = response.text
try:
body_json = response.json()
except ValueError:
body_json = None
return CallResult(
method=method.upper(),
url=str(response.url),
status_code=response.status_code,
elapsed_ms=elapsed_ms,
body_text=body_text,
body_json=body_json,
)
except httpx.HTTPError as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
return CallResult(
method=method.upper(),
url=url,
status_code=None,
elapsed_ms=elapsed_ms,
body_text="",
body_json=None,
error=str(exc),
)
def list_domains(self, *, include_disabled: bool = False) -> CallResult:
return self.request(
"GET",
"/v1/domains",
params={"include_disabled": include_disabled},
)
def create_domain(self, *, domain: str, display_name: str) -> CallResult:
return self.request(
"POST",
"/v1/domains",
json_body={"domain": domain, "display_name": display_name, "metadata": {}},
)
def upload_file(self, *, domain: str, filename: str, content: bytes) -> CallResult:
return self.request(
"POST",
"/v1/files",
data={"domain": domain},
files={"file": (filename, content)},
)
def list_conversations(self, *, limit: int = 30) -> CallResult:
"""List this tenant's Conversations (Threads), newest first (ADR-0024)."""
return self.request("GET", "/v1/threads", params={"limit": limit})
def list_recorded_runs(self, thread_id: str) -> CallResult:
"""Open one Conversation: every Recorded Run, oldest first (ADR-0024)."""
return self.request("GET", f"/v1/threads/{thread_id}/runs")
def stream_run(
self,
thread_id: str,
*,
message: str,
user_id: str,
on_token: Callable[[str], None] | None = None,
) -> CallResult:
path = f"/v1/threads/{thread_id}/runs"
url = f"{self.profile.base_url}{path}"
try:
headers = self._headers(accept="text/event-stream")
except ValueError as exc:
return CallResult(
method="POST",
url=url,
status_code=None,
elapsed_ms=0.0,
body_text="",
body_json=None,
error=str(exc),
stream_events=[],
)
headers["Content-Type"] = "application/json"
started = time.perf_counter()
try:
with httpx.Client(timeout=_TIMEOUT) as client:
with client.stream(
"POST",
url,
headers=headers,
json={"message": message, "user_id": user_id},
) as response:
content_type = (response.headers.get("content-type") or "").lower()
if response.status_code >= 400 or "text/event-stream" not in content_type:
body_text = response.read().decode("utf-8", errors="replace")
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
try:
body_json = json.loads(body_text) if body_text else None
except ValueError:
body_json = None
return CallResult(
method="POST",
url=str(response.url),
status_code=response.status_code,
elapsed_ms=elapsed_ms,
body_text=body_text,
body_json=body_json,
stream_events=[],
)
events, raw_text = _consume_sse(response, on_token=on_token)
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
result_payload = None
error_payload = None
for item in events:
if item.event == "result" and isinstance(item.data, dict):
result_payload = item.data
elif item.event == "error" and isinstance(item.data, dict):
error_payload = item.data
body_json = result_payload if result_payload is not None else error_payload
dropped = (
response.status_code == 200
and result_payload is None
and error_payload is None
)
return CallResult(
method="POST",
url=str(response.url),
status_code=response.status_code,
elapsed_ms=elapsed_ms,
body_text=raw_text,
body_json=body_json,
error=(
"Stream ended without result or error event"
if dropped
else None
),
stream_events=events,
)
except httpx.HTTPError as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
return CallResult(
method="POST",
url=url,
status_code=None,
elapsed_ms=elapsed_ms,
body_text="",
body_json=None,
error=str(exc),
stream_events=[],
)
def _consume_sse(
response: httpx.Response,
*,
on_token: Callable[[str], None] | None = None,
) -> tuple[list[SseEvent], str]:
events: list[SseEvent] = []
raw_chunks: list[str] = []
event_name = "message"
data_lines: list[str] = []
def flush() -> None:
nonlocal event_name, data_lines
if not data_lines and event_name == "message":
return
raw_data = "\n".join(data_lines)
try:
parsed: Any = json.loads(raw_data) if raw_data else None
except ValueError:
parsed = raw_data
item = SseEvent(event=event_name or "message", data=parsed, raw_data=raw_data)
events.append(item)
if (
on_token is not None
and item.event == "token"
and isinstance(item.data, dict)
and isinstance(item.data.get("text"), str)
):
on_token(item.data["text"])
event_name = "message"
data_lines = []
for line_bytes in response.iter_lines():
line = line_bytes.decode("utf-8") if isinstance(line_bytes, bytes) else line_bytes
raw_chunks.append(line + "\n")
if line == "":
flush()
continue
if line.startswith(":"):
continue
if line.startswith("event:"):
event_name = line[6:].lstrip()
continue
if line.startswith("data:"):
value = line[5:]
if value.startswith(" "):
value = value[1:]
data_lines.append(value)
continue
if data_lines or event_name != "message":
flush()
return events, "".join(raw_chunks)

111
src/chat_view.py Normal file
View File

@@ -0,0 +1,111 @@
"""Rendering helpers for the Chat + History page (ADR-0025).
Turns a Recorded Run (`GET /v1/threads/{id}/runs` item) into formatted,
human-readable pieces instead of a raw `st.json()` dump — the console-styled
look this page is explicitly moving away from. A collapsed "Raw JSON" escape
hatch is kept for anyone who wants the exact wire shape.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import streamlit as st
def fmt_dt(value: Any) -> str:
"""A short, local, human-readable timestamp. Falls back to the raw value."""
if not value:
return "—"
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return parsed.strftime("%b %d, %H:%M:%S")
except ValueError:
return str(value)
def conversation_button_label(item: dict[str, Any]) -> str:
thread_id = str(item.get("thread_id", ""))
run_count = item.get("run_count", 0)
status = str(item.get("last_status", "—"))
when = fmt_dt(item.get("last_completed_at"))
turn_word = "turn" if run_count == 1 else "turns"
return f"{when} · {run_count} {turn_word} · {status} · {thread_id[:8]}…"
def run_meta_caption(run: dict[str, Any]) -> str:
"""One-line summary: timing, tokens, route, chunk count."""
parts = [f"{run.get('duration_ms', '—')} ms"]
total_tokens = run.get("total_tokens")
if total_tokens:
parts.append(f"{total_tokens:,} tokens")
route = run.get("triage_route")
if route:
parts.append(f"routed to {route}")
evidence = run.get("evidence_snapshot") or []
if evidence:
chunk_word = "chunk" if len(evidence) == 1 else "chunks"
parts.append(f"{len(evidence)} {chunk_word} used")
status = run.get("status")
if status and status != "answered":
parts.append(str(status))
return " · ".join(parts)
def render_run_banners(run: dict[str, Any]) -> None:
"""Escalation/error notices — always visible, never tucked in a collapsed
expander, since these are the two outcomes a user needs to notice."""
if run.get("escalation_reason"):
st.info(
f"The bot also offered to hand this to a person — reason: "
f"`{run.get('escalation_reason')}`. {run.get('escalation_summary') or ''}"
)
if run.get("error_code"):
st.error(f"{run.get('error_code')}: {run.get('error_message') or ''}")
def render_run_details(run: dict[str, Any]) -> None:
"""Collapsed expander with the LLM Call Ledger, Evidence Snapshot, and raw JSON."""
llm_calls = run.get("llm_calls") or []
evidence = run.get("evidence_snapshot") or []
if not llm_calls and not evidence:
return
with st.expander("Details", expanded=False):
if llm_calls:
st.markdown("**Model calls**")
for call in llm_calls:
st.markdown(
f"- `{call.get('node')}` · `{call.get('model')}` · "
f"{call.get('input_tokens', 0):,} in / {call.get('output_tokens', 0):,} out · "
f"{call.get('latency_ms', '—')} ms"
)
if evidence:
st.markdown("**Chunks used**")
for chunk in evidence:
st.markdown(
f"`{chunk.get('domain')}` / {chunk.get('source_filename')} "
f"#{chunk.get('chunk_index')} · score {chunk.get('score')}"
)
st.text(chunk.get("content") or "")
if st.checkbox("Raw JSON", key=f"raw_{run.get('run_id')}"):
st.json(run)
def render_turn_meta(run: dict[str, Any]) -> None:
"""Everything under one assistant bubble: caption, banners, details."""
st.caption(run_meta_caption(run))
render_run_banners(run)
render_run_details(run)
def render_transcript(runs: list[dict[str, Any]]) -> None:
"""Read the whole Conversation as chat bubbles, oldest first."""
for run in runs:
with st.chat_message("user"):
st.write(run.get("user_message") or "")
with st.chat_message("assistant"):
st.write(run.get("assistant_message") or "—")
render_turn_meta(run)

83
src/profiles.py Normal file
View File

@@ -0,0 +1,83 @@
"""Local Tenant Profile store (label + base URL + API key)."""
from __future__ import annotations
import json
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
_DATA_DIR = _ROOT / "data"
_STORE = _DATA_DIR / "tenant_profiles.json"
@dataclass
class TenantProfile:
id: str
label: str
base_url: str
api_key: str
@property
def has_api_key(self) -> bool:
return bool(self.api_key.strip())
def _ensure_store() -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
if not _STORE.exists():
_STORE.write_text("[]\n", encoding="utf-8")
def list_profiles() -> list[TenantProfile]:
_ensure_store()
raw = json.loads(_STORE.read_text(encoding="utf-8"))
return [TenantProfile(**item) for item in raw]
def save_profiles(profiles: list[TenantProfile]) -> None:
_ensure_store()
payload = [asdict(profile) for profile in profiles]
_STORE.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def upsert_profile(
*,
label: str,
base_url: str,
api_key: str,
profile_id: str | None = None,
) -> TenantProfile:
profiles = list_profiles()
cleaned = TenantProfile(
id=profile_id or str(uuid.uuid4()),
label=label.strip(),
base_url=base_url.strip().rstrip("/"),
api_key=api_key.strip(),
)
replaced = False
next_profiles: list[TenantProfile] = []
for existing in profiles:
if existing.id == cleaned.id or (
profile_id is None and existing.label.lower() == cleaned.label.lower()
):
next_profiles.append(cleaned)
replaced = True
else:
next_profiles.append(existing)
if not replaced:
next_profiles.append(cleaned)
save_profiles(next_profiles)
return cleaned
def delete_profile(profile_id: str) -> None:
save_profiles([p for p in list_profiles() if p.id != profile_id])
def get_profile(profile_id: str) -> TenantProfile | None:
for profile in list_profiles():
if profile.id == profile_id:
return profile
return None

240
src/theme.py Normal file
View File

@@ -0,0 +1,240 @@
"""Ask Page theme — evidence-ledger look, distinct from Hybrid Console teal."""
from __future__ import annotations
import streamlit as st
CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Figtree:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--ink: #0c1a2a;
--slate: #243447;
--paper: #e8eef4;
--panel: #f7f9fb;
--line: #c5d0db;
--cobalt: #2f5d9f;
--cobalt-soft: #d7e4f5;
--ok: #1f6b4a;
--warn: #9a5b12;
--rose: #9f1239;
}
html, body, [class*="css"] {
font-family: "Figtree", system-ui, sans-serif;
}
.stApp {
background:
radial-gradient(900px 420px at 0% -10%, #d5e3f2 0%, transparent 55%),
radial-gradient(700px 380px at 100% 0%, #dde6ef 0%, transparent 50%),
linear-gradient(180deg, #eef3f7 0%, #e2e9f0 100%);
}
.ask-hero {
margin: 0 0 1rem 0;
padding: 1.25rem 1.4rem 1.15rem;
border: 1px solid var(--line);
border-radius: 4px;
background: rgba(247,249,251,0.92);
}
.ask-kicker {
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--cobalt);
font-weight: 500;
margin: 0 0 0.35rem 0;
}
.ask-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 700;
color: var(--ink);
margin: 0;
letter-spacing: -0.02em;
line-height: 1.15;
}
.ask-sub {
color: var(--slate);
margin: 0.45rem 0 0 0;
font-size: 1.05rem;
max-width: 36rem;
}
.howto {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 1rem 0 1.35rem 0;
}
@media (max-width: 800px) {
.howto { grid-template-columns: 1fr; }
}
.howto-step {
background: #fff;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.85rem 1rem;
}
.howto-n {
font-family: "IBM Plex Mono", monospace;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--cobalt);
margin: 0 0 0.35rem 0;
}
.howto-t {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
font-weight: 600;
color: var(--ink);
margin: 0 0 0.25rem 0;
}
.howto-d {
margin: 0;
color: var(--slate);
font-size: 0.9rem;
line-height: 1.4;
}
.section-label {
font-family: "IBM Plex Mono", monospace;
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cobalt);
margin: 1.5rem 0 0.35rem 0;
}
.section-hint {
color: var(--slate);
font-size: 0.92rem;
margin: 0 0 0.75rem 0;
}
.status-ok {
display: inline-block;
background: #e6f4ec;
color: var(--ok);
border: 1px solid #b7dec8;
border-radius: 4px;
padding: 0.35rem 0.65rem;
font-size: 0.88rem;
margin: 0 0 0.75rem 0;
}
.empty-card {
background: #fff;
border: 1px dashed var(--line);
border-radius: 4px;
padding: 1.1rem 1.2rem;
margin: 0.5rem 0 1rem 0;
}
.empty-card h3 {
font-family: "Fraunces", Georgia, serif;
margin: 0 0 0.4rem 0;
font-size: 1.25rem;
color: var(--ink);
}
.empty-card p {
margin: 0;
color: var(--slate);
line-height: 1.45;
}
.ledger {
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
overflow: hidden;
}
.ledger-row {
display: grid;
grid-template-columns: 4.5rem 1fr;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
}
.ledger-row:last-child { border-bottom: none; }
.ledger-meta {
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
color: var(--cobalt);
}
.ledger-body { color: var(--ink); font-size: 0.92rem; }
.answer-panel {
border-left: 4px solid var(--cobalt);
background: #fff;
padding: 1rem 1.1rem;
border-radius: 0 4px 4px 0;
border: 1px solid var(--line);
border-left-width: 4px;
margin: 0.5rem 0 1rem 0;
white-space: pre-wrap;
}
.mono {
font-family: "IBM Plex Mono", monospace;
font-size: 0.8rem;
}
</style>
"""
def inject() -> None:
st.markdown(CSS, unsafe_allow_html=True)
def hero() -> None:
st.markdown(
"""
<div class="ask-hero">
<p class="ask-kicker">Grounded Ask</p>
<h1 class="ask-title">Ask your documents</h1>
<p class="ask-sub">Upload insurance docs, ask one question, get an answer with the passages it used.</p>
</div>
<div class="howto">
<div class="howto-step">
<p class="howto-n">Step 1</p>
<p class="howto-t">Connect</p>
<p class="howto-d">Paste your API key once. We save it on this machine as a profile.</p>
</div>
<div class="howto-step">
<p class="howto-n">Step 2</p>
<p class="howto-t">Add documents</p>
<p class="howto-d">Pick a topic folder (domain), upload CSV / Excel / Word.</p>
</div>
<div class="howto-step">
<p class="howto-n">Step 3</p>
<p class="howto-t">Ask</p>
<p class="howto-d">Type a question. Read the answer and the source excerpts below it.</p>
</div>
</div>
""",
unsafe_allow_html=True,
)
def section(label: str, hint: str = "") -> None:
st.markdown(f'<p class="section-label">{label}</p>', unsafe_allow_html=True)
if hint:
st.markdown(f'<p class="section-hint">{hint}</p>', unsafe_allow_html=True)

1275
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff