support-chatbot

August 20, 2026 · View on GitHub

Provides level-1 support for 510's products and services.

Description

Synopsis: a dockerized python API that serves a chatbot. Based on langchain / langgraph and Azure OpenAI models. Observability and evaluations through Langfuse. Uses uv for dependency management.

Manuals

The chatbot can serve multiple manuals (documentation sites). Each manual is defined in manuals.yaml:

manuals:
  "121":
    # Required: crawling settings.
    root_url: "https://manual.121.global/en/" 
    base_url: "https://manual.121.global/en/"
    exclude_dirs:
      - "https://manual.121.global/en/nlrc"
    # Optional: split pages into chunks; omit to keep one page = one document.
    chunk_size: 1000
    chunk_overlap: 200
    # Optional: drop navigation/header/footer text repeated across pages.
    strip_boilerplate: true
    boilerplate_threshold: 0.9

Settings:

  • root_url (required): the seed page where crawling starts (the first URL fetched).
  • base_url (required): the scope boundary: a page is crawled only if it shares base_url's host and sits at or below its path. Usually identical to root_url; set it differently only when the entry point and allowed scope diverge (e.g. seed from a deep landing page while allowing a broader subtree).
  • exclude_dirs (optional, default none): URL prefixes removed from an otherwise in-scope crawl.
  • chunk_size / chunk_overlap (optional): split each page into overlapping chunks of at most chunk_size characters, with chunk_overlap characters shared between consecutive chunks to preserve context across boundaries. Omit both to index whole pages without splitting.
  • strip_boilerplate (optional, default true): remove navigation, header, and footer text that repeats across pages before indexing.
  • boilerplate_threshold (optional, default 0.9): when strip_boilerplate is enabled, a line is treated as boilerplate once it appears in at least this fraction (01) of the crawled pages.

To add a manual: add an entry to manuals.yaml, create its prompt in Langfuse (see Prompts), then index it (see below).

Each manual is stored in its own search index named support-chatbot-index-{manual_id} (e.g. support-chatbot-index-121). Non-prod deployments append the environment name (e.g. support-chatbot-index-121-dev) so that ingesting from dev never overwrites the prod index; set ENVIRONMENT accordingly per deployment (prod keeps the bare name).

Usage

  • POST /ask: body {"question": "...", "manual_id": "121"}. Selects the manual's prompt, searches its index, and returns the answer with inline [n] citations and the backing sources. Optional body fields: session_id (groups a user's turns into one conversation/memory thread; a fresh id is generated per request when omitted, i.e. stateless) and user_id (attributed in Langfuse tracing).
  • POST /ask/stream: same request body, but streams the answer as newline-delimited JSON (NDJSON): {"type": "token", "text": ...} fragments, a final {"type": "done", "trace_id": ..., "sources": [...]}, or {"type": "error", "message": ...} if generation fails mid-stream.
  • POST /ingest-manual?manual_id=121: scrapes the manual and rebuilds its index.

Prompts

System prompts are loaded at runtime from Langfuse prompt management (not from files), so they can be edited and versioned without a redeploy. Three text prompts must exist:

  • citations: product-agnostic; adds inline [n] citations to answers.
  • direct-answer: product-agnostic; steers conversational / off-topic turns that skip retrieval (greetings, small talk, out-of-scope requests). Appended to the manual's system prompt on the router's direct branch.
  • <manual_id>: one per manual/product (e.g. 121); used as that manual's system prompt.

Each <manual_id> prompt must use the section structure # Role, # Scope, # Answering, # Conversation. The direct branch reuses the manual prompt but appends direct-answer, which instructs the model to follow # Role / # Scope / # Conversation and ignore # Answering (the document-grounding rules, including the "no documents retrieved" canned reply, apply only when context is present). Keep these exact headers across manuals, or the override silently no-ops.

The prompt version fetched is selected by a Langfuse label derived from ENVIRONMENT: prod maps to the Production label; other environments use their own name (e.g. dev).

Observability & evaluation

Every question first passes through a router step that decides whether it needs the manual (retrieve) or is a greeting / small talk / off-topic message (direct). Only the retrieve branch runs retrieval; direct turns answer conversationally with no document lookup (saving latency, cost, and tokens).

On the retrieve branch a contextualize step rewrites the latest message into a standalone question using recent history (history-aware retrieval), so a follow-up like "are you sure?" becomes an explicit question. That standalone query is used both for retrieval and for evaluation (see below); first turns skip the rewrite. The answer itself is still generated from the full conversation.

When the Langfuse keys are set, each /ask and /ask/stream request is traced as a Langfuse root span named chat-turn (one per request); the router, contextualize, retrieval, and LLM steps nest underneath it. The root span exposes:

  • input: the user's latest message, verbatim (plain string).

  • output: the final answer.

  • metadata.retrieval_used: true when the router chose retrieval.

  • metadata.retrieved_context: the numbered [n] page_content block exactly as fed to the model; present only when retrieval_used is true (so a faithfulness judge sees the same context the answer was grounded on).

  • metadata.search_query: the history-aware standalone question used for retrieval; present only when retrieval_used is true.- metadata.conversation_history: the prior question/answer turns as a plain transcript (no retrieved context, current turn excluded) — recorded on every turn for user-facing judges such as a distress evaluator. retrieved_context lives on the chat-turn span, not on the child generate / AzureChatOpenAI observations. When configuring an observation-level evaluator (e.g. Faithfulness or Context Relevance), target the chat-turn observation, filter on metadata.retrieval_used = true (so the judge skips chitchat / off-topic turns that never retrieved), and map:

  • {{context}}Metadata, JsonPath $.retrieved_context

  • {{answer}}Output (no JsonPath)

  • {{question}}Metadata, JsonPath $.search_query (the standalone question, not the raw span input, so multi-turn follow-ups are scored against a self-contained question)

Targeting chat-turn (rather than the LLM generation) also ensures the judge runs once per answer instead of on every internal LLM call. Traces are tagged with manual:<manual_id>, carry the request's session_id/user_id, and are namespaced by the ENVIRONMENT value (filterable in the Langfuse UI).

Configuration

cp example.env .env

Edit the provided ENV-variables accordingly.

Read endpoints require AUTH_API_KEY. Write endpoints (for vector store refresh) require AUTH_API_KEY_WRITE.

Environment variables

Required:

VariableDescription
AUTH_API_KEYAPI key for read endpoints (/ask, /ask/stream, /feedback).
AUTH_API_KEY_WRITEAPI key for write endpoints (/ingest-manual).
VECTOR_STORE_ADDRESSAzure AI Search endpoint URL.
VECTOR_STORE_PASSWORDAzure AI Search admin/query key.
AZURE_OPENAI_ENDPOINTAzure OpenAI resource endpoint.
AZURE_OPENAI_API_KEYAzure OpenAI API key.
AZURE_OPENAI_API_VERSIONAzure OpenAI API version.
MODEL_CHATAzure OpenAI chat deployment name (not the model name).
MODEL_EMBEDDINGSAzure OpenAI embeddings deployment name.
LANGFUSE_PUBLIC_KEYLangfuse public key. Required because system prompts are loaded from Langfuse (see Prompts).
LANGFUSE_SECRET_KEYLangfuse secret key. Required for the same reason.

Optional:

VariableDefaultDescription
PORT8000Port the API binds to.
ENVIRONMENTprodDeployment environment. Namespaces vector-store indexes, selects the Langfuse prompt label, and tags Langfuse traces. prod keeps bare index names / the Production label.
RETRIEVAL_K5Manual pages retrieved per question. Lower reduces latency/cost at the risk of missing context.
CITATIONS_ENABLEDtrueAdd inline [n] citations mapping answers to sources.
LANGFUSE_BASE_URL(Langfuse cloud)Base URL of a self-hosted Langfuse. Set only when not using Langfuse cloud.

Setting the Langfuse keys also enables LLM tracing (latency, token usage, user feedback) in addition to prompt loading.

Run locally

First initialize the API

uv sync
uv run uvicorn main:app --reload

Then initialize the interface

uv run streamlit run interface/app.py

Run with Docker

docker build -t support-chatbot .
docker run --env-file .env -p 8000:8000 support-chatbot

Run tests

uv run -- python -m pytest tests -v

AI Disclaimer

Parts of the code in this repository were written and reviewed with the assistance of AI tools, including large language models (LLMs). All AI-generated code has been reviewed by human contributors before being merged. The humans involved take responsibility for the correctness and quality of the code. If you have questions or concerns, please contact the maintainers.