Local development flow¶
Audience: anyone running the stack with
docker compose upfrom the repo root. Pairs with thearchitecture.mdwalkthrough and the README §1 quick-start.
Two flows live in the same Docker Compose stack on your laptop. Both share Chroma as the persistent vector store.
At a glance¶
| Flow | When | Container | Purpose |
|---|---|---|---|
| Ask | Every POST /ask |
support-bot-api (long-running) |
Embed the question, retrieve + re-rank chunks from Chroma, generate an answer with the configured LLM, return {answer, confidence, request_id, trace} as JSON. |
| Ingestion | Once per source page (or whenever the page changes) | support-bot-ingestion (one-shot, on the ingest profile) |
Scrape → clean → analyse → chunk → embed → upsert into Chroma. |
Diagram¶
Hold "Alt" / "Option" to enable pan & zoom
flowchart TB
subgraph ING["Ingestion (one-shot, profile=ingest)"]
direction TB
JOB["ingestion container<br/>docker/ingestion.Dockerfile<br/>python -m support_bot.composition.ingestion_main"]
LOCK["FileLockIngestionRunLock<br/><LOCK_DIR>/<request_id>.lock<br/>adapters/ingestion_lock.py"]
SCRAPER["RequestsPageScraper<br/>adapters/http_source.py"]
CLEANER["BoilerplatePageCleaner<br/>adapters/cleaner.py"]
ANALYZER["LlmPageAnalyzer<br/>adapters/llm_page_analyzer.py<br/>(WP06: gpt-4o-mini)"]
CHUNKER["HybridChunker<br/>adapters/hybrid_chunker.py<br/>(default; FixedSizeChunker is fallback)"]
EMBED["OpenAIEmbedder<br/>adapters/embedding_openai.py<br/>(default: text-embedding-3-large @ 1024d)"]
UPSERT["ChromaVectorStore.upsert<br/>adapters/vectorstore_chroma.py"]
JOB -->|"acquire"| LOCK
JOB --> SCRAPER
SCRAPER -->|"SourcePage"| CLEANER
CLEANER -->|"CleanedPage"| ANALYZER
ANALYZER -->|"PageStructure"| CHUNKER
CHUNKER -->|"Chunk[]"| EMBED
EMBED -->|"vector"| UPSERT
end
subgraph ASK["Ask (per POST /ask)"]
direction TB
U(["User"]) -->|"POST /ask<br/>{question}"| API["FastAPI app<br/>composition/api_app.py"]
API -->|"RequestIdMiddleware<br/>(first in chain)"| MID["bind request_id to<br/>structlog + OTel span"]
MID --> LG["LangGraph StateGraph<br/>application/answering/graph.py"]
LG --> RET["retrieve node<br/>k=4, LexicalRerankRetriever"]
RET -->|"RetrievedChunk[]"| GUARD["guard node<br/>(records visit only)"]
GUARD -->|"decision: high"| GEN["generate node<br/>OpenAIAnswerGenerator / gpt-4o-mini"]
GUARD -->|"decision: low"| REF["refuse node<br/>ThresholdLowConfidencePolicy"]
GEN -->|"AskResponse<br/>answer, confidence='high'"| OUT(["HTTP 200<br/>{answer, confidence, request_id, trace}"])
REF -->|"AskResponse<br/>answer='I cannot answer...'<br/>confidence='low'"| OUT
end
UPSERT -->|"chromadb.HttpClient<br/>:8000"| CHROMA[("Chroma<br/>chromadb/chroma:1.5.9<br/>persistent volume")]
RET -->|"chromadb.HttpClient"| CHROMA
Step-by-step — the ask flow¶
- User → FastAPI.
POST /askwith{"question": "..."}. RequestIdMiddlewareis the first middleware. It readsX-Request-Idfrom the inbound headers or mints auuid4().hex, binds it tostructlog.contextvars, sets it as therequest.idOTel attribute on the active span, and echoesX-Request-Idon the response. Every log line, span, and metric emitted downstream in this request scope inherits this single id.AskQuestionUseCase.execute(question, *, request_id=...)builds an initialAgentStateand hands it to the compiledStateGraph.retrievenode callsRetriever.retrieve(question, k=4). In production theRetrieverisLexicalRerankRetriever(ChromaRetriever), so the top-1 chunk is already promoted by the 4-char stem-prefix re-ranker before the answerer sees it.guardnode records the visit. It does not make the decision — that's the conditional edge's job.- Conditional edge
_decidecallsLowConfidencePolicy.should_refuse(retrieved_chunks)and returns"generate"(top-1 ≥ 0.5) or"refuse". generatenode (high confidence) callsAnswerGenerator.generate(question, retrieved)→OpenAIAnswerGenerator(ChatOpenAI, defaultgpt-4o-mini). The system prompt forbids external knowledge.refusenode (low confidence) returns the fixed string"I cannot answer based on the available content."- FastAPI wraps the
AgentState.answer/AgentState.confidenceinAskResponseand returns 200.
Step-by-step — the ingestion flow¶
- Operator triggers
docker compose --profile ingest up ingestion(or runs the container in CI / Helm hook). - Lock acquire.
FileLockIngestionRunLockcreates<LOCK_DIR>/<request_id>.lock(TTL 600s). A second concurrent run returns{"status": "skipped", "reason": "run_in_progress"}. - Scrape.
RequestsPageScraper.fetch(source_url)issuesrequests.get(...)with a configured timeout + retries. 5xx, timeouts, and connection errors raise typed exceptions (VectorStoreUnavailable/EmbedderUnavailable). - Clean.
BoilerplatePageCleanerwalks the DOM and stripsnav / footer / header / aside / script / style / noscriptand[role="navigation"]/[class*="cookie" i]/[id*="cookie" i]selectors. - Analyse (WP06).
LlmPageAnalyzercallsgpt-4o-miniwith a JSON-schemaPageStructuredescribing semantic regions (headings, FAQ items withQ/Atext, generic paragraphs). - Chunk.
HybridChunker(default) walks thePageStructureand emitsChunkobjects. Each chunk has a stablechunk_id = sha1(source_url + ":" + ordinal)[:40]so re-ingesting the same source is idempotent (upsert overwrites by id).FixedSizeChunkeris still selectable as a fallback viaCHUNKER_BACKEND=fixed_size. - Embed.
OpenAIEmbedder(default) callstext-embedding-3-largeand requests 1024-dim output via the Matryoshkadimensionsparameter. The localSentenceTransformersEmbedder(intfloat/multilingual-e5-large) is selectable viaEMBEDDER_BACKEND=local. - Upsert.
ChromaVectorStore.upsert(chunks)writestext + embedding + metadata{source_url, section, ordinal}to the configured collection.Retry-Afteris respected on transient Chroma 5xx. - Lock release.
try/finallyguarantees the lock file is removed even if any step above raised.
Shared infrastructure¶
- Chroma is the only persistent state. Both flows talk
to it via
chromadb.HttpClientonCHROMA_HOST:CHROMA_PORT(defaultchroma:8000inside the compose network).PersistentClientis not used — Chroma runs as its own container so the API can be redeployed without losing embeddings. - OpenTelemetry. Initialised once in
composition/observability.pybefore any adapter is built. Both flows get auto-instrumentation for FastAPI, httpx, logging, and chromadb; manual spans around every LangGraph node, every adapter call, and every ingestion step. Spans are emitted even when no OTLP exporter is configured —opentelemetry-instrumentation-logginginjectstrace_id/span_idinto everystructlogline. - Single request id. The
RequestIdMiddlewaremints one for every ask request; the ingestion container readsREQUEST_IDfrom the env (defaultuuid4().hex) and uses it for the whole run. That single id appears in every log line, span attribute, lock filename, and Helm Job stdout — seeAGENTS.md§6.
Where to look in the code¶
| Concern | File |
|---|---|
| FastAPI app | src/support_bot/composition/api_app.py |
Routes (POST /ask, /healthz, /metrics) |
src/support_bot/application/api/routes.py |
| RequestIdMiddleware | src/support_bot/application/api/middleware.py |
| LangGraph topology | src/support_bot/application/answering/graph.py |
| Answerer | src/support_bot/adapters/answerer_openai.py |
| OpenAI embedder | src/support_bot/adapters/embedding_openai.py |
| Local embedder | src/support_bot/adapters/embedding_local.py |
| Hybrid chunker | src/support_bot/adapters/hybrid_chunker.py |
| Page analyser | src/support_bot/adapters/llm_page_analyzer.py |
| Vector store | src/support_bot/adapters/vectorstore_chroma.py |
| Lexical re-ranker | src/support_bot/adapters/lexical_rerank_retriever.py |
| Refusal policy | src/support_bot/adapters/low_confidence_policy.py |
| Ingestion entry | src/support_bot/composition/ingestion_main.py |
| Settings | src/support_bot/composition/settings.py |
| Compose stack | deploy/docker-compose.yml |