torchdocs-agent / docs /app /README.md
eliezer avihail
docs: per-package reference + showcase site (GitHub Pages) (#107)
80ceec5 unverified
|
Raw
History Blame Contribute Delete
10.5 kB

A newer version of the Gradio SDK is available: 6.22.0

Upgrade
metadata
title: The app/ package
kind: reference
package: app

app/ β€” the Gradio web app

The long-lived Gradio server that fronts the agent: the Hugging Face Space entrypoint that loads the embedding model once, streams a live reasoning trace while it works, and delegates every answer to agent/.

Why this package exists / its boundary

app/ owns UI and serving concerns only. It paints the chat surface, keeps the server up, manages concurrency and rate limiting, streams progress, and formats the final markdown. It does not answer questions β€” the moment a question needs answering, _pipeline() hands off to agent/ (agent.guard, agent.route.answer_routed) and index/ (index.embed, index.freshness).

The split is deliberate: the batch GitHub Actions runs and the eval harness call the same agent/ code with no Gradio in sight, and the app stays a thin, replaceable shell. If you are looking for retrieval, tool-calling, or JSON validation logic, it is not here β€” it is in agent/. What lives here is everything that turns a validated Answer object into a responsive web page.

The request lifecycle in the UI

respond() (app/main.py) is a generator. Gradio streams every value it yields to the same output Markdown component, so the screen updates in place with no page refresh. The sequence per question:

  1. Thinking note β€” yields the static THINKING_NOTE ("πŸ”Ž Searching the PyTorch docs…") immediately, so the first paint happens the instant Enter is pressed.
  2. Live grey trace + spinner β€” the real work (_pipeline) runs on a daemon worker thread. While it is alive, the generator loops every THINKING_TICK (0.6 s), reads the trace lines the pipeline has appended, and yields them via _render_trace(): each step in the theme's subdued grey with a turning Braille wheel (THINKING_SPINNER) on a trailing line. A multi- second wait reads as visible work rather than a freeze.
  3. Black answer β€” when the worker finishes, the generator yields the final markdown (render(answer)), replacing the whole grey trace with the answer in normal text.
  4. Freshness spinner β€” if the answer has citations and index.freshness is enabled, a stale-while-revalidate pass starts. The answer stays fully on screen while a bare wheel (just the spinner, no words) turns underneath it via run_below() β€” the user reads while the app verifies the cited pages.
  5. (Maybe) regenerated answer β€” a clean check just drops the spinner. If the cited docs drifted, freshness.refresh_pages() self-heals the stored copies, answer_routed is re-run against the fresh content, and the new answer swaps in with the ↻ FRESHNESS_NOTE. A failed check or failed regeneration silently keeps the answer already shown.

Why stream the reasoning trace and not answer tokens

The obvious move β€” stream answer tokens like a chatbot β€” does not fit this pipeline. The answer is not free prose; it is a validated JSON Answer object assembled over several tool calls (search β†’ read β†’ generate β†’ static check). There are no partial tokens to emit until the whole thing is built and validated. So instead the app streams the reasoning: the pipeline emits a short trace line per step (which docs it searched, what it found, when it starts writing) through the progress sink, and respond() renders those live. The grey trace is honest visible progress for a request whose payload can only arrive all at once.

Serving & robustness

Concern Mechanism (in app/main.py) Why
Warmup _warm_up() calls index.embed.embed_query("warmup") once at startup Loads bge-small (~130 MB) before the first request, so no user eats the cold-start; also warms the guard's topicality embed. Best-effort β€” a warmup failure is logged, not fatal.
Concurrency demo.queue(default_concurrency_limit=CONCURRENCY) Gradio defaults every event to serial concurrency_limit=1; opening the queue lets many I/O-bound requests (LLM + Neon) overlap so nobody waits in line. max_threads is lifted in step (max(40, CONCURRENCY*2)) so the thread pool β€” which also holds threads parked in 429 back-off β€” never becomes the hidden ceiling.
Backpressure max_size=QUEUE_SIZE Bounds how many requests may wait behind the workers. Under a flood, extra callers get "queue full" instead of an unbounded, forever-growing line.
Per-client rate limit _rate_limited() β€” sliding window keyed on client IP At most RATE_LIMIT questions per RATE_WINDOW seconds per IP, so one over-eager caller can't occupy every worker slot and burn the shared free-tier LLM quota. Set RATE_LIMIT=0 to disable. The bucket table self-prunes past 4096 entries.
Fail-open errors try/except in _pipeline and in the worker work() The UI must never crash or hang. Any exception is logged with type + message and the user gets the generic ERROR_NOTE; the real error never reaches the browser, since an exception string can leak hosts, model slugs, and config internals.
Smoke-test contract ERROR_NOTE contains the literal phrase "went wrong" The post-deploy smoke test (scripts/smoke_space.py) greps for that marker to detect a broken Space. Keep the phrase. The respond event is also registered with api_name="respond" so the smoke test has a stable client.predict(..., api_name="/respond") endpoint; because gradio_client.predict returns the last yielded value, the generator's final yield is always a real answer.

UX decisions & rationale

  • Send-on-Enter requires lines=1. The question Textbox is lines=1 (not 2), and this is load-bearing: Gradio only fires Textbox.submit on a bare Enter for a single-line box. A multi-line box (lines>1) treats Enter as a newline and submits on Shift+Enter instead. max_lines=6 still lets a long question grow visually β€” the submit rule keys off the lines prop, not the rendered height β€” so Enter keeps sending. Do not bump lines back to 2.
  • Theme-aware subdued grey that survives the sanitiser. Trace lines use the inline style color:var(--body-text-color-subdued) (TRACE_STYLE), a Gradio CSS variable that adapts to light/dark theme. The inline style survives Gradio's markdown sanitiser (verified on gradio 6.20), which would strip a <style> block or class. Trace text is html.escaped because a step can echo the user's untrusted query.
  • English-only copy. All UI strings (INTRO, EXAMPLES, notes) are English, matching the agent's English-only answering contract β€” no localisation layer.
  • In-place freshness swap. The freshness pass never blanks the screen or refreshes the page: it streams updates to the same output component, keeping the answer visible with only a spinner line changing underneath. Verification is invisible unless it actually changes the answer.

Tool & library choices

  • gradio β€” provides the whole web surface (Blocks, Textbox, Markdown, Examples, Button) and, crucially, the queue model that makes the app concurrent and gives it backpressure. It is also the native Hugging Face Spaces SDK, so the same file is the local dev server and the deployed entrypoint. Its generator-streaming support is what lets respond() push a live trace.
  • threading (stdlib) β€” the answer pipeline runs on a daemon worker thread so respond() can stay responsive and stream the spinner/trace instead of blocking. A threading.Lock guards the shared trace list (worker appends, generator reads) and the rate-limit buckets (_RATE_LOCK). run_below() reuses the same pattern for the freshness and regeneration passes.
  • python-dotenv β€” load_dotenv() reads local .env so python -m app.main works with NEON_URL + OpenRouter config without exporting vars by hand; on the Space the real environment already carries them, so this is a no-op there.

File by file

  • app/main.py β€” the entire app: config constants and the TORCHDOCS_* env-var reads; the rate limiter (_rate_limited); warmup (_warm_up); the pure answer pipeline (_pipeline, no UI concerns) and markdown assembly (render, _render_trace); the streaming UI generator (respond) with its freshness pass; and the wiring (build_ui, serve, main). Running it as python -m app.main starts a local server.
  • app/__init__.py β€” empty package marker (present but zero-length); the module is imported as app.main.
  • app.py (repo root) β€” the Hugging Face Spaces entrypoint shim. It imports _warm_up, build_ui, and serve from app.main, warms the model, and builds the demo at import time (Spaces looks for a module-level demo), then calls serve(demo) under __main__ for local runs. It exists so the Space has a top-level app.py while the real code stays in the app/ package β€” the two share serve() so bind settings never drift between entrypoints.

Configuration

All read once at import in app/main.py; override per deploy in the Space's Variables and secrets.

Env var Default What it controls
TORCHDOCS_CONCURRENCY 16 How many questions are answered at once (Gradio default_concurrency_limit, and the floor for max_threads). Generous because requests are almost all I/O wait.
TORCHDOCS_QUEUE_SIZE 64 Queue max_size: how many requests may wait behind the workers before new ones are rejected with "queue full".
TORCHDOCS_RATE_LIMIT 8 Max questions per client IP per window. 0 disables the throttle.
TORCHDOCS_RATE_WINDOW_SECONDS 60 Length of the sliding rate-limit window, in seconds.
PORT 7860 Server bind port (server_name is fixed to 0.0.0.0).

Answering-side config (NEON_URL, the OpenRouter/LLM provider vars, TORCHDOCS_LIVE_HYDRATE) is consumed by agent/ and index/, not by this package β€” see the deploy runbook.

Related docs