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:
2
.env.example
Normal file
2
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
data/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.ruff_cache/
|
||||
.streamlit/secrets.toml
|
||||
11
.streamlit/config.toml
Normal file
11
.streamlit/config.toml
Normal 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
33
CONTEXT.md
Normal 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
36
README.md
Normal 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
357
app.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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 & 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()
|
||||
3
docs/adr/0001-separate-app-tenant-profiles.md
Normal file
3
docs/adr/0001-separate-app-tenant-profiles.md
Normal 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
24
pyproject.toml
Normal 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
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
327
src/api_client.py
Normal file
327
src/api_client.py
Normal 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
111
src/chat_view.py
Normal 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
83
src/profiles.py
Normal 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
240
src/theme.py
Normal 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)
|
||||
Reference in New Issue
Block a user