From 66ffeaa217f0a6b01873de8d00db5944d4a9b5c7 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 16:38:41 -0500 Subject: [PATCH 01/20] docs: add feature specs for ask endpoint validation, response contract, FAQ corpus data, deterministic mock generation, optional LLM generation, health endpoint, retrieval pipeline, and spec traceability matrix --- SPECS/ask-endpoint-validation.md | 37 +++++++++++++++++++++++++ SPECS/ask-response-contract.md | 41 ++++++++++++++++++++++++++++ SPECS/faq-data.md | 40 +++++++++++++++++++++++++++ SPECS/generation-mock.md | 31 +++++++++++++++++++++ SPECS/generation-optional-llm.md | 34 +++++++++++++++++++++++ SPECS/health-endpoint.md | 24 +++++++++++++++++ SPECS/retrieval-pipeline.md | 38 ++++++++++++++++++++++++++ SPECS/spec-traceability.md | 46 ++++++++++++++++++++++++++++++++ 8 files changed, 291 insertions(+) create mode 100644 SPECS/ask-endpoint-validation.md create mode 100644 SPECS/ask-response-contract.md create mode 100644 SPECS/faq-data.md create mode 100644 SPECS/generation-mock.md create mode 100644 SPECS/generation-optional-llm.md create mode 100644 SPECS/health-endpoint.md create mode 100644 SPECS/retrieval-pipeline.md create mode 100644 SPECS/spec-traceability.md diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md new file mode 100644 index 00000000..7c9a8128 --- /dev/null +++ b/SPECS/ask-endpoint-validation.md @@ -0,0 +1,37 @@ +# Feature Spec: Ask Endpoint Validation + +## Goal +- Enforce strict, predictable request validation for `POST /ask` so API behavior is safe and testable. + +## Scope +- In: +- Validate request body fields `question` and `top_k`. +- Return consistent 400 errors for invalid input. +- Out: +- Retrieval ranking behavior. +- Answer generation quality. + +## Requirements +- Endpoint: +- `POST /ask` +- Request schema: +- `question` is required string with length `5..300`. +- `top_k` is optional integer with default `3` and valid range `1..5`. +- Validation failures MUST return HTTP 400. +- Validation failures include: +- Missing `question`. +- `question` length below 5. +- `question` length above 300. +- `top_k` below 1. +- `top_k` above 5. +- Error responses MUST be JSON and machine-parseable. +- Validation MUST run before retrieval or generation logic executes. + +## Acceptance Criteria +- [ ] Missing `question` returns `400`. +- [ ] `question` shorter than 5 characters returns `400`. +- [ ] `question` longer than 300 characters returns `400`. +- [ ] `top_k = 0` returns `400`. +- [ ] `top_k > 5` returns `400`. +- [ ] Omitted `top_k` is accepted and treated as `3`. + diff --git a/SPECS/ask-response-contract.md b/SPECS/ask-response-contract.md new file mode 100644 index 00000000..f02175da --- /dev/null +++ b/SPECS/ask-response-contract.md @@ -0,0 +1,41 @@ +# Feature Spec: Ask Response Contract + +## Goal +- Guarantee a stable JSON response contract from `POST /ask` across success and fallback paths. + +## Scope +- In: +- Response schema for status `200`. +- Source object field contract. +- Retrieval metadata contract. +- Out: +- HTTP error schema details outside `POST /ask` success response. + +## Requirements +- `POST /ask` successful response MUST include keys: +- `answer` +- `sources` +- `retrieval` +- `answer` MUST be a string. +- `sources` MUST be an array (possibly empty). +- Each `sources` item MUST include: +- `id` (string) +- `title` (string) +- `snippet` (string) +- `score` (number) +- `retrieval` MUST include: +- `top_k` (integer) +- `matched` (integer) +- Contract MUST remain stable for: +- Matched retrieval response. +- Fallback response (no sources). +- In fallback response: +- `sources` MUST equal `[]`. +- `retrieval.matched` MUST equal `0`. + +## Acceptance Criteria +- [ ] Contract test validates required top-level keys exist on every 200 response. +- [ ] Contract test validates source item field presence and scalar types. +- [ ] Fallback path preserves full schema and uses empty `sources`. +- [ ] Retrieval metadata fields are always present and consistent with payload. + diff --git a/SPECS/faq-data.md b/SPECS/faq-data.md new file mode 100644 index 00000000..270b7271 --- /dev/null +++ b/SPECS/faq-data.md @@ -0,0 +1,40 @@ +# Feature Spec: FAQ Corpus Data + +## Goal +- Define a small, local, version-controlled FAQ corpus that powers retrieval tests and API behavior. + +## Scope +- In: +- Local FAQ files under `data/faq/`. +- Required document fields and minimum corpus breadth. +- Deterministic, test-friendly content expectations. +- Out: +- External content sources or dynamic ingestion pipelines. + +## Requirements +- Corpus MUST contain between 8 and 15 documents. +- Corpus content MUST represent the fictitious institution name as `Mockridge Bank`. +- Each document MUST include: +- `id` +- `title` +- `body` +- Corpus MUST represent core Mockridge Bank topics, including: +- checking accounts +- savings accounts +- auto loans +- credit cards +- overdraft fees +- fraud/disputes +- mobile app +- support hours +- Document content MUST be stable and human-readable. +- Corpus format MAY be markdown or JSON, but parser behavior MUST be documented. +- IDs MUST be unique across corpus. +- Corpus MUST be local and committed to repository. +- Corpus MUST not include sensitive data, credentials, or personal information. + +## Acceptance Criteria +- [ ] Data loader can parse all corpus files without runtime errors. +- [ ] Document IDs are unique and non-empty. +- [ ] At least one retrieval test depends on known corpus content and passes. +- [ ] Corpus size is within defined range (8-15). diff --git a/SPECS/generation-mock.md b/SPECS/generation-mock.md new file mode 100644 index 00000000..b3137db6 --- /dev/null +++ b/SPECS/generation-mock.md @@ -0,0 +1,31 @@ +# Feature Spec: Deterministic Mock Generation + +## Goal +- Provide a default answer generator that is deterministic, locally runnable, and independent of external model downloads. + +## Scope +- In: +- Implement a mock/extractive generator as the default generation path. +- Build answer text from retrieved FAQ snippets. +- Define fallback answer behavior for unmatched retrieval. +- Out: +- Human-like conversational quality optimization. +- Probabilistic/creative generation behavior. + +## Requirements +- Default generator MUST be selected when `RAG_GENERATOR` is unset or set to `mock`. +- Generator MUST not require network access, API keys, or model downloads. +- For matched retrieval: +- Answer MUST be constructed from retrieved content deterministically. +- Same input and same retrieval set MUST produce identical output. +- For unmatched retrieval: +- Return a safe fallback answer. +- `sources` MUST be empty in API response. +- Generator interface MUST be cleanly swappable with optional LLM generator. + +## Acceptance Criteria +- [ ] Tests run fully with `RAG_GENERATOR=mock` and no model downloads. +- [ ] Repeated identical requests produce identical answers. +- [ ] Matched retrieval path returns a non-empty answer derived from source content. +- [ ] Unmatched retrieval path returns fallback answer and empty sources. + diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md new file mode 100644 index 00000000..04b7ed39 --- /dev/null +++ b/SPECS/generation-optional-llm.md @@ -0,0 +1,34 @@ +# Feature Spec: Optional DistilGPT2 Generation + +## Goal +- Enable an optional local LLM generation mode for runtime experimentation without affecting baseline determinism or test portability. + +## Scope +- In: +- Support `RAG_GENERATOR=distilgpt2`. +- Implement a separate generator path backed by Hugging Face `transformers`. +- Document runtime behavior and first-run model download expectations. +- Out: +- CI dependency on LLM mode. +- Any requirement for LLM mode during tests. + +## Requirements +- LLM mode MUST be opt-in via: +- `RAG_GENERATOR=distilgpt2` +- Default mode MUST remain `mock`. +- LLM mode MUST not be required to start or test default application workflow. +- If LLM mode is selected and model assets are unavailable: +- System MUST fail clearly with actionable local setup guidance. +- No secrets or API keys may be required for LLM mode. +- Repository MUST NOT commit model weight files. +- README MUST clearly state: +- LLM mode is optional. +- First-run download size/cost is local disk/network only. +- Tests run without LLM mode. + +## Acceptance Criteria +- [ ] With default environment, app uses mock generator. +- [ ] With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter. +- [ ] Test suite does not depend on `distilgpt2`. +- [ ] Documentation explains optional setup and non-requirement for tests. + diff --git a/SPECS/health-endpoint.md b/SPECS/health-endpoint.md new file mode 100644 index 00000000..8068174c --- /dev/null +++ b/SPECS/health-endpoint.md @@ -0,0 +1,24 @@ +# Feature Spec: Health Endpoint + +## Goal +- Provide a stable service-health signal for local development, CI checks, and smoke tests. + +## Scope +- In: +- Implement `GET /health`. +- Return a deterministic JSON body and HTTP 200. +- Out: +- Metrics, dependency health checks, authentication, and readiness/liveness split. + +## Requirements +- `GET /health` MUST return HTTP 200. +- Response body MUST be exactly: +- `{ "status": "ok" }` +- Response content type MUST be JSON. +- Endpoint behavior MUST be deterministic and independent of retrieval/generation subsystems. + +## Acceptance Criteria +- [ ] Calling `GET /health` returns status code `200`. +- [ ] Response JSON includes key `status` with value `ok`. +- [ ] No API keys, external services, or model downloads are required. + diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md new file mode 100644 index 00000000..d51b65b1 --- /dev/null +++ b/SPECS/retrieval-pipeline.md @@ -0,0 +1,38 @@ +# Feature Spec: Retrieval Pipeline + +## Goal +- Retrieve the most relevant local FAQ documents for a customer question using embeddings and ChromaDB. + +## Scope +- In: +- Embed incoming question with `all-MiniLM-L6-v2`. +- Query local persisted ChromaDB for top-k matches. +- Return scored, sorted source candidates. +- Apply minimum relevance threshold rule. +- Out: +- Final answer wording strategy. +- External document ingestion services. + +## Requirements +- Retrieval MUST use local FAQ corpus data only. +- Retrieval MUST use local ChromaDB persistence (no remote vector DB). +- Query flow: +- Embed question with `all-MiniLM-L6-v2` via SentenceTransformers. +- Query ChromaDB using `top_k`. +- Map results to source items with `id`, `title`, `snippet`, `score`. +- Sources MUST be sorted by descending relevance score. +- If no document satisfies relevance threshold: +- Retrieval result MUST be treated as unmatched. +- Downstream response MUST use fallback behavior. +- Retrieval metadata MUST include: +- `top_k` as the effective query size. +- `matched` as number of documents included in `sources`. +- Retrieval behavior MUST be deterministic for the same corpus and input. + +## Acceptance Criteria +- [ ] A known banking query retrieves an expected FAQ document as top result. +- [ ] Changing `top_k` changes the maximum returned source count accordingly. +- [ ] Source list is sorted by score descending. +- [ ] Unknown/out-of-domain query triggers unmatched retrieval path. +- [ ] Retrieval metadata reports `top_k` and `matched` accurately. + diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md new file mode 100644 index 00000000..d29392c7 --- /dev/null +++ b/SPECS/spec-traceability.md @@ -0,0 +1,46 @@ +# Feature Spec: Spec Traceability Matrix + +## Goal +- Provide explicit traceability between specifications, tests, and implementation modules. + +## Scope +- In: +- Mapping of each spec to planned test files. +- Mapping of each spec to planned application modules. +- Out: +- Detailed test-case code. +- Release management process. + +## Requirements +- Every feature spec in `SPECS/` MUST map to at least one test file. +- Every feature spec in `SPECS/` MUST map to at least one implementation module. +- Traceability document MUST be updated when adding/changing feature specs. + +## Acceptance Criteria +- [ ] Matrix includes each current spec file in `SPECS/`. +- [ ] Matrix lists at least one test target per spec. +- [ ] Matrix lists at least one implementation target per spec. + +## Mapping +- `SPECS/health-endpoint.md` +- Tests: `tests/test_health.py` +- Implementation: `app/main.py` +- `SPECS/ask-endpoint-validation.md` +- Tests: `tests/test_validation.py` +- Implementation: `app/models.py`, `app/main.py` +- `SPECS/retrieval-pipeline.md` +- Tests: `tests/test_retrieval.py` +- Implementation: `app/retrieval.py` +- `SPECS/generation-mock.md` +- Tests: `tests/test_retrieval.py`, `tests/test_contract.py` +- Implementation: `app/generation.py` +- `SPECS/generation-optional-llm.md` +- Tests: `tests/test_generator_config.py` (optional/non-blocking), existing suite in mock mode +- Implementation: `app/generation.py`, `app/main.py` +- `SPECS/ask-response-contract.md` +- Tests: `tests/test_contract.py` +- Implementation: `app/models.py`, `app/main.py` +- `SPECS/faq-data.md` +- Tests: `tests/test_data_loader.py`, `tests/test_retrieval.py` +- Implementation: `app/retrieval.py`, `data/faq/*` + From 3f6feba27d44dbabf74efe83c10bd981ce24aae0 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 16:51:24 -0500 Subject: [PATCH 02/20] docs: Adding streamlit specification document, and reformatting other specification files for more logical bullet points --- SPECS/ask-endpoint-validation.md | 25 +++++++++--------- SPECS/ask-response-contract.md | 35 ++++++++++++------------- SPECS/faq-data.md | 30 +++++++++++----------- SPECS/feature-template.md | 9 ++++--- SPECS/generation-mock.md | 19 +++++++------- SPECS/generation-optional-llm.md | 21 ++++++++------- SPECS/health-endpoint.md | 9 +++---- SPECS/retrieval-pipeline.md | 27 ++++++++++---------- SPECS/spec-traceability.md | 40 +++++++++++++++-------------- SPECS/streamlit-ui.md | 44 ++++++++++++++++++++++++++++++++ 10 files changed, 151 insertions(+), 108 deletions(-) create mode 100644 SPECS/streamlit-ui.md diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md index 7c9a8128..66421fc1 100644 --- a/SPECS/ask-endpoint-validation.md +++ b/SPECS/ask-endpoint-validation.md @@ -5,25 +5,25 @@ ## Scope - In: -- Validate request body fields `question` and `top_k`. -- Return consistent 400 errors for invalid input. + - Validate request body fields `question` and `top_k`. + - Return consistent 400 errors for invalid input. - Out: -- Retrieval ranking behavior. -- Answer generation quality. + - Retrieval ranking behavior. + - Answer generation quality. ## Requirements - Endpoint: -- `POST /ask` + - `POST /ask` - Request schema: -- `question` is required string with length `5..300`. -- `top_k` is optional integer with default `3` and valid range `1..5`. + - `question` is required string with length `5..300`. + - `top_k` is optional integer with default `3` and valid range `1..5`. - Validation failures MUST return HTTP 400. - Validation failures include: -- Missing `question`. -- `question` length below 5. -- `question` length above 300. -- `top_k` below 1. -- `top_k` above 5. + - Missing `question`. + - `question` length below 5. + - `question` length above 300. + - `top_k` below 1. + - `top_k` above 5. - Error responses MUST be JSON and machine-parseable. - Validation MUST run before retrieval or generation logic executes. @@ -34,4 +34,3 @@ - [ ] `top_k = 0` returns `400`. - [ ] `top_k > 5` returns `400`. - [ ] Omitted `top_k` is accepted and treated as `3`. - diff --git a/SPECS/ask-response-contract.md b/SPECS/ask-response-contract.md index f02175da..e84a5b3f 100644 --- a/SPECS/ask-response-contract.md +++ b/SPECS/ask-response-contract.md @@ -5,37 +5,36 @@ ## Scope - In: -- Response schema for status `200`. -- Source object field contract. -- Retrieval metadata contract. + - Response schema for status `200`. + - Source object field contract. + - Retrieval metadata contract. - Out: -- HTTP error schema details outside `POST /ask` success response. + - HTTP error schema details outside `POST /ask` success response. ## Requirements - `POST /ask` successful response MUST include keys: -- `answer` -- `sources` -- `retrieval` + - `answer` + - `sources` + - `retrieval` - `answer` MUST be a string. - `sources` MUST be an array (possibly empty). - Each `sources` item MUST include: -- `id` (string) -- `title` (string) -- `snippet` (string) -- `score` (number) + - `id` (string) + - `title` (string) + - `snippet` (string) + - `score` (number) - `retrieval` MUST include: -- `top_k` (integer) -- `matched` (integer) + - `top_k` (integer) + - `matched` (integer) - Contract MUST remain stable for: -- Matched retrieval response. -- Fallback response (no sources). + - Matched retrieval response. + - Fallback response (no sources). - In fallback response: -- `sources` MUST equal `[]`. -- `retrieval.matched` MUST equal `0`. + - `sources` MUST equal `[]`. + - `retrieval.matched` MUST equal `0`. ## Acceptance Criteria - [ ] Contract test validates required top-level keys exist on every 200 response. - [ ] Contract test validates source item field presence and scalar types. - [ ] Fallback path preserves full schema and uses empty `sources`. - [ ] Retrieval metadata fields are always present and consistent with payload. - diff --git a/SPECS/faq-data.md b/SPECS/faq-data.md index 270b7271..31479ade 100644 --- a/SPECS/faq-data.md +++ b/SPECS/faq-data.md @@ -5,28 +5,28 @@ ## Scope - In: -- Local FAQ files under `data/faq/`. -- Required document fields and minimum corpus breadth. -- Deterministic, test-friendly content expectations. + - Local FAQ files under `data/faq/`. + - Required document fields and minimum corpus breadth. + - Deterministic, test-friendly content expectations. - Out: -- External content sources or dynamic ingestion pipelines. + - External content sources or dynamic ingestion pipelines. ## Requirements - Corpus MUST contain between 8 and 15 documents. - Corpus content MUST represent the fictitious institution name as `Mockridge Bank`. - Each document MUST include: -- `id` -- `title` -- `body` + - `id` + - `title` + - `body` - Corpus MUST represent core Mockridge Bank topics, including: -- checking accounts -- savings accounts -- auto loans -- credit cards -- overdraft fees -- fraud/disputes -- mobile app -- support hours + - checking accounts + - savings accounts + - auto loans + - credit cards + - overdraft fees + - fraud/disputes + - mobile app + - support hours - Document content MUST be stable and human-readable. - Corpus format MAY be markdown or JSON, but parser behavior MUST be documented. - IDs MUST be unique across corpus. diff --git a/SPECS/feature-template.md b/SPECS/feature-template.md index 7dbc70a5..0340cac8 100644 --- a/SPECS/feature-template.md +++ b/SPECS/feature-template.md @@ -1,14 +1,17 @@ # Feature Spec: ## Goal -- +- ## Scope - In: + - - Out: + - ## Requirements -- +- + - ## Acceptance Criteria -- [ ] \ No newline at end of file +- [ ] diff --git a/SPECS/generation-mock.md b/SPECS/generation-mock.md index b3137db6..ea45d92d 100644 --- a/SPECS/generation-mock.md +++ b/SPECS/generation-mock.md @@ -5,22 +5,22 @@ ## Scope - In: -- Implement a mock/extractive generator as the default generation path. -- Build answer text from retrieved FAQ snippets. -- Define fallback answer behavior for unmatched retrieval. + - Implement a mock/extractive generator as the default generation path. + - Build answer text from retrieved FAQ snippets. + - Define fallback answer behavior for unmatched retrieval. - Out: -- Human-like conversational quality optimization. -- Probabilistic/creative generation behavior. + - Human-like conversational quality optimization. + - Probabilistic/creative generation behavior. ## Requirements - Default generator MUST be selected when `RAG_GENERATOR` is unset or set to `mock`. - Generator MUST not require network access, API keys, or model downloads. - For matched retrieval: -- Answer MUST be constructed from retrieved content deterministically. -- Same input and same retrieval set MUST produce identical output. + - Answer MUST be constructed from retrieved content deterministically. + - Same input and same retrieval set MUST produce identical output. - For unmatched retrieval: -- Return a safe fallback answer. -- `sources` MUST be empty in API response. + - Return a safe fallback answer. + - `sources` MUST be empty in API response. - Generator interface MUST be cleanly swappable with optional LLM generator. ## Acceptance Criteria @@ -28,4 +28,3 @@ - [ ] Repeated identical requests produce identical answers. - [ ] Matched retrieval path returns a non-empty answer derived from source content. - [ ] Unmatched retrieval path returns fallback answer and empty sources. - diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index 04b7ed39..c67f683f 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -5,30 +5,29 @@ ## Scope - In: -- Support `RAG_GENERATOR=distilgpt2`. -- Implement a separate generator path backed by Hugging Face `transformers`. -- Document runtime behavior and first-run model download expectations. + - Support `RAG_GENERATOR=distilgpt2`. + - Implement a separate generator path backed by Hugging Face `transformers`. + - Document runtime behavior and first-run model download expectations. - Out: -- CI dependency on LLM mode. -- Any requirement for LLM mode during tests. + - CI dependency on LLM mode. + - Any requirement for LLM mode during tests. ## Requirements - LLM mode MUST be opt-in via: -- `RAG_GENERATOR=distilgpt2` + - `RAG_GENERATOR=distilgpt2` - Default mode MUST remain `mock`. - LLM mode MUST not be required to start or test default application workflow. - If LLM mode is selected and model assets are unavailable: -- System MUST fail clearly with actionable local setup guidance. + - System MUST fail clearly with actionable local setup guidance. - No secrets or API keys may be required for LLM mode. - Repository MUST NOT commit model weight files. - README MUST clearly state: -- LLM mode is optional. -- First-run download size/cost is local disk/network only. -- Tests run without LLM mode. + - LLM mode is optional. + - First-run download size/cost is local disk/network only. + - Tests run without LLM mode. ## Acceptance Criteria - [ ] With default environment, app uses mock generator. - [ ] With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter. - [ ] Test suite does not depend on `distilgpt2`. - [ ] Documentation explains optional setup and non-requirement for tests. - diff --git a/SPECS/health-endpoint.md b/SPECS/health-endpoint.md index 8068174c..89ef541b 100644 --- a/SPECS/health-endpoint.md +++ b/SPECS/health-endpoint.md @@ -5,15 +5,15 @@ ## Scope - In: -- Implement `GET /health`. -- Return a deterministic JSON body and HTTP 200. + - Implement `GET /health`. + - Return a deterministic JSON body and HTTP 200. - Out: -- Metrics, dependency health checks, authentication, and readiness/liveness split. + - Metrics, dependency health checks, authentication, and readiness/liveness split. ## Requirements - `GET /health` MUST return HTTP 200. - Response body MUST be exactly: -- `{ "status": "ok" }` + - `{ "status": "ok" }` - Response content type MUST be JSON. - Endpoint behavior MUST be deterministic and independent of retrieval/generation subsystems. @@ -21,4 +21,3 @@ - [ ] Calling `GET /health` returns status code `200`. - [ ] Response JSON includes key `status` with value `ok`. - [ ] No API keys, external services, or model downloads are required. - diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index d51b65b1..b29598b0 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -5,28 +5,28 @@ ## Scope - In: -- Embed incoming question with `all-MiniLM-L6-v2`. -- Query local persisted ChromaDB for top-k matches. -- Return scored, sorted source candidates. -- Apply minimum relevance threshold rule. + - Embed incoming question with `all-MiniLM-L6-v2`. + - Query local persisted ChromaDB for top-k matches. + - Return scored, sorted source candidates. + - Apply minimum relevance threshold rule. - Out: -- Final answer wording strategy. -- External document ingestion services. + - Final answer wording strategy. + - External document ingestion services. ## Requirements - Retrieval MUST use local FAQ corpus data only. - Retrieval MUST use local ChromaDB persistence (no remote vector DB). - Query flow: -- Embed question with `all-MiniLM-L6-v2` via SentenceTransformers. -- Query ChromaDB using `top_k`. -- Map results to source items with `id`, `title`, `snippet`, `score`. + - Embed question with `all-MiniLM-L6-v2` via SentenceTransformers. + - Query ChromaDB using `top_k`. + - Map results to source items with `id`, `title`, `snippet`, `score`. - Sources MUST be sorted by descending relevance score. - If no document satisfies relevance threshold: -- Retrieval result MUST be treated as unmatched. -- Downstream response MUST use fallback behavior. + - Retrieval result MUST be treated as unmatched. + - Downstream response MUST use fallback behavior. - Retrieval metadata MUST include: -- `top_k` as the effective query size. -- `matched` as number of documents included in `sources`. + - `top_k` as the effective query size. + - `matched` as number of documents included in `sources`. - Retrieval behavior MUST be deterministic for the same corpus and input. ## Acceptance Criteria @@ -35,4 +35,3 @@ - [ ] Source list is sorted by score descending. - [ ] Unknown/out-of-domain query triggers unmatched retrieval path. - [ ] Retrieval metadata reports `top_k` and `matched` accurately. - diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index d29392c7..517e562e 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -5,11 +5,11 @@ ## Scope - In: -- Mapping of each spec to planned test files. -- Mapping of each spec to planned application modules. + - Mapping of each spec to planned test files. + - Mapping of each spec to planned application modules. - Out: -- Detailed test-case code. -- Release management process. + - Detailed test-case code. + - Release management process. ## Requirements - Every feature spec in `SPECS/` MUST map to at least one test file. @@ -23,24 +23,26 @@ ## Mapping - `SPECS/health-endpoint.md` -- Tests: `tests/test_health.py` -- Implementation: `app/main.py` + - Tests: `tests/test_health.py` + - Implementation: `app/main.py` - `SPECS/ask-endpoint-validation.md` -- Tests: `tests/test_validation.py` -- Implementation: `app/models.py`, `app/main.py` + - Tests: `tests/test_validation.py` + - Implementation: `app/models.py`, `app/main.py` - `SPECS/retrieval-pipeline.md` -- Tests: `tests/test_retrieval.py` -- Implementation: `app/retrieval.py` + - Tests: `tests/test_retrieval.py` + - Implementation: `app/retrieval.py` - `SPECS/generation-mock.md` -- Tests: `tests/test_retrieval.py`, `tests/test_contract.py` -- Implementation: `app/generation.py` + - Tests: `tests/test_retrieval.py`, `tests/test_contract.py` + - Implementation: `app/generation.py` - `SPECS/generation-optional-llm.md` -- Tests: `tests/test_generator_config.py` (optional/non-blocking), existing suite in mock mode -- Implementation: `app/generation.py`, `app/main.py` + - Tests: `tests/test_generator_config.py` (optional/non-blocking), existing suite in mock mode + - Implementation: `app/generation.py`, `app/main.py` - `SPECS/ask-response-contract.md` -- Tests: `tests/test_contract.py` -- Implementation: `app/models.py`, `app/main.py` + - Tests: `tests/test_contract.py` + - Implementation: `app/models.py`, `app/main.py` - `SPECS/faq-data.md` -- Tests: `tests/test_data_loader.py`, `tests/test_retrieval.py` -- Implementation: `app/retrieval.py`, `data/faq/*` - + - Tests: `tests/test_data_loader.py`, `tests/test_retrieval.py` + - Implementation: `app/retrieval.py`, `data/faq/*` +- `SPECS/streamlit-ui.md` + - Tests: `tests/test_streamlit_smoke.py` (optional smoke), manual acceptance checks + - Implementation: `ui/streamlit_app.py` diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md new file mode 100644 index 00000000..c256a2e2 --- /dev/null +++ b/SPECS/streamlit-ui.md @@ -0,0 +1,44 @@ +# Feature Spec: Streamlit UI + +## Goal +- Provide a simple local UI to interact with the RAG FAQ API for manual testing and demonstration. + +## Scope +- In: + - A single-page Streamlit application. + - A Streamlit app with question input and `top_k` control. + - Submit workflow that calls `POST /ask`. + - Rendering of answer, sources, and retrieval metadata. + - User-visible handling for API validation and runtime errors. +- Out: + - Authentication and user accounts. + - Multi-page navigation. + - Advanced visual design or theming system. + - Streaming token-by-token generation output. + +## Requirements +- UI MUST run locally with Streamlit and no API keys. +- UI MUST be a single-page interface. +- UI MUST NOT implement authentication, authorization, or user accounts. +- UI MUST include: + - Question text input. + - `top_k` control constrained to `1..5` with default `3`. + - Submit action to call API endpoint `POST /ask`. +- On success (`200`), UI MUST display: + - `answer` + - `sources` list with `title`, `snippet`, and `score` + - `retrieval.top_k` and `retrieval.matched` +- On fallback responses (`sources=[]`), UI MUST clearly indicate no matching sources were found. +- On API validation errors (`400`), UI MUST show clear, non-crashing feedback to user. +- UI MUST not require optional LLM mode; default mock mode must be fully supported. +- UI MUST not embed secrets or credentials in code. + +## Acceptance Criteria +- [ ] User can enter a valid question, submit, and view answer output. +- [ ] User can change `top_k` and see reflected retrieval metadata. +- [ ] Source citations are rendered when present. +- [ ] Fallback path is visible and understandable when no matches exist. +- [ ] Validation errors are shown in the UI without app crash. +- [ ] UI runs locally against the API in default mock mode. +- [ ] UI is implemented as a single page. +- [ ] UI is accessible without authentication or account flows. From 368798661a9c76585ac5db7b3df59217f3071ced Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 19:40:42 -0500 Subject: [PATCH 03/20] test: Adding testing files to cover the specifications --- .env.example | 3 + .gitignore | 56 ++++++++++ PROJECT_BRIEF.md | 182 +++++++++++++++++++++++++++++++ SPECS/ask-endpoint-validation.md | 1 + SPECS/ask-response-contract.md | 9 +- SPECS/faq-data.md | 3 + SPECS/retrieval-pipeline.md | 3 + pytest.ini | 6 + tests/conftest.py | 23 ++++ tests/test_contract.py | 72 ++++++++++++ tests/test_data_loader.py | 63 +++++++++++ tests/test_determinism.py | 9 ++ tests/test_generator_config.py | 31 ++++++ tests/test_health.py | 6 + tests/test_retrieval.py | 53 +++++++++ tests/test_streamlit_smoke.py | 10 ++ tests/test_validation.py | 72 ++++++++++++ 17 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 PROJECT_BRIEF.md create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_contract.py create mode 100644 tests/test_data_loader.py create mode 100644 tests/test_determinism.py create mode 100644 tests/test_generator_config.py create mode 100644 tests/test_health.py create mode 100644 tests/test_retrieval.py create mode 100644 tests/test_streamlit_smoke.py create mode 100644 tests/test_validation.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e29c16f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Optional configuration. Defaults are applied if unset. +RAG_GENERATOR=mock +RAG_MIN_SCORE=0.25 diff --git a/.gitignore b/.gitignore index e69de29b..d0eacd20 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,56 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Tool caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ + +# Logs +*.log + +# Environment / secrets +.env +.env.* +!.env.example + +# Jupyter +.ipynb_checkpoints/ + +# IDE/editor files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Streamlit local secrets/config overrides +.streamlit/secrets.toml + +# Local vector DB / embeddings artifacts +chroma/ +chroma_db/ +.chroma/ +*.sqlite3 diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md new file mode 100644 index 00000000..5799a92a --- /dev/null +++ b/PROJECT_BRIEF.md @@ -0,0 +1,182 @@ +# Project Brief — Spec-Driven RAG FAQ API (Mockridge Bank) + +## Purpose of This File + +This document is a concise onboarding brief for code generation tools. +It orients a new tool to the project goals, constraints, specs, tests, and +implementation order without replacing the authoritative specs. + +Source-of-truth hierarchy: +- Specs in `SPECS/` define behavior and acceptance criteria. +- Tests in `tests/` encode those specs. +- Implementation exists to satisfy the tests. + +Note: Per project rules, do not use `README.md` as context for the LLM/tool. + +--- + +## Project Overview + +Build a spec-driven, testable RAG-style Customer FAQ Answering API +for a fictional bank named "Mockridge Bank". + +The system: +- Accepts customer questions about bank products and services. +- Retrieves relevant FAQ documents using vector similarity search. +- Generates an answer based on retrieved documents. +- Returns the answer with cited sources. +- Runs locally without API keys or secrets. +- Needs to work consistently across platforms (Windows, Mac, Linux) + +Primary goals: +- Demonstrate AI system testing and spec-driven development. +- Provide deterministic, CI-friendly behavior. +- Cleanly separate retrieval and generation. + +Non-goal: +- High-quality or creative LLM output. (This requires API keys or large model downloads, which we want to avoid) + +--- + +## Core Principles + +- Specs define behavior, tests enforce it, code satisfies tests. +- Determinism is required by default. +- AI components must be testable and mockable. +- Reviewer setup friction must be minimal. + +--- + +## Key Constraints + +- No paid APIs and no API keys required for tests. +- No model weights committed to the repository. +- No GPU required. +- No external network calls during tests. +- `pytest` must run successfully by default. +- Optional LLM usage must be clearly documented and opt-in. + +--- + +## Tech Stack + +- Language: Python 3.10+ +- API: FastAPI +- Vector store: ChromaDB (local, persisted) +- Embeddings: `all-MiniLM-L6-v2` via SentenceTransformers +- Optional LLM: `distilgpt2` via Hugging Face `transformers` +- UI: Streamlit (single-page) +- Tests: pytest + +--- + +## Functional Requirements (High-Level) + +The API exposes endpoints for question answering and health checks. Detailed +request/response shapes, validation rules, and error cases are defined in `SPECS/` +and enforced by `tests/`. + +--- + +## Data Requirements + +Local FAQ corpus for Mockridge Bank: +- Location: `data/faq/` +- Format: Markdown or JSON +- Size: 8–15 documents +- Each document must include `id`, `title`, `body` +- Markdown files must explicitly include: + - `id: ` + - `title: ` + +Example topics: +- checking accounts +- savings accounts +- auto loans +- credit cards +- overdraft fees +- fraud/disputes +- mobile app +- support hours + +--- + +## RAG Pipeline (Logical Flow) + +1. Validate request. +2. Embed the question. +3. Retrieve relevant documents from the local vector store. +4. Apply a relevance threshold and return a fallback response if no matches. +5. Generate an answer from retrieved content. +6. Return answer plus cited sources and metadata. + +--- + +## Generation Strategy + +Default generator (used in tests): +- Deterministic mock/extractive generator. +- Builds answers from retrieved text. +- No model downloads or external calls. + +Optional generator (runtime only): +- `distilgpt2` via `transformers`. +- Enabled with `RAG_GENERATOR=distilgpt2` (optional). +- First run downloads ~330MB. +- Must not be required for tests. +- If unavailable, fail clearly with actionable guidance. + +--- + +## Testing Requirements + +All tests use pytest and run without network, API keys, or LLM downloads. +Coverage is defined by the specs in `SPECS/` and implemented in `tests/`. + +--- + +## Project Structure + +- `app/main.py` FastAPI app and routes +- `app/models.py` Pydantic schemas +- `app/retrieval.py` ChromaDB + embeddings +- `app/generation.py` mock + optional LLM generator +- `data/faq/*` local FAQ corpus +- `ui/streamlit_app.py` single-page Streamlit UI +- `tests/` pytest suite +- `pytest.ini` test configuration +- `SPECS/` authoritative feature specs + +--- + +## Environment Variables + +Optional only. Defaults are applied when unset. Reviewers should not need to set any values. +An example file is provided at `.env.example`. + +- `RAG_GENERATOR=mock` (default) +- `RAG_GENERATOR=distilgpt2` (optional) +- `RAG_MIN_SCORE=0.25` (default relevance threshold) + +--- + +## Explicit Non-Goals + +- High-quality natural language generation +- Authentication or authorization +- External APIs +- GPU acceleration +- Production scaling or deployment + +--- + +## Implementation Order (Spec-Driven) + +1. Create file structure. +2. Implement Pydantic models and validation. +3. Implement API routes based on specs. +4. Implement data loader and retrieval pipeline. +5. Implement deterministic mock generator. +6. Add optional `distilgpt2` generator behind env flag. +7. Add Streamlit UI. +8. Keep tests green at every step. diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md index 66421fc1..b04cfdc3 100644 --- a/SPECS/ask-endpoint-validation.md +++ b/SPECS/ask-endpoint-validation.md @@ -25,6 +25,7 @@ - `top_k` below 1. - `top_k` above 5. - Error responses MUST be JSON and machine-parseable. +- Error responses MUST include a top-level `detail` field suitable for user-facing validation feedback. - Validation MUST run before retrieval or generation logic executes. ## Acceptance Criteria diff --git a/SPECS/ask-response-contract.md b/SPECS/ask-response-contract.md index e84a5b3f..0718b585 100644 --- a/SPECS/ask-response-contract.md +++ b/SPECS/ask-response-contract.md @@ -16,12 +16,12 @@ - `answer` - `sources` - `retrieval` -- `answer` MUST be a string. +- `answer` MUST be a non-empty string. - `sources` MUST be an array (possibly empty). - Each `sources` item MUST include: - - `id` (string) - - `title` (string) - - `snippet` (string) + - `id` (non-empty string) + - `title` (non-empty string) + - `snippet` (non-empty string) - `score` (number) - `retrieval` MUST include: - `top_k` (integer) @@ -36,5 +36,6 @@ ## Acceptance Criteria - [ ] Contract test validates required top-level keys exist on every 200 response. - [ ] Contract test validates source item field presence and scalar types. +- [ ] Contract test validates `answer`, `id`, `title`, and `snippet` are non-empty strings. - [ ] Fallback path preserves full schema and uses empty `sources`. - [ ] Retrieval metadata fields are always present and consistent with payload. diff --git a/SPECS/faq-data.md b/SPECS/faq-data.md index 31479ade..920e5d75 100644 --- a/SPECS/faq-data.md +++ b/SPECS/faq-data.md @@ -18,6 +18,9 @@ - `id` - `title` - `body` +- For markdown documents, required fields MUST be explicitly present as: + - `id: ` + - `title: ` - Corpus MUST represent core Mockridge Bank topics, including: - checking accounts - savings accounts diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index b29598b0..e292d441 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -24,6 +24,9 @@ - If no document satisfies relevance threshold: - Retrieval result MUST be treated as unmatched. - Downstream response MUST use fallback behavior. +- Relevance threshold MUST be configurable via environment variable: + - `RAG_MIN_SCORE` + - Default value: `0.25` - Retrieval metadata MUST include: - `top_k` as the effective query size. - `matched` as number of documents included in `sources`. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..f6fc1c4f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -ra +markers = + smoke: quick smoke checks for module startup/import paths diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..0f77611d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def default_generator_env(monkeypatch: pytest.MonkeyPatch): + """ + Force deterministic generator mode unless a test explicitly overrides it. + """ + monkeypatch.setenv("RAG_GENERATOR", "mock") + + +@pytest.fixture() +def client() -> TestClient: + """ + Import the FastAPI app lazily so tests clearly fail until app.main exists. + """ + try: + from app.main import app + except Exception as exc: # pragma: no cover - explicit failure path for missing app + pytest.fail(f"Could not import `app.main.app`: {exc}") + + return TestClient(app) diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 00000000..16e27137 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,72 @@ +def test_ask_response_contract_contains_required_top_level_keys(client): + payload = {"question": "What are your savings account options?", "top_k": 3} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + assert "answer" in body + assert "sources" in body + assert "retrieval" in body + + assert isinstance(body["answer"], str) + assert isinstance(body["sources"], list) + assert isinstance(body["retrieval"], dict) + assert body["answer"].strip() != "" + + +def test_ask_response_sources_have_required_fields_when_present(client): + payload = {"question": "Tell me about checking accounts", "top_k": 3} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + for source in body["sources"]: + assert "id" in source + assert "title" in source + assert "snippet" in source + assert "score" in source + + assert isinstance(source["id"], str) + assert isinstance(source["title"], str) + assert isinstance(source["snippet"], str) + assert isinstance(source["score"], (int, float)) + assert source["id"].strip() != "" + assert source["title"].strip() != "" + assert source["snippet"].strip() != "" + + +def test_ask_response_retrieval_metadata_has_required_fields(client): + payload = {"question": "Do you offer auto loans?", "top_k": 2} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + retrieval = body["retrieval"] + assert "top_k" in retrieval + assert "matched" in retrieval + assert isinstance(retrieval["top_k"], int) + assert isinstance(retrieval["matched"], int) + assert retrieval["top_k"] == 2 + assert retrieval["matched"] == len(body["sources"]) + + +def test_ask_fallback_keeps_stable_schema(client): + payload = { + "question": "zxqyqv synthetic non banking phrase no match please", + "top_k": 3, + } + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + assert set(body.keys()) == {"answer", "sources", "retrieval"} + assert isinstance(body["answer"], str) + assert body["answer"].strip() != "" + assert body["sources"] == [] + assert isinstance(body["retrieval"], dict) + assert body["retrieval"]["top_k"] == 3 + assert body["retrieval"]["matched"] == 0 diff --git a/tests/test_data_loader.py b/tests/test_data_loader.py new file mode 100644 index 00000000..03a3695c --- /dev/null +++ b/tests/test_data_loader.py @@ -0,0 +1,63 @@ +import json +import re +from pathlib import Path + + +FAQ_DIR = Path("data/faq") + + +def _extract_markdown_fields(path: Path) -> dict: + text = path.read_text(encoding="utf-8").strip() + id_match = re.search(r"(?im)^\s*id:\s*(.+)\s*$", text) + title_match = re.search(r"(?im)^\s*title:\s*(.+)\s*$", text) + doc_id = id_match.group(1).strip() if id_match else "" + title = title_match.group(1).strip() if title_match else "" + body = text + return {"id": doc_id, "title": title, "body": body} + + +def _extract_json_fields(path: Path) -> dict: + payload = json.loads(path.read_text(encoding="utf-8")) + return { + "id": str(payload.get("id", "")).strip(), + "title": str(payload.get("title", "")).strip(), + "body": str(payload.get("body", "")).strip(), + } + + +def _load_docs(): + files = list(FAQ_DIR.glob("*.md")) + list(FAQ_DIR.glob("*.json")) + docs = [] + for file_path in files: + if file_path.suffix == ".md": + docs.append(_extract_markdown_fields(file_path)) + else: + docs.append(_extract_json_fields(file_path)) + return docs + + +def test_faq_directory_exists(): + assert FAQ_DIR.exists(), "Expected FAQ directory at data/faq" + assert FAQ_DIR.is_dir() + + +def test_faq_corpus_size_within_expected_range(): + docs = _load_docs() + assert 8 <= len(docs) <= 15 + + +def test_faq_docs_have_required_fields_and_non_empty_values(): + docs = _load_docs() + assert docs, "No FAQ docs found in data/faq" + + for doc in docs: + assert set(doc.keys()) == {"id", "title", "body"} + assert isinstance(doc["id"], str) and doc["id"] != "" + assert isinstance(doc["title"], str) and doc["title"] != "" + assert isinstance(doc["body"], str) and doc["body"] != "" + + +def test_faq_document_ids_are_unique(): + docs = _load_docs() + ids = [doc["id"] for doc in docs] + assert len(ids) == len(set(ids)) diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 00000000..5d9f707f --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,9 @@ +def test_same_input_produces_same_output(client): + payload = {"question": "Do you support fraud disputes in the mobile app?", "top_k": 3} + + first = client.post("/ask", json=payload) + second = client.post("/ask", json=payload) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json() == second.json() diff --git a/tests/test_generator_config.py b/tests/test_generator_config.py new file mode 100644 index 00000000..d2444b8b --- /dev/null +++ b/tests/test_generator_config.py @@ -0,0 +1,31 @@ +def test_default_generator_mode_is_mock_deterministic(client): + payload = {"question": "How can I contact support?", "top_k": 3} + first = client.post("/ask", json=payload) + second = client.post("/ask", json=payload) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json() == second.json() + + +def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(monkeypatch): + # Ensure the app module is imported after setting env. + monkeypatch.setenv("RAG_GENERATOR", "distilgpt2") + + import importlib + import sys + + sys.modules.pop("app.main", None) + app_module = importlib.import_module("app.main") + from fastapi.testclient import TestClient + + client = TestClient(app_module.app) + payload = {"question": "What credit card options do you have?", "top_k": 3} + response = client.post("/ask", json=payload) + + # Accept success (if model is available) or explicit runtime failure. + assert response.status_code in {200, 500, 503} + if response.status_code in {500, 503}: + body = response.json() + as_text = str(body).lower() + assert "distilgpt2" in as_text or "model" in as_text diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 00000000..a02381cf --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,6 @@ +def test_health_returns_ok(client): + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert "application/json" in response.headers.get("content-type", "") diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py new file mode 100644 index 00000000..6ba0347d --- /dev/null +++ b/tests/test_retrieval.py @@ -0,0 +1,53 @@ +def test_known_query_returns_expected_top_document(client): + payload = {"question": "What are your checking account monthly fees?", "top_k": 3} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + assert body["retrieval"]["matched"] >= 1 + assert len(body["sources"]) >= 1 + + top_source = body["sources"][0] + combined = f"{top_source['title']} {top_source['snippet']}".lower() + assert "checking" in combined or "checking_accounts" in top_source.get("id", "").lower() + + +def test_top_k_controls_maximum_number_of_sources(client): + payload = {"question": "Tell me about bank account options", "top_k": 1} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + assert len(body["sources"]) <= 1 + assert body["retrieval"]["top_k"] == 1 + assert body["retrieval"]["matched"] == len(body["sources"]) + + +def test_sources_are_sorted_by_descending_score(client): + payload = {"question": "How do overdraft fees work?", "top_k": 5} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + if len(body["sources"]) < 2: + assert body["retrieval"]["matched"] == len(body["sources"]) + return + scores = [source["score"] for source in body["sources"]] + assert scores == sorted(scores, reverse=True) + + +def test_unknown_query_returns_fallback_with_empty_sources(client): + payload = { + "question": "quartz nebula hedgehog protocol 91821 unrelated", + "top_k": 3, + } + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + assert isinstance(body["answer"], str) + assert body["answer"].strip() != "" + assert body["sources"] == [] + assert body["retrieval"]["top_k"] == 3 + assert body["retrieval"]["matched"] == 0 diff --git a/tests/test_streamlit_smoke.py b/tests/test_streamlit_smoke.py new file mode 100644 index 00000000..40dcbce8 --- /dev/null +++ b/tests/test_streamlit_smoke.py @@ -0,0 +1,10 @@ +import importlib + +import pytest + + +@pytest.mark.smoke +def test_streamlit_app_module_imports(): + pytest.importorskip("streamlit") + module = importlib.import_module("ui.streamlit_app") + assert module is not None diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 00000000..9527dbfe --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,72 @@ +def test_ask_missing_question_returns_400(client): + response = client.post("/ask", json={}) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_question_too_short_returns_400(client): + payload = {"question": "hey", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_question_too_long_returns_400(client): + payload = {"question": "x" * 301, "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_top_k_zero_returns_400(client): + payload = {"question": "What are your overdraft fees?", "top_k": 0} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_top_k_above_range_returns_400(client): + payload = {"question": "What are your overdraft fees?", "top_k": 6} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_omitted_top_k_uses_default(client): + payload = {"question": "What are your overdraft fees?"} + response = client.post("/ask", json=payload) + assert response.status_code == 200 + assert response.json()["retrieval"]["top_k"] == 3 + + +def test_ask_question_min_length_is_allowed(client): + payload = {"question": "abcde", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 200 + + +def test_ask_question_max_length_is_allowed(client): + payload = {"question": "x" * 300, "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 200 + + +def test_ask_top_k_negative_returns_400(client): + payload = {"question": "What are your overdraft fees?", "top_k": -1} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +def test_ask_top_k_non_int_returns_400(client): + payload = {"question": "What are your overdraft fees?", "top_k": "three"} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() From de0ea84d2aeaf967bba93580eef47abeca188105 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 20:24:51 -0500 Subject: [PATCH 04/20] docs: Update project brief and specifications, enhance test coverage, and improve validation criteria --- PROJECT_BRIEF.md | 5 +- SPECS/ask-endpoint-validation.md | 16 +- SPECS/ask-response-contract.md | 15 +- SPECS/entrypoint-cli.md | 36 +++++ SPECS/faq-data.md | 37 ++--- SPECS/generation-mock.md | 13 +- SPECS/generation-optional-llm.md | 18 ++- SPECS/health-endpoint.md | 9 +- SPECS/retrieval-pipeline.md | 15 +- SPECS/spec-traceability.md | 11 +- SPECS/streamlit-ui.md | 29 ++-- pytest.ini | 6 + tests/conftest.py | 19 ++- tests/test_cli.py | 245 +++++++++++++++++++++++++++++++ tests/test_contract.py | 37 +++++ tests/test_data_loader.py | 36 +++++ tests/test_determinism.py | 10 ++ tests/test_generator_config.py | 18 +++ tests/test_health.py | 11 ++ tests/test_retrieval.py | 34 +++++ tests/test_streamlit_smoke.py | 7 + tests/test_validation.py | 196 +++++++++++++++++++++++++ 22 files changed, 756 insertions(+), 67 deletions(-) create mode 100644 SPECS/entrypoint-cli.md create mode 100644 tests/test_cli.py diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md index 5799a92a..fb193f90 100644 --- a/PROJECT_BRIEF.md +++ b/PROJECT_BRIEF.md @@ -1,4 +1,4 @@ -# Project Brief — Spec-Driven RAG FAQ API (Mockridge Bank) +# Project Brief — Customer FAQ Assistant (Mockridge Bank) ## Purpose of This File @@ -17,7 +17,7 @@ Note: Per project rules, do not use `README.md` as context for the LLM/tool. ## Project Overview -Build a spec-driven, testable RAG-style Customer FAQ Answering API +Build a spec-driven, testable RAG-style Customer FAQ Assistant for a fictional bank named "Mockridge Bank". The system: @@ -141,6 +141,7 @@ Coverage is defined by the specs in `SPECS/` and implemented in `tests/`. - `app/models.py` Pydantic schemas - `app/retrieval.py` ChromaDB + embeddings - `app/generation.py` mock + optional LLM generator +- `run.py` cross-platform entry point for setup, run, and test commands - `data/faq/*` local FAQ corpus - `ui/streamlit_app.py` single-page Streamlit UI - `tests/` pytest suite diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md index b04cfdc3..384e061e 100644 --- a/SPECS/ask-endpoint-validation.md +++ b/SPECS/ask-endpoint-validation.md @@ -28,10 +28,14 @@ - Error responses MUST include a top-level `detail` field suitable for user-facing validation feedback. - Validation MUST run before retrieval or generation logic executes. +## Related Specifications +- `ask-response-contract.md` - Defines success response schema for valid requests +- `retrieval-pipeline.md` - Processes validated requests + ## Acceptance Criteria -- [ ] Missing `question` returns `400`. -- [ ] `question` shorter than 5 characters returns `400`. -- [ ] `question` longer than 300 characters returns `400`. -- [ ] `top_k = 0` returns `400`. -- [ ] `top_k > 5` returns `400`. -- [ ] Omitted `top_k` is accepted and treated as `3`. +- [x] Missing `question` returns `400`. (test_validation.py::test_ask_missing_question_returns_400) +- [x] `question` shorter than 5 characters returns `400`. (test_validation.py::test_ask_question_too_short_returns_400) +- [x] `question` longer than 300 characters returns `400`. (test_validation.py::test_ask_question_too_long_returns_400) +- [x] `top_k = 0` returns `400`. (test_validation.py::test_ask_top_k_zero_returns_400) +- [x] `top_k > 5` returns `400`. (test_validation.py::test_ask_top_k_above_range_returns_400) +- [x] Omitted `top_k` is accepted and treated as `3`. (test_validation.py::test_ask_omitted_top_k_uses_default) diff --git a/SPECS/ask-response-contract.md b/SPECS/ask-response-contract.md index 0718b585..ca970d74 100644 --- a/SPECS/ask-response-contract.md +++ b/SPECS/ask-response-contract.md @@ -33,9 +33,14 @@ - `sources` MUST equal `[]`. - `retrieval.matched` MUST equal `0`. +## Related Specifications +- `ask-endpoint-validation.md` - Defines request validation before response generation +- `retrieval-pipeline.md` - Provides source data for response +- `generation-mock.md` - Generates answer content for response + ## Acceptance Criteria -- [ ] Contract test validates required top-level keys exist on every 200 response. -- [ ] Contract test validates source item field presence and scalar types. -- [ ] Contract test validates `answer`, `id`, `title`, and `snippet` are non-empty strings. -- [ ] Fallback path preserves full schema and uses empty `sources`. -- [ ] Retrieval metadata fields are always present and consistent with payload. +- [x] Contract test validates required top-level keys exist on every 200 response. (test_contract.py::test_ask_response_contract_contains_required_top_level_keys) +- [x] Contract test validates source item field presence and scalar types. (test_contract.py::test_ask_response_sources_have_required_fields_when_present) +- [x] Contract test validates `answer`, `id`, `title`, and `snippet` are non-empty strings. (test_contract.py::test_ask_response_sources_have_required_fields_when_present) +- [x] Fallback path preserves full schema and uses empty `sources`. (test_contract.py::test_ask_fallback_keeps_stable_schema) +- [x] Retrieval metadata fields are always present and consistent with payload. (test_contract.py::test_ask_response_retrieval_metadata_has_required_fields) diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md new file mode 100644 index 00000000..0fd9a138 --- /dev/null +++ b/SPECS/entrypoint-cli.md @@ -0,0 +1,36 @@ +# Feature Spec: Entrypoint CLI (run.py) + +## Goal +- Provide a single cross-platform entry point for setup, running services, and tests. + +## Scope +- In: + - A `run.py` script that manages setup, API, UI, and test commands. + - Cross-platform support for macOS, Linux, and Windows. + - Clear help output with available commands. +- Out: + - Full environment management beyond project dependencies. + - Complex process supervision or daemonization. + +## Requirements +- `run.py` MUST support these commands: + - `setup`: Install dependencies. + - `api`: Run the FastAPI backend. + - `ui`: Run the Streamlit UI. + - `fullstack`: Run API and UI concurrently. + - `test`: Run pytest. + - `help`: Show available commands and examples. +- `run.py` MUST be cross-platform and use `sys.executable` for subprocess calls. +- `run.py` MUST default to creating and using a local `.venv` for setup. +- `run.py` MUST support a `--no-venv` option to install into the current environment instead. +- `fullstack` command MUST start API and UI on their default ports and shut down cleanly on Ctrl+C. +- Commands MUST print clear status messages and fail clearly with actionable errors. + +## Acceptance Criteria +- [ ] `python run.py help` prints usage and available commands. (test_cli.py - to be implemented) +- [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) +- [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) +- [ ] `python run.py api` starts the backend. (manual acceptance) +- [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) +- [ ] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (manual acceptance) +- [ ] `python run.py test` runs pytest successfully. (test_cli.py - to be implemented) diff --git a/SPECS/faq-data.md b/SPECS/faq-data.md index 920e5d75..f77b85b9 100644 --- a/SPECS/faq-data.md +++ b/SPECS/faq-data.md @@ -15,29 +15,32 @@ - Corpus MUST contain between 8 and 15 documents. - Corpus content MUST represent the fictitious institution name as `Mockridge Bank`. - Each document MUST include: - - `id` - - `title` - - `body` + - `id` (unique identifier) + - `title` (document title) + - `body` (document content) - For markdown documents, required fields MUST be explicitly present as: - `id: ` - `title: ` - Corpus MUST represent core Mockridge Bank topics, including: - - checking accounts - - savings accounts - - auto loans - - credit cards - - overdraft fees - - fraud/disputes - - mobile app - - support hours + - Checking accounts + - Savings accounts + - Auto loans + - Credit cards + - Overdraft fees + - Fraud/disputes + - Mobile app + - Support hours - Document content MUST be stable and human-readable. - Corpus format MAY be markdown or JSON, but parser behavior MUST be documented. -- IDs MUST be unique across corpus. +- Document IDs MUST be unique across corpus. - Corpus MUST be local and committed to repository. -- Corpus MUST not include sensitive data, credentials, or personal information. +- Corpus MUST NOT include sensitive data, credentials, or personal information. + +## Related Specifications +- `retrieval-pipeline.md` - Consumes FAQ corpus for semantic search ## Acceptance Criteria -- [ ] Data loader can parse all corpus files without runtime errors. -- [ ] Document IDs are unique and non-empty. -- [ ] At least one retrieval test depends on known corpus content and passes. -- [ ] Corpus size is within defined range (8-15). +- [x] Data loader can parse all corpus files without runtime errors. (test_data_loader.py::test_faq_docs_have_required_fields_and_non_empty_values) +- [x] Document IDs are unique and non-empty. (test_data_loader.py::test_faq_document_ids_are_unique) +- [x] At least one retrieval test depends on known corpus content and passes. (test_retrieval.py::test_known_query_returns_expected_top_document) +- [x] Corpus size is within defined range (8-15). (test_data_loader.py::test_faq_corpus_size_within_expected_range) diff --git a/SPECS/generation-mock.md b/SPECS/generation-mock.md index ea45d92d..5882db9d 100644 --- a/SPECS/generation-mock.md +++ b/SPECS/generation-mock.md @@ -23,8 +23,13 @@ - `sources` MUST be empty in API response. - Generator interface MUST be cleanly swappable with optional LLM generator. +## Related Specifications +- `generation-optional-llm.md` - Alternative LLM-based generator (opt-in) +- `retrieval-pipeline.md` - Provides source documents for answer generation +- `ask-response-contract.md` - Defines answer field in response schema + ## Acceptance Criteria -- [ ] Tests run fully with `RAG_GENERATOR=mock` and no model downloads. -- [ ] Repeated identical requests produce identical answers. -- [ ] Matched retrieval path returns a non-empty answer derived from source content. -- [ ] Unmatched retrieval path returns fallback answer and empty sources. +- [x] Tests run fully with `RAG_GENERATOR=mock` and no model downloads. (conftest.py::default_generator_env) +- [x] Repeated identical requests produce identical answers. (test_determinism.py::test_same_input_produces_same_output) +- [x] Matched retrieval path returns a non-empty answer derived from source content. (test_contract.py::test_ask_response_contract_contains_required_top_level_keys) +- [x] Unmatched retrieval path returns fallback answer and empty sources. (test_retrieval.py::test_unknown_query_returns_fallback_with_empty_sources) diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index c67f683f..bc7b06ec 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -13,21 +13,25 @@ - Any requirement for LLM mode during tests. ## Requirements -- LLM mode MUST be opt-in via: +- LLM mode MUST be opt-in via environment variable: - `RAG_GENERATOR=distilgpt2` - Default mode MUST remain `mock`. -- LLM mode MUST not be required to start or test default application workflow. +- LLM mode MUST NOT be required to start or test default application workflow. - If LLM mode is selected and model assets are unavailable: - System MUST fail clearly with actionable local setup guidance. -- No secrets or API keys may be required for LLM mode. +- LLM mode MUST NOT require secrets or API keys. - Repository MUST NOT commit model weight files. -- README MUST clearly state: +- Documentation MUST clearly state: - LLM mode is optional. - First-run download size/cost is local disk/network only. - Tests run without LLM mode. +## Related Specifications +- `generation-mock.md` - Default deterministic generator (required for tests) +- `retrieval-pipeline.md` - Provides source documents for LLM context + ## Acceptance Criteria -- [ ] With default environment, app uses mock generator. -- [ ] With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter. -- [ ] Test suite does not depend on `distilgpt2`. +- [x] With default environment, app uses mock generator. (test_generator_config.py::test_default_generator_mode_is_mock_deterministic) +- [x] With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter. (test_generator_config.py::test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable) +- [x] Test suite does not depend on `distilgpt2`. (conftest.py::default_generator_env) - [ ] Documentation explains optional setup and non-requirement for tests. diff --git a/SPECS/health-endpoint.md b/SPECS/health-endpoint.md index 89ef541b..1ba2009f 100644 --- a/SPECS/health-endpoint.md +++ b/SPECS/health-endpoint.md @@ -17,7 +17,10 @@ - Response content type MUST be JSON. - Endpoint behavior MUST be deterministic and independent of retrieval/generation subsystems. +## Related Specifications +- None - Health endpoint is independent of other features + ## Acceptance Criteria -- [ ] Calling `GET /health` returns status code `200`. -- [ ] Response JSON includes key `status` with value `ok`. -- [ ] No API keys, external services, or model downloads are required. +- [x] Calling `GET /health` returns status code `200`. (test_health.py::test_health_returns_ok) +- [x] Response JSON includes key `status` with value `ok`. (test_health.py::test_health_returns_ok) +- [x] No API keys, external services, or model downloads are required. (test_health.py::test_health_returns_ok) diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index e292d441..5f3aeb41 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -32,9 +32,14 @@ - `matched` as number of documents included in `sources`. - Retrieval behavior MUST be deterministic for the same corpus and input. +## Related Specifications +- `faq-data.md` - Defines corpus structure that retrieval depends on +- `ask-endpoint-validation.md` - Validates request before retrieval +- `generation-mock.md` - Consumes retrieval results to generate answers + ## Acceptance Criteria -- [ ] A known banking query retrieves an expected FAQ document as top result. -- [ ] Changing `top_k` changes the maximum returned source count accordingly. -- [ ] Source list is sorted by score descending. -- [ ] Unknown/out-of-domain query triggers unmatched retrieval path. -- [ ] Retrieval metadata reports `top_k` and `matched` accurately. +- [x] A known banking query retrieves an expected FAQ document as top result. (test_retrieval.py::test_known_query_returns_expected_top_document) +- [x] Changing `top_k` changes the maximum returned source count accordingly. (test_retrieval.py::test_top_k_controls_maximum_number_of_sources) +- [x] Source list is sorted by score descending. (test_retrieval.py::test_sources_are_sorted_by_descending_score) +- [x] Unknown/out-of-domain query triggers unmatched retrieval path. (test_retrieval.py::test_unknown_query_returns_fallback_with_empty_sources) +- [x] Retrieval metadata reports `top_k` and `matched` accurately. (test_contract.py::test_ask_response_retrieval_metadata_has_required_fields) diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index 517e562e..26858e3a 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -17,9 +17,9 @@ - Traceability document MUST be updated when adding/changing feature specs. ## Acceptance Criteria -- [ ] Matrix includes each current spec file in `SPECS/`. -- [ ] Matrix lists at least one test target per spec. -- [ ] Matrix lists at least one implementation target per spec. +- [x] Matrix includes each current spec file in `SPECS/`. +- [x] Matrix lists at least one test target per spec. +- [x] Matrix lists at least one implementation target per spec. ## Mapping - `SPECS/health-endpoint.md` @@ -32,7 +32,7 @@ - Tests: `tests/test_retrieval.py` - Implementation: `app/retrieval.py` - `SPECS/generation-mock.md` - - Tests: `tests/test_retrieval.py`, `tests/test_contract.py` + - Tests: `tests/test_determinism.py`, `tests/test_retrieval.py`, `tests/test_contract.py` - Implementation: `app/generation.py` - `SPECS/generation-optional-llm.md` - Tests: `tests/test_generator_config.py` (optional/non-blocking), existing suite in mock mode @@ -46,3 +46,6 @@ - `SPECS/streamlit-ui.md` - Tests: `tests/test_streamlit_smoke.py` (optional smoke), manual acceptance checks - Implementation: `ui/streamlit_app.py` +- `SPECS/entrypoint-cli.md` + - Tests: `tests/test_cli.py`, manual acceptance checks + - Implementation: `run.py` diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index c256a2e2..81955cfb 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -1,12 +1,12 @@ -# Feature Spec: Streamlit UI +# Feature Spec: Customer FAQ Assistant UI (Streamlit) ## Goal -- Provide a simple local UI to interact with the RAG FAQ API for manual testing and demonstration. +- Provide a simple local UI to interact with the Customer FAQ Assistant for manual testing and demonstration. ## Scope - In: - - A single-page Streamlit application. - - A Streamlit app with question input and `top_k` control. + - Single-page Streamlit application. + - Question input and `top_k` control. - Submit workflow that calls `POST /ask`. - Rendering of answer, sources, and retrieval metadata. - User-visible handling for API validation and runtime errors. @@ -33,12 +33,17 @@ - UI MUST not require optional LLM mode; default mock mode must be fully supported. - UI MUST not embed secrets or credentials in code. +## Related Specifications +- `ask-endpoint-validation.md` - Defines validation rules that UI must handle +- `ask-response-contract.md` - Defines response schema that UI must render +- `generation-mock.md` - Default generator that UI relies on + ## Acceptance Criteria -- [ ] User can enter a valid question, submit, and view answer output. -- [ ] User can change `top_k` and see reflected retrieval metadata. -- [ ] Source citations are rendered when present. -- [ ] Fallback path is visible and understandable when no matches exist. -- [ ] Validation errors are shown in the UI without app crash. -- [ ] UI runs locally against the API in default mock mode. -- [ ] UI is implemented as a single page. -- [ ] UI is accessible without authentication or account flows. +- [ ] User can enter a valid question, submit, and view answer output. (manual acceptance) +- [ ] User can change `top_k` and see reflected retrieval metadata. (manual acceptance) +- [ ] Source citations are rendered when present. (manual acceptance) +- [ ] Fallback path is visible and understandable when no matches exist. (manual acceptance) +- [ ] Validation errors are shown in the UI without app crash. (manual acceptance) +- [x] UI runs locally against the API in default mock mode. (test_streamlit_smoke.py::test_streamlit_app_module_imports) +- [ ] UI is implemented as a single page. (manual acceptance) +- [ ] UI is accessible without authentication or account flows. (manual acceptance) diff --git a/pytest.ini b/pytest.ini index f6fc1c4f..b874a31b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,3 +4,9 @@ python_files = test_*.py addopts = -ra markers = smoke: quick smoke checks for module startup/import paths + unit: unit tests for isolated component behavior + integration: integration tests requiring multiple components + requires_data: tests that depend on FAQ corpus data + optional: optional tests that don't block CI (e.g., LLM mode) + contract: API contract validation tests + validation: input validation and error handling tests diff --git a/tests/conftest.py b/tests/conftest.py index 0f77611d..83998c31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,13 @@ @pytest.fixture(autouse=True) def default_generator_env(monkeypatch: pytest.MonkeyPatch): """ - Force deterministic generator mode unless a test explicitly overrides it. + Force deterministic generator mode for all tests unless explicitly overridden. + + This fixture automatically sets RAG_GENERATOR=mock for every test to ensure + deterministic behavior and avoid requiring LLM model downloads during testing. + + Spec: generation-mock.md, generation-optional-llm.md + Requirement: "Default generator MUST be selected when `RAG_GENERATOR` is unset or set to `mock`" """ monkeypatch.setenv("RAG_GENERATOR", "mock") @@ -13,7 +19,16 @@ def default_generator_env(monkeypatch: pytest.MonkeyPatch): @pytest.fixture() def client() -> TestClient: """ - Import the FastAPI app lazily so tests clearly fail until app.main exists. + Provide a FastAPI TestClient for making HTTP requests to the API in tests. + + The app is imported lazily to provide clear failure messages if app.main + doesn't exist yet. This fixture is used by all API integration tests. + + Returns: + TestClient: Configured test client for the FastAPI application + + Spec: health-endpoint.md, ask-endpoint-validation.md, ask-response-contract.md + Note: Used by all endpoint tests to interact with the API """ try: from app.main import app diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..c927ee4f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,245 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).parent.parent +RUN_PY = PROJECT_ROOT / "run.py" + + +@pytest.mark.unit +def test_run_py_exists(): + """ + Verify that run.py file exists in the project root. + + Spec: entrypoint-cli.md + Requirement: "A `run.py` script that manages setup, API, UI, and test commands" + """ + assert RUN_PY.exists(), f"Expected run.py at {RUN_PY}" + assert RUN_PY.is_file() + + +@pytest.mark.integration +def test_run_py_help_command_prints_usage(monkeypatch): + """ + Verify that 'python run.py help' prints usage and available commands. + + Spec: entrypoint-cli.md + Acceptance Criteria: "`python run.py help` prints usage and available commands" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "help"], + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, f"Expected exit code 0, got {result.returncode}" + output = result.stdout.lower() + + # Verify that help output mentions the required commands + assert "setup" in output, "Help should mention 'setup' command" + assert "api" in output, "Help should mention 'api' command" + assert "ui" in output, "Help should mention 'ui' command" + assert "fullstack" in output, "Help should mention 'fullstack' command" + assert "test" in output, "Help should mention 'test' command" + assert "help" in output, "Help should mention 'help' command" + + +@pytest.mark.integration +def test_run_py_test_command_executes_pytest(): + """ + Verify that 'python run.py test' runs pytest successfully. + + Spec: entrypoint-cli.md + Acceptance Criteria: "`python run.py test` runs pytest successfully" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "test"], + capture_output=True, + text=True, + timeout=120, + cwd=PROJECT_ROOT, + ) + + # Test command should execute pytest + # It may pass or fail, but should invoke pytest + output = result.stdout + result.stderr + assert "pytest" in output.lower() or "test" in output.lower(), \ + "Test command should invoke pytest" + + +@pytest.mark.integration +def test_run_py_invalid_command_fails_clearly(): + """ + Verify that run.py fails clearly with actionable error for invalid commands. + + Spec: entrypoint-cli.md + Requirement: "Commands MUST print clear status messages and fail clearly with actionable errors" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "invalid_command_xyz"], + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode != 0, "Invalid command should return non-zero exit code" + output = result.stdout + result.stderr + assert len(output) > 0, "Error message should be printed for invalid command" + + +@pytest.mark.integration +def test_run_py_no_arguments_shows_help(): + """ + Verify that running 'python run.py' without arguments shows help or usage. + + Spec: entrypoint-cli.md + Requirement: "Clear help output with available commands" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY)], + capture_output=True, + text=True, + timeout=10, + ) + + # Should either show help or fail with usage message + output = result.stdout + result.stderr + assert len(output) > 0, "Should print usage or help when no arguments provided" + + +@pytest.mark.integration +def test_run_py_recognizes_setup_command(): + """ + Verify that run.py recognizes 'setup' command. + + Spec: entrypoint-cli.md + Requirement: "`run.py` MUST support these commands: `setup`" + Note: This test verifies command recognition, not full execution + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + # Using --help flag if available, or checking error message doesn't say "unknown command" + result = subprocess.run( + [sys.executable, str(RUN_PY), "setup", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + + # If --help isn't supported, command should still be recognized + output = result.stdout + result.stderr + assert "unknown" not in output.lower() or result.returncode == 0, \ + "Setup command should be recognized" + + +@pytest.mark.integration +def test_run_py_recognizes_api_command(): + """ + Verify that run.py recognizes 'api' command. + + Spec: entrypoint-cli.md + Requirement: "`run.py` MUST support these commands: `api`" + Note: This test verifies command recognition, not full execution + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "api", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + + output = result.stdout + result.stderr + assert "unknown" not in output.lower() or result.returncode == 0, \ + "API command should be recognized" + + +@pytest.mark.integration +def test_run_py_recognizes_ui_command(): + """ + Verify that run.py recognizes 'ui' command. + + Spec: entrypoint-cli.md + Requirement: "`run.py` MUST support these commands: `ui`" + Note: This test verifies command recognition, not full execution + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "ui", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + + output = result.stdout + result.stderr + assert "unknown" not in output.lower() or result.returncode == 0, \ + "UI command should be recognized" + + +@pytest.mark.integration +def test_run_py_recognizes_fullstack_command(): + """ + Verify that run.py recognizes 'fullstack' command. + + Spec: entrypoint-cli.md + Requirement: "`run.py` MUST support these commands: `fullstack`" + Note: This test verifies command recognition, not full execution + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "fullstack", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + + output = result.stdout + result.stderr + assert "unknown" not in output.lower() or result.returncode == 0, \ + "Fullstack command should be recognized" + + +@pytest.mark.integration +def test_run_py_is_executable_with_python(): + """ + Verify that run.py can be executed with sys.executable. + + Spec: entrypoint-cli.md + Requirement: "`run.py` MUST be cross-platform and use `sys.executable` for subprocess calls" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "help"], + capture_output=True, + text=True, + timeout=10, + ) + + # Should execute without import errors or syntax errors + assert "SyntaxError" not in result.stderr, "run.py should not have syntax errors" + assert "ImportError" not in result.stderr or result.returncode == 0, \ + "run.py should handle imports gracefully" diff --git a/tests/test_contract.py b/tests/test_contract.py index 16e27137..52b2a718 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,4 +1,15 @@ +import pytest + + +@pytest.mark.contract +@pytest.mark.integration def test_ask_response_contract_contains_required_top_level_keys(client): + """ + Verify that POST /ask response contains required top-level keys and types. + + Spec: ask-response-contract.md + Acceptance Criteria: "Contract test validates required top-level keys exist on every 200 response" + """ payload = {"question": "What are your savings account options?", "top_k": 3} response = client.post("/ask", json=payload) @@ -15,7 +26,17 @@ def test_ask_response_contract_contains_required_top_level_keys(client): assert body["answer"].strip() != "" +@pytest.mark.contract +@pytest.mark.integration +@pytest.mark.requires_data def test_ask_response_sources_have_required_fields_when_present(client): + """ + Verify that source items in response contain all required fields with correct types. + + Spec: ask-response-contract.md + Acceptance Criteria: "Contract test validates source item field presence and scalar types" + "Contract test validates `answer`, `id`, `title`, and `snippet` are non-empty strings" + """ payload = {"question": "Tell me about checking accounts", "top_k": 3} response = client.post("/ask", json=payload) @@ -37,7 +58,15 @@ def test_ask_response_sources_have_required_fields_when_present(client): assert source["snippet"].strip() != "" +@pytest.mark.contract +@pytest.mark.integration def test_ask_response_retrieval_metadata_has_required_fields(client): + """ + Verify that retrieval metadata contains required fields and matches payload. + + Spec: ask-response-contract.md + Acceptance Criteria: "Retrieval metadata fields are always present and consistent with payload" + """ payload = {"question": "Do you offer auto loans?", "top_k": 2} response = client.post("/ask", json=payload) @@ -53,7 +82,15 @@ def test_ask_response_retrieval_metadata_has_required_fields(client): assert retrieval["matched"] == len(body["sources"]) +@pytest.mark.contract +@pytest.mark.integration def test_ask_fallback_keeps_stable_schema(client): + """ + Verify that fallback response (no matches) preserves full schema contract. + + Spec: ask-response-contract.md + Acceptance Criteria: "Fallback path preserves full schema and uses empty `sources`" + """ payload = { "question": "zxqyqv synthetic non banking phrase no match please", "top_k": 3, diff --git a/tests/test_data_loader.py b/tests/test_data_loader.py index 03a3695c..760e1c3d 100644 --- a/tests/test_data_loader.py +++ b/tests/test_data_loader.py @@ -2,6 +2,8 @@ import re from pathlib import Path +import pytest + FAQ_DIR = Path("data/faq") @@ -36,17 +38,42 @@ def _load_docs(): return docs +@pytest.mark.unit def test_faq_directory_exists(): + """ + Verify that the FAQ corpus directory exists at data/faq/. + + Spec: faq-data.md + Requirement: "Corpus MUST be local and committed to repository" + """ assert FAQ_DIR.exists(), "Expected FAQ directory at data/faq" assert FAQ_DIR.is_dir() +@pytest.mark.unit +@pytest.mark.requires_data def test_faq_corpus_size_within_expected_range(): + """ + Verify that FAQ corpus contains between 8 and 15 documents. + + Spec: faq-data.md + Requirement: "Corpus MUST contain between 8 and 15 documents" + Acceptance Criteria: "Corpus size is within defined range (8-15)" + """ docs = _load_docs() assert 8 <= len(docs) <= 15 +@pytest.mark.unit +@pytest.mark.requires_data def test_faq_docs_have_required_fields_and_non_empty_values(): + """ + Verify that all FAQ documents have required fields (id, title, body) with non-empty values. + + Spec: faq-data.md + Requirement: "Each document MUST include: `id`, `title`, `body`" + Acceptance Criteria: "Data loader can parse all corpus files without runtime errors" + """ docs = _load_docs() assert docs, "No FAQ docs found in data/faq" @@ -57,7 +84,16 @@ def test_faq_docs_have_required_fields_and_non_empty_values(): assert isinstance(doc["body"], str) and doc["body"] != "" +@pytest.mark.unit +@pytest.mark.requires_data def test_faq_document_ids_are_unique(): + """ + Verify that all FAQ document IDs are unique across the corpus. + + Spec: faq-data.md + Requirement: "IDs MUST be unique across corpus" + Acceptance Criteria: "Document IDs are unique and non-empty" + """ docs = _load_docs() ids = [doc["id"] for doc in docs] assert len(ids) == len(set(ids)) diff --git a/tests/test_determinism.py b/tests/test_determinism.py index 5d9f707f..54258ec0 100644 --- a/tests/test_determinism.py +++ b/tests/test_determinism.py @@ -1,4 +1,14 @@ +import pytest + + +@pytest.mark.integration def test_same_input_produces_same_output(client): + """ + Verify that repeated identical requests produce identical answers (determinism). + + Spec: generation-mock.md + Acceptance Criteria: "Repeated identical requests produce identical answers" + """ payload = {"question": "Do you support fraud disputes in the mobile app?", "top_k": 3} first = client.post("/ask", json=payload) diff --git a/tests/test_generator_config.py b/tests/test_generator_config.py index d2444b8b..32f94afe 100644 --- a/tests/test_generator_config.py +++ b/tests/test_generator_config.py @@ -1,4 +1,14 @@ +import pytest + + +@pytest.mark.integration def test_default_generator_mode_is_mock_deterministic(client): + """ + Verify that default generator mode uses mock and produces deterministic output. + + Spec: generation-optional-llm.md + Acceptance Criteria: "With default environment, app uses mock generator" + """ payload = {"question": "How can I contact support?", "top_k": 3} first = client.post("/ask", json=payload) second = client.post("/ask", json=payload) @@ -8,7 +18,15 @@ def test_default_generator_mode_is_mock_deterministic(client): assert first.json() == second.json() +@pytest.mark.optional +@pytest.mark.integration def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(monkeypatch): + """ + Verify that distilgpt2 mode routes through LLM adapter or fails clearly if unavailable. + + Spec: generation-optional-llm.md + Acceptance Criteria: "With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter" + """ # Ensure the app module is imported after setting env. monkeypatch.setenv("RAG_GENERATOR", "distilgpt2") diff --git a/tests/test_health.py b/tests/test_health.py index a02381cf..5c031ee5 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,15 @@ +import pytest + + +@pytest.mark.integration def test_health_returns_ok(client): + """ + Verify that GET /health returns 200 with {"status": "ok"} JSON response. + + Spec: health-endpoint.md + Acceptance Criteria: "Calling `GET /health` returns status code `200`" + "Response JSON includes key `status` with value `ok`" + """ response = client.get("/health") assert response.status_code == 200 diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py index 6ba0347d..c1710a76 100644 --- a/tests/test_retrieval.py +++ b/tests/test_retrieval.py @@ -1,4 +1,15 @@ +import pytest + + +@pytest.mark.integration +@pytest.mark.requires_data def test_known_query_returns_expected_top_document(client): + """ + Verify that a known banking query retrieves the expected FAQ document. + + Spec: retrieval-pipeline.md + Acceptance Criteria: "A known banking query retrieves an expected FAQ document as top result" + """ payload = {"question": "What are your checking account monthly fees?", "top_k": 3} response = client.post("/ask", json=payload) @@ -12,7 +23,15 @@ def test_known_query_returns_expected_top_document(client): assert "checking" in combined or "checking_accounts" in top_source.get("id", "").lower() +@pytest.mark.integration +@pytest.mark.requires_data def test_top_k_controls_maximum_number_of_sources(client): + """ + Verify that changing top_k parameter controls the maximum returned source count. + + Spec: retrieval-pipeline.md + Acceptance Criteria: "Changing `top_k` changes the maximum returned source count accordingly" + """ payload = {"question": "Tell me about bank account options", "top_k": 1} response = client.post("/ask", json=payload) @@ -23,7 +42,15 @@ def test_top_k_controls_maximum_number_of_sources(client): assert body["retrieval"]["matched"] == len(body["sources"]) +@pytest.mark.integration +@pytest.mark.requires_data def test_sources_are_sorted_by_descending_score(client): + """ + Verify that source list is sorted by relevance score in descending order. + + Spec: retrieval-pipeline.md + Acceptance Criteria: "Source list is sorted by score descending" + """ payload = {"question": "How do overdraft fees work?", "top_k": 5} response = client.post("/ask", json=payload) @@ -36,7 +63,14 @@ def test_sources_are_sorted_by_descending_score(client): assert scores == sorted(scores, reverse=True) +@pytest.mark.integration def test_unknown_query_returns_fallback_with_empty_sources(client): + """ + Verify that out-of-domain query triggers unmatched retrieval (fallback) path. + + Spec: retrieval-pipeline.md + Acceptance Criteria: "Unknown/out-of-domain query triggers unmatched retrieval path" + """ payload = { "question": "quartz nebula hedgehog protocol 91821 unrelated", "top_k": 3, diff --git a/tests/test_streamlit_smoke.py b/tests/test_streamlit_smoke.py index 40dcbce8..f87d818b 100644 --- a/tests/test_streamlit_smoke.py +++ b/tests/test_streamlit_smoke.py @@ -5,6 +5,13 @@ @pytest.mark.smoke def test_streamlit_app_module_imports(): + """ + Verify that the Streamlit UI module can be imported without errors. + + Spec: streamlit-ui.md + Acceptance Criteria: "UI runs locally against the API in default mock mode" + Note: This is a smoke test to ensure the UI module exists and has no import errors. + """ pytest.importorskip("streamlit") module = importlib.import_module("ui.streamlit_app") assert module is not None diff --git a/tests/test_validation.py b/tests/test_validation.py index 9527dbfe..864a6e3c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,11 +1,30 @@ +import pytest + + +@pytest.mark.validation +@pytest.mark.integration def test_ask_missing_question_returns_400(client): + """ + Verify that POST /ask returns 400 when question field is missing. + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "Missing `question` returns `400`" + """ response = client.post("/ask", json={}) assert response.status_code == 400 assert response.headers.get("content-type", "").startswith("application/json") assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_question_too_short_returns_400(client): + """ + Verify that POST /ask returns 400 when question is shorter than 5 characters. + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "`question` shorter than 5 characters returns `400`" + """ payload = {"question": "hey", "top_k": 3} response = client.post("/ask", json=payload) assert response.status_code == 400 @@ -13,7 +32,15 @@ def test_ask_question_too_short_returns_400(client): assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_question_too_long_returns_400(client): + """ + Verify that POST /ask returns 400 when question is longer than 300 characters. + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "`question` longer than 300 characters returns `400`" + """ payload = {"question": "x" * 301, "top_k": 3} response = client.post("/ask", json=payload) assert response.status_code == 400 @@ -21,7 +48,15 @@ def test_ask_question_too_long_returns_400(client): assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_top_k_zero_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is 0. + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "`top_k = 0` returns `400`" + """ payload = {"question": "What are your overdraft fees?", "top_k": 0} response = client.post("/ask", json=payload) assert response.status_code == 400 @@ -29,7 +64,15 @@ def test_ask_top_k_zero_returns_400(client): assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_top_k_above_range_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is above the valid range (>5). + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "`top_k > 5` returns `400`" + """ payload = {"question": "What are your overdraft fees?", "top_k": 6} response = client.post("/ask", json=payload) assert response.status_code == 400 @@ -37,26 +80,58 @@ def test_ask_top_k_above_range_returns_400(client): assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_omitted_top_k_uses_default(client): + """ + Verify that POST /ask accepts omitted top_k and defaults to 3. + + Spec: ask-endpoint-validation.md + Acceptance Criteria: "Omitted `top_k` is accepted and treated as `3`" + """ payload = {"question": "What are your overdraft fees?"} response = client.post("/ask", json=payload) assert response.status_code == 200 assert response.json()["retrieval"]["top_k"] == 3 +@pytest.mark.validation +@pytest.mark.integration def test_ask_question_min_length_is_allowed(client): + """ + Verify that POST /ask accepts a question at minimum length (5 characters). + + Spec: ask-endpoint-validation.md + Requirement: "`question` is required string with length `5..300`" + """ payload = {"question": "abcde", "top_k": 3} response = client.post("/ask", json=payload) assert response.status_code == 200 +@pytest.mark.validation +@pytest.mark.integration def test_ask_question_max_length_is_allowed(client): + """ + Verify that POST /ask accepts a question at maximum length (300 characters). + + Spec: ask-endpoint-validation.md + Requirement: "`question` is required string with length `5..300`" + """ payload = {"question": "x" * 300, "top_k": 3} response = client.post("/ask", json=payload) assert response.status_code == 200 +@pytest.mark.validation +@pytest.mark.integration def test_ask_top_k_negative_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is negative. + + Spec: ask-endpoint-validation.md + Requirement: "`top_k` is optional integer with default `3` and valid range `1..5`" + """ payload = {"question": "What are your overdraft fees?", "top_k": -1} response = client.post("/ask", json=payload) assert response.status_code == 400 @@ -64,9 +139,130 @@ def test_ask_top_k_negative_returns_400(client): assert "detail" in response.json() +@pytest.mark.validation +@pytest.mark.integration def test_ask_top_k_non_int_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is not an integer. + + Spec: ask-endpoint-validation.md + Requirement: "`top_k` is optional integer with default `3` and valid range `1..5`" + """ payload = {"question": "What are your overdraft fees?", "top_k": "three"} response = client.post("/ask", json=payload) assert response.status_code == 400 assert response.headers.get("content-type", "").startswith("application/json") assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_empty_string_question_returns_400(client): + """ + Verify that POST /ask returns 400 when question is an empty string. + + Spec: ask-endpoint-validation.md + Requirement: "`question` is required string with length `5..300`" + Edge case: Empty string is different from missing field + """ + payload = {"question": "", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_whitespace_only_question_returns_400(client): + """ + Verify that POST /ask returns 400 when question contains only whitespace. + + Spec: ask-endpoint-validation.md + Requirement: "`question` is required string with length `5..300`" + Edge case: Whitespace-only strings should not be accepted + """ + payload = {"question": " ", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_null_question_returns_400(client): + """ + Verify that POST /ask returns 400 when question is null/None. + + Spec: ask-endpoint-validation.md + Requirement: "`question` is required string with length `5..300`" + Edge case: Null values should be rejected + """ + payload = {"question": None, "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_top_k_float_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is a float. + + Spec: ask-endpoint-validation.md + Requirement: "`top_k` is optional integer with default `3` and valid range `1..5`" + Edge case: Float values like 2.5 should be rejected + """ + payload = {"question": "What are your overdraft fees?", "top_k": 2.5} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_top_k_null_returns_400(client): + """ + Verify that POST /ask returns 400 when top_k is explicitly null/None. + + Spec: ask-endpoint-validation.md + Requirement: "`top_k` is optional integer with default `3` and valid range `1..5`" + Edge case: Explicit null is different from omitted field and should be rejected + """ + payload = {"question": "What are your overdraft fees?", "top_k": None} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_question_with_special_characters_is_allowed(client): + """ + Verify that POST /ask accepts questions with special characters. + + Spec: ask-endpoint-validation.md + Edge case: Special characters should be allowed as long as length is valid + """ + payload = {"question": "What's the APR% for credit cards?", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 200 + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_question_with_unicode_is_allowed(client): + """ + Verify that POST /ask accepts questions with unicode characters. + + Spec: ask-endpoint-validation.md + Edge case: Unicode characters should be supported + """ + payload = {"question": "¿Cuáles son las tarifas de sobregiro?", "top_k": 3} + response = client.post("/ask", json=payload) + assert response.status_code == 200 From be41c47fbb09c9f8a5555a50409cf978d3ac0769 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 20:36:08 -0500 Subject: [PATCH 05/20] data: Generated documents that will be used to fill the vector databases for RAG --- data/faq/auto_loans.md | 16 ++++++++++++++++ data/faq/checking_accounts.md | 14 ++++++++++++++ data/faq/credit_cards.md | 16 ++++++++++++++++ data/faq/fraud_disputes.md | 14 ++++++++++++++ data/faq/mobile_app.md | 14 ++++++++++++++ data/faq/overdraft_fees.md | 14 ++++++++++++++ data/faq/savings_accounts.md | 16 ++++++++++++++++ data/faq/support_hours.md | 12 ++++++++++++ 8 files changed, 116 insertions(+) create mode 100644 data/faq/auto_loans.md create mode 100644 data/faq/checking_accounts.md create mode 100644 data/faq/credit_cards.md create mode 100644 data/faq/fraud_disputes.md create mode 100644 data/faq/mobile_app.md create mode 100644 data/faq/overdraft_fees.md create mode 100644 data/faq/savings_accounts.md create mode 100644 data/faq/support_hours.md diff --git a/data/faq/auto_loans.md b/data/faq/auto_loans.md new file mode 100644 index 00000000..b526d825 --- /dev/null +++ b/data/faq/auto_loans.md @@ -0,0 +1,16 @@ +id: auto_loans +title: Mockridge Bank Auto Loans +body: | + Mockridge Bank offers auto loans for new and used vehicles, including purchases from dealers or private sellers. Loan terms typically range from 36 to 72 months. Rates and terms depend on creditworthiness, vehicle age, loan amount, and term length. + + Pre-approval: You can apply for pre-approval online to understand your budget before shopping. Pre-approval does not require you to select a vehicle and can help when negotiating with a dealer. Pre-approval decisions are based on the information you provide and a credit review. + + Payment options: Automatic payments are available and may reduce your APR. You can select a due date that aligns with your budget, and set up email or mobile alerts for upcoming payments. There are no prepayment penalties, so you can pay extra or pay off the loan early without fees. + + Required documentation: Typical documentation includes proof of identity, proof of income, and vehicle details once selected. For private-party purchases, additional documentation may be required. Mockridge Bank will provide a checklist during the application process. + + Refinancing: If you already have an auto loan, you may be eligible to refinance with Mockridge Bank. Refinancing can help reduce your monthly payment, lower your rate, or adjust your term length based on your goals. + + Insurance and collateral: Auto loans are secured by the vehicle, and comprehensive and collision insurance are typically required for the life of the loan. Mockridge Bank will confirm insurance coverage as part of the funding process. + + To get current rates and determine eligibility, apply online or speak with a Mockridge Bank loan officer. Terms and rates are subject to change based on market conditions and applicant qualifications. diff --git a/data/faq/checking_accounts.md b/data/faq/checking_accounts.md new file mode 100644 index 00000000..e48e6c5f --- /dev/null +++ b/data/faq/checking_accounts.md @@ -0,0 +1,14 @@ +id: checking_accounts +title: Mockridge Bank Checking Accounts +body: | + Mockridge Bank offers two checking account options designed for everyday spending and simple money management: Everyday Checking and Premium Checking. Everyday Checking is intended for routine use such as bill pay, debit card purchases, and direct deposits. It includes a basic set of features without requiring a high balance. Premium Checking includes additional benefits for customers who keep larger balances or use more banking services. + + Fees and waivers: Everyday Checking has no monthly maintenance fee when you enroll in eStatements; otherwise a $5 monthly fee may apply. Premium Checking has a $15 monthly fee that can be waived with qualifying direct deposits or by maintaining a combined balance threshold across linked Mockridge Bank accounts. Fee waivers are evaluated each statement cycle based on your account activity. + + Included features: All checking accounts include a debit card, online banking, mobile check deposit, and access to the Mockridge Bank mobile app. You can set up automatic bill pay, recurring transfers, and low balance alerts. Both accounts support ATM withdrawals, and Premium Checking includes a higher allowance for out-of-network ATM fee reimbursements each month. + + Deposits and access: You can open a checking account online or at any Mockridge Bank branch with a valid ID and an initial deposit. Direct deposit is available for payroll and government benefits. Funds availability follows our standard deposit policy, which varies by deposit type and account history. + + Additional services: Overdraft coverage is optional and can be added or removed at any time. You can link a Mockridge Bank savings account to help cover overdrafts automatically. Wire transfers and cashier's checks are available for an additional fee. For customers who travel, Premium Checking includes waived incoming wire fees and enhanced fraud monitoring. + + If you are unsure which checking account is right for you, Mockridge Bank staff can review your typical monthly activity and recommend the best fit based on fees, balance patterns, and desired features. diff --git a/data/faq/credit_cards.md b/data/faq/credit_cards.md new file mode 100644 index 00000000..391ea328 --- /dev/null +++ b/data/faq/credit_cards.md @@ -0,0 +1,16 @@ +id: credit_cards +title: Mockridge Bank Credit Cards +body: | + Mockridge Bank offers two primary credit card options: the Cash Rewards Card and the Travel Rewards Card. Both cards include fraud monitoring, mobile alerts, and online account management through the Mockridge Bank mobile app and online banking. + + Cash Rewards Card: Earn 1.5% cash back on all purchases with no rotating categories. Cash back can be redeemed as a statement credit or deposited into a Mockridge Bank checking or savings account. There is no annual fee for the Cash Rewards Card. + + Travel Rewards Card: Earn points on travel and dining purchases and standard points on all other categories. Points can be redeemed for travel statement credits, gift cards, or merchandise. The Travel Rewards Card has no foreign transaction fees and includes travel assistance features such as emergency card replacement. + + Rates and limits: APRs and credit limits are based on creditworthiness. Introductory promotions may be available periodically, and details are provided at application time. You can view your current APR and minimum payment requirements in your account dashboard. + + Payment options: You can set up automatic payments for the minimum due, a fixed amount, or the full statement balance. Alerts can be configured for payment due dates and large transactions. + + Security: If you suspect fraud, you can lock your card instantly in the mobile app and contact Mockridge Bank support. Replacement cards can be ordered through the app or by phone. + + To apply, submit a secure online application or visit a Mockridge Bank branch. Approval decisions are based on credit history, income, and other eligibility factors. diff --git a/data/faq/fraud_disputes.md b/data/faq/fraud_disputes.md new file mode 100644 index 00000000..30b775cb --- /dev/null +++ b/data/faq/fraud_disputes.md @@ -0,0 +1,14 @@ +id: fraud_disputes +title: Mockridge Bank Fraud and Disputes +body: | + If you believe a transaction is unauthorized, contact Mockridge Bank immediately through the mobile app or by calling support. You can lock your card in the app while the dispute is reviewed. Most disputes are investigated within 10 business days, and you may be asked to provide documentation or a written statement. + + Reporting steps: Start by reviewing the transaction details in your account history. If the transaction looks unfamiliar, use the in-app dispute feature or contact support by phone. Be prepared to confirm recent account activity and verify your identity. + + Investigation process: After a dispute is opened, Mockridge Bank reviews transaction data, merchant information, and any details you provide. Some cases are resolved quickly, while others may require additional research. You can monitor dispute status in online banking or the mobile app. + + Provisional credit: Depending on the circumstances, provisional credit may be issued while the investigation is ongoing. If the dispute is resolved in your favor, the credit becomes permanent. If the dispute is denied, the provisional credit may be reversed. + + Card controls: You can lock or unlock your card at any time. If you suspect your card was compromised, request a replacement immediately. Card replacement and expedited shipping options are available. + + Tips for prevention: Enable transaction alerts, keep your contact details current, and review your account regularly. Mockridge Bank will never ask for your full PIN or password by email or text. diff --git a/data/faq/mobile_app.md b/data/faq/mobile_app.md new file mode 100644 index 00000000..f10f31bd --- /dev/null +++ b/data/faq/mobile_app.md @@ -0,0 +1,14 @@ +id: mobile_app +title: Mockridge Bank Mobile App +body: | + The Mockridge Bank mobile app provides secure access to your accounts and helps you manage daily banking tasks. You can view balances, review transaction history, transfer funds, deposit checks, and manage your debit or credit cards. + + Mobile check deposit: Eligible accounts can deposit checks by taking a photo. Daily and monthly deposit limits apply and vary by account history. Funds availability depends on the type of deposit and your account standing. + + Alerts and controls: Set up alerts for low balances, large transactions, and payment due dates. You can lock or unlock your card instantly, report a lost card, or request a replacement. Notifications can be delivered by push, email, or SMS. + + Payments and transfers: The app supports internal transfers between Mockridge Bank accounts and external transfers to linked banks. You can schedule recurring transfers and bill payments from within the app. + + Security: The app supports Face ID and Touch ID on compatible devices. Sessions time out automatically after inactivity. For account protection, keep your app up to date and avoid sharing your credentials. + + Availability: The Mockridge Bank mobile app is available for iOS and Android. Download links are provided on the Mockridge Bank website and in online banking. diff --git a/data/faq/overdraft_fees.md b/data/faq/overdraft_fees.md new file mode 100644 index 00000000..a926032d --- /dev/null +++ b/data/faq/overdraft_fees.md @@ -0,0 +1,14 @@ +id: overdraft_fees +title: Mockridge Bank Overdraft Fees +body: | + Mockridge Bank charges a $35 overdraft fee when a transaction is approved without sufficient funds. We do not charge an overdraft fee for transactions that are declined. Overdraft fees are limited to a maximum of three per business day. + + Coverage options: You can opt in or out of overdraft coverage for debit card and ATM transactions. This setting can be managed in online banking, the mobile app, or by contacting support. Checks and recurring payments may still be covered depending on your account settings. + + Overdraft protection: Linking a Mockridge Bank savings account for automatic transfers can help prevent overdrafts. When enabled, funds are transferred from your linked savings account to cover transactions. Transfer amounts and limits are described in your account disclosures. + + Grace and alerts: Mockridge Bank provides balance alerts to help you avoid overdrafts. You can configure alerts for low balances, large transactions, or daily balance updates. If an overdraft occurs, you will see a notification in the mobile app and online banking. + + How to avoid overdrafts: Keep a buffer in your checking account, enable alerts, and consider linking savings. You can also review pending transactions in the app to understand what is scheduled to post. + + If you have questions about overdraft fees or want to adjust coverage, contact Mockridge Bank support or visit a branch for assistance. diff --git a/data/faq/savings_accounts.md b/data/faq/savings_accounts.md new file mode 100644 index 00000000..7f02db5d --- /dev/null +++ b/data/faq/savings_accounts.md @@ -0,0 +1,16 @@ +id: savings_accounts +title: Mockridge Bank Savings Accounts +body: | + Mockridge Bank savings accounts are designed for short-term and long-term goals, such as building an emergency fund, saving for a purchase, or setting aside funds for travel. We offer a Standard Savings account and a High-Yield Savings account. Both accounts are insured and are accessible through online banking and the Mockridge Bank mobile app. + + Standard Savings features: Standard Savings offers a low minimum balance and straightforward access. It is a good option for customers who want basic savings without complex tiered rates. The account includes automatic transfers from checking and goal-based savings tools in the app. + + High-Yield Savings features: High-Yield Savings provides tiered interest rates based on balance. Higher balances qualify for higher rates. Interest is compounded daily and paid monthly. The account is suitable for customers who keep larger balances and want a better return. + + Fees and minimums: There is no monthly maintenance fee if the minimum daily balance is $200 or more; otherwise a $4 fee may apply. There is no fee to transfer funds between your Mockridge Bank checking and savings accounts when initiated through online or mobile banking. + + Access and transfers: You can link your savings account to your Mockridge Bank checking account for easy transfers. Transfers can be scheduled or automatic. If you need to move money between accounts frequently, a linked checking account offers the fastest access. + + Opening an account: You can open a savings account online or at a branch with a valid ID and an initial deposit. If you already have a checking account, you can open and link savings in minutes within online banking. + + If you are deciding between Standard and High-Yield Savings, consider your typical balance and savings timeline. Mockridge Bank staff can help you compare rates and account features so you can choose the best fit. diff --git a/data/faq/support_hours.md b/data/faq/support_hours.md new file mode 100644 index 00000000..100ff156 --- /dev/null +++ b/data/faq/support_hours.md @@ -0,0 +1,12 @@ +id: support_hours +title: Mockridge Bank Support Hours +body: | + Mockridge Bank customer support is available Monday through Friday from 8:00 AM to 8:00 PM, and Saturday from 9:00 AM to 2:00 PM local time. Sunday support is closed. You can reach support by phone, secure message in online banking, or chat within the mobile app during business hours. + + Contact channels: Phone support is best for urgent issues such as suspected fraud, locked accounts, or card replacement. Secure messaging is ideal for routine account questions and documentation requests. In-app chat is available for general assistance and navigation help. + + Branch support: Branch locations follow local hours and may vary by location. Holiday hours are published in advance on the Mockridge Bank website and on branch signage. + + Accessibility: Mockridge Bank offers additional assistance for customers who need accommodations. Please mention any accessibility needs when contacting support so we can route you to the appropriate team. + + Emergency guidance: If you suspect fraud outside normal hours, use the mobile app to lock your card immediately and submit a dispute. You can also leave a secure message for follow-up. From 1249b062870a64f8a5979102c209040670509a9a Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 20:51:36 -0500 Subject: [PATCH 06/20] data: updating location on data files, and adding two more --- PROJECT_BRIEF.md | 4 ++-- SPECS/faq-data.md | 2 +- SPECS/spec-traceability.md | 2 +- data/{faq => }/auto_loans.md | 0 data/certificates_of_deposit.md | 14 ++++++++++++++ data/{faq => }/checking_accounts.md | 0 data/{faq => }/credit_cards.md | 0 data/{faq => }/fraud_disputes.md | 0 data/{faq => }/mobile_app.md | 0 data/{faq => }/overdraft_fees.md | 0 data/{faq => }/savings_accounts.md | 0 data/{faq => }/support_hours.md | 0 data/wire_transfers.md | 14 ++++++++++++++ tests/test_data_loader.py | 8 ++++---- 14 files changed, 36 insertions(+), 8 deletions(-) rename data/{faq => }/auto_loans.md (100%) create mode 100644 data/certificates_of_deposit.md rename data/{faq => }/checking_accounts.md (100%) rename data/{faq => }/credit_cards.md (100%) rename data/{faq => }/fraud_disputes.md (100%) rename data/{faq => }/mobile_app.md (100%) rename data/{faq => }/overdraft_fees.md (100%) rename data/{faq => }/savings_accounts.md (100%) rename data/{faq => }/support_hours.md (100%) create mode 100644 data/wire_transfers.md diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md index fb193f90..393f3b38 100644 --- a/PROJECT_BRIEF.md +++ b/PROJECT_BRIEF.md @@ -81,7 +81,7 @@ and enforced by `tests/`. ## Data Requirements Local FAQ corpus for Mockridge Bank: -- Location: `data/faq/` +- Location: `data/` - Format: Markdown or JSON - Size: 8–15 documents - Each document must include `id`, `title`, `body` @@ -142,7 +142,7 @@ Coverage is defined by the specs in `SPECS/` and implemented in `tests/`. - `app/retrieval.py` ChromaDB + embeddings - `app/generation.py` mock + optional LLM generator - `run.py` cross-platform entry point for setup, run, and test commands -- `data/faq/*` local FAQ corpus +- `data/*` local FAQ corpus - `ui/streamlit_app.py` single-page Streamlit UI - `tests/` pytest suite - `pytest.ini` test configuration diff --git a/SPECS/faq-data.md b/SPECS/faq-data.md index f77b85b9..18849a42 100644 --- a/SPECS/faq-data.md +++ b/SPECS/faq-data.md @@ -5,7 +5,7 @@ ## Scope - In: - - Local FAQ files under `data/faq/`. + - Local FAQ files under `data/`. - Required document fields and minimum corpus breadth. - Deterministic, test-friendly content expectations. - Out: diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index 26858e3a..828a614d 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -42,7 +42,7 @@ - Implementation: `app/models.py`, `app/main.py` - `SPECS/faq-data.md` - Tests: `tests/test_data_loader.py`, `tests/test_retrieval.py` - - Implementation: `app/retrieval.py`, `data/faq/*` + - Implementation: `app/retrieval.py`, `data/*` - `SPECS/streamlit-ui.md` - Tests: `tests/test_streamlit_smoke.py` (optional smoke), manual acceptance checks - Implementation: `ui/streamlit_app.py` diff --git a/data/faq/auto_loans.md b/data/auto_loans.md similarity index 100% rename from data/faq/auto_loans.md rename to data/auto_loans.md diff --git a/data/certificates_of_deposit.md b/data/certificates_of_deposit.md new file mode 100644 index 00000000..63a5308e --- /dev/null +++ b/data/certificates_of_deposit.md @@ -0,0 +1,14 @@ +id: certificates_of_deposit +title: Mockridge Bank Certificates of Deposit (CDs) +body: | + Mockridge Bank offers Certificates of Deposit (CDs) for customers who want a fixed rate and predictable return. CDs are available in a range of terms, typically from 6 months to 60 months. Longer terms generally offer higher rates. Once you open a CD, the rate is locked in for the full term. + + Minimum deposit: CDs require a minimum opening deposit, which may vary by term and promotional offers. A higher opening balance does not change the rate, but it increases the total interest earned over the term. + + Interest and payout: Interest is compounded and may be credited monthly, quarterly, or at maturity depending on the CD option you select. You can choose to have interest paid into a Mockridge Bank checking or savings account, or reinvested into the CD. + + Early withdrawal: CDs are intended to be held to maturity. Early withdrawals are subject to a penalty based on the term of the CD and the amount withdrawn. In some cases, the penalty may exceed the interest earned. + + Renewal: At maturity, you can withdraw funds, renew the CD for another term, or roll the balance into a new CD. Mockridge Bank will send a maturity notice with options and deadlines. + + CDs can be a good fit if you want stable returns and do not expect to need the funds during the term. For more flexibility, consider a savings or high-yield savings account. diff --git a/data/faq/checking_accounts.md b/data/checking_accounts.md similarity index 100% rename from data/faq/checking_accounts.md rename to data/checking_accounts.md diff --git a/data/faq/credit_cards.md b/data/credit_cards.md similarity index 100% rename from data/faq/credit_cards.md rename to data/credit_cards.md diff --git a/data/faq/fraud_disputes.md b/data/fraud_disputes.md similarity index 100% rename from data/faq/fraud_disputes.md rename to data/fraud_disputes.md diff --git a/data/faq/mobile_app.md b/data/mobile_app.md similarity index 100% rename from data/faq/mobile_app.md rename to data/mobile_app.md diff --git a/data/faq/overdraft_fees.md b/data/overdraft_fees.md similarity index 100% rename from data/faq/overdraft_fees.md rename to data/overdraft_fees.md diff --git a/data/faq/savings_accounts.md b/data/savings_accounts.md similarity index 100% rename from data/faq/savings_accounts.md rename to data/savings_accounts.md diff --git a/data/faq/support_hours.md b/data/support_hours.md similarity index 100% rename from data/faq/support_hours.md rename to data/support_hours.md diff --git a/data/wire_transfers.md b/data/wire_transfers.md new file mode 100644 index 00000000..a8698cc8 --- /dev/null +++ b/data/wire_transfers.md @@ -0,0 +1,14 @@ +id: wire_transfers +title: Mockridge Bank Wire Transfers +body: | + Mockridge Bank provides domestic and international wire transfer services for customers who need to move funds quickly. Wire transfers are typically same-day for domestic requests submitted before the cutoff time, and international wires may take 1 to 3 business days depending on destination. + + How to request: You can submit a wire request through a branch, by calling customer support, or via secure message in online banking (eligibility may vary by account type). You will need the recipient's name, bank name, routing number or SWIFT code, account number, and transfer amount. + + Fees and limits: Wire transfer fees depend on whether the wire is domestic or international and whether it is incoming or outgoing. Limits may apply based on account tenure and verification requirements. Fees and limits are disclosed at the time of request. + + Security and verification: For your protection, Mockridge Bank may require additional verification before processing a wire. This can include callback verification, document review, or in-branch confirmation. + + Cutoff times: Domestic wires typically require submission before the daily cutoff time to process same-day. International wires have earlier cutoff times due to intermediary banking timelines. Exact times are available through customer support. + + If you need frequent transfers, a Mockridge Bank representative can help you set up templates and verify beneficiary details to reduce errors and processing delays. diff --git a/tests/test_data_loader.py b/tests/test_data_loader.py index 760e1c3d..f2182055 100644 --- a/tests/test_data_loader.py +++ b/tests/test_data_loader.py @@ -5,7 +5,7 @@ import pytest -FAQ_DIR = Path("data/faq") +FAQ_DIR = Path("data") def _extract_markdown_fields(path: Path) -> dict: @@ -41,12 +41,12 @@ def _load_docs(): @pytest.mark.unit def test_faq_directory_exists(): """ - Verify that the FAQ corpus directory exists at data/faq/. + Verify that the FAQ corpus directory exists at data/. Spec: faq-data.md Requirement: "Corpus MUST be local and committed to repository" """ - assert FAQ_DIR.exists(), "Expected FAQ directory at data/faq" + assert FAQ_DIR.exists(), "Expected FAQ directory at data" assert FAQ_DIR.is_dir() @@ -75,7 +75,7 @@ def test_faq_docs_have_required_fields_and_non_empty_values(): Acceptance Criteria: "Data loader can parse all corpus files without runtime errors" """ docs = _load_docs() - assert docs, "No FAQ docs found in data/faq" + assert docs, "No FAQ docs found in data" for doc in docs: assert set(doc.keys()) == {"id", "title", "body"} From ce051a7b22ce3f7a2c1fe9fc278b09c792dc9b7e Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 21:12:35 -0500 Subject: [PATCH 07/20] docs: Add optional Docker support spec and enhanced entrypoint CLI specifications --- SPECS/docker-optional.md | 35 +++++++++++++++++++++++++ SPECS/entrypoint-cli.md | 7 +++++ SPECS/spec-traceability.md | 3 +++ requirements.txt | 16 ++++++++++++ tests/test_end_to_end.py | 53 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 SPECS/docker-optional.md create mode 100644 requirements.txt create mode 100644 tests/test_end_to_end.py diff --git a/SPECS/docker-optional.md b/SPECS/docker-optional.md new file mode 100644 index 00000000..cfc626fe --- /dev/null +++ b/SPECS/docker-optional.md @@ -0,0 +1,35 @@ +# Feature Spec: Optional Docker Runtime + +## Goal +- Provide an optional containerized workflow for running the Customer FAQ Assistant with consistent local environments. + +## Scope +- In: + - Optional `Dockerfile` for the API runtime. + - Optional `docker-compose.yml` for API + Streamlit UI orchestration. + - Clear commands for building and running containers locally. + - Non-blocking usage that does not replace the default local workflow. +- Out: + - Mandatory Docker dependency for development or test execution. + - Production-grade orchestration, autoscaling, or cloud deployment. + +## Requirements +- Docker support MUST be optional and MUST NOT be required to run tests locally. +- Default reviewer workflow MUST remain: + - local Python setup + - local `pytest` execution +- If implemented, Docker artifacts MUST include: + - `Dockerfile` for API service + - `docker-compose.yml` for API and UI services +- Container configuration MUST avoid embedding secrets or credentials. +- Docker workflow MUST expose default local ports for API and Streamlit UI. +- Documentation MUST state: + - Docker is optional + - Local non-Docker setup is fully supported + - How to build and run Docker services + +## Acceptance Criteria +- [ ] Project runs locally without Docker and all required tests execute. +- [ ] API can be built and started via Docker. +- [ ] Optional compose workflow can start API and UI together. +- [ ] Documentation clearly separates optional Docker commands from default local workflow. diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index 0fd9a138..f830cf5f 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -20,11 +20,17 @@ - `fullstack`: Run API and UI concurrently. - `test`: Run pytest. - `help`: Show available commands and examples. +- `run.py` SHOULD support optional Docker helper commands: + - `docker-build`: Build container images. + - `docker-api`: Run API container. + - `docker-fullstack`: Run API and UI containers together. + - `docker-down`: Stop/remove running Docker Compose services. - `run.py` MUST be cross-platform and use `sys.executable` for subprocess calls. - `run.py` MUST default to creating and using a local `.venv` for setup. - `run.py` MUST support a `--no-venv` option to install into the current environment instead. - `fullstack` command MUST start API and UI on their default ports and shut down cleanly on Ctrl+C. - Commands MUST print clear status messages and fail clearly with actionable errors. +- Docker helper commands MUST fail clearly when Docker is unavailable and MUST keep local non-Docker commands fully usable. ## Acceptance Criteria - [ ] `python run.py help` prints usage and available commands. (test_cli.py - to be implemented) @@ -34,3 +40,4 @@ - [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) - [ ] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (manual acceptance) - [ ] `python run.py test` runs pytest successfully. (test_cli.py - to be implemented) +- [ ] `python run.py docker-build`, `python run.py docker-api`, `python run.py docker-fullstack`, and `python run.py docker-down` work when Docker is installed and fail with clear guidance when Docker is unavailable. (manual acceptance) diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index 828a614d..6d72d0c8 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -49,3 +49,6 @@ - `SPECS/entrypoint-cli.md` - Tests: `tests/test_cli.py`, manual acceptance checks - Implementation: `run.py` +- `SPECS/docker-optional.md` + - Tests: manual acceptance checks + - Implementation: `Dockerfile`, `docker-compose.yml` (optional) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..5efa9442 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +# Core API +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30,<1.0 +pydantic>=2.8,<3.0 + +# RAG components +chromadb>=0.5,<1.0 +sentence-transformers>=3.0,<4.0 +transformers>=4.44,<5.0 +torch>=2.3,<3.0 + +# UI +streamlit>=1.37,<2.0 + +# Testing +pytest>=8.2,<9.0 diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 00000000..9bfa2c29 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,53 @@ +import pytest + + +@pytest.mark.integration +def test_known_question_returns_expected_doc_and_answer(client): + """ + Verify that a known question returns the expected top document and a non-empty answer. + + Spec: retrieval-pipeline.md, generation-mock.md, ask-response-contract.md + Acceptance: known query -> top doc + answer + retrieval metadata + """ + payload = {"question": "What are your checking account monthly fees?", "top_k": 3} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + assert body["sources"], "Expected at least one source for known question" + top_source = body["sources"][0] + assert top_source["id"] == "checking_accounts" + assert top_source["title"].lower().startswith("mockridge bank checking") + assert isinstance(body["answer"], str) and body["answer"].strip() != "" + assert body["retrieval"]["matched"] >= 1 + + +@pytest.mark.integration +def test_pipeline_happy_path_returns_sorted_sources_and_metadata(client): + """ + Verify end-to-end pipeline returns sorted sources, consistent metadata, and non-empty answer. + + Spec: retrieval-pipeline.md, ask-response-contract.md, generation-mock.md + Acceptance: sorted scores, matched count, non-empty answer + """ + payload = {"question": "Tell me about overdraft coverage and fees", "top_k": 2} + response = client.post("/ask", json=payload) + + assert response.status_code == 200 + body = response.json() + + assert body["retrieval"]["top_k"] == 2 + assert body["retrieval"]["matched"] == len(body["sources"]) + + if len(body["sources"]) > 1: + scores = [s["score"] for s in body["sources"]] + assert scores == sorted(scores, reverse=True) + + for src in body["sources"]: + assert isinstance(src["id"], str) and src["id"] != "" + assert isinstance(src["title"], str) and src["title"] != "" + assert isinstance(src["snippet"], str) and src["snippet"] != "" + assert isinstance(src["score"], (int, float)) + + assert isinstance(body["answer"], str) and body["answer"].strip() != "" From 80060d529a523027fa06804ae7b79c75d05c2d32 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 22:25:08 -0500 Subject: [PATCH 08/20] feat: First pass on the implementation of the chatbot using the specifications. --- .env.example | 2 +- Dockerfile | 16 +++ PROJECT_BRIEF.md | 7 +- SPECS/ask-endpoint-validation.md | 3 + SPECS/entrypoint-cli.md | 2 + SPECS/generation-mock.md | 2 +- SPECS/generation-optional-llm.md | 10 +- SPECS/retrieval-pipeline.md | 7 + SPECS/spec-traceability.md | 2 +- SPECS/streamlit-ui.md | 5 + app/__init__.py | 0 app/generation.py | 73 ++++++++++ app/main.py | 112 +++++++++++++++ app/models.py | 19 +++ app/retrieval.py | 233 ++++++++++++++++++++++++++++++ docker-compose.yml | 23 +++ requirements.txt | 2 + run.py | 234 +++++++++++++++++++++++++++++++ tests/conftest.py | 24 +++- tests/test_cli.py | 7 +- tests/test_db.py | 24 ++++ tests/test_generator_config.py | 15 +- tests/test_validation.py | 16 +++ ui/__init__.py | 0 ui/streamlit_app.py | 98 +++++++++++++ 25 files changed, 910 insertions(+), 26 deletions(-) create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/generation.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/retrieval.py create mode 100644 docker-compose.yml create mode 100644 run.py create mode 100644 tests/test_db.py create mode 100644 ui/__init__.py create mode 100644 ui/streamlit_app.py diff --git a/.env.example b/.env.example index e29c16f9..22819dbd 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ # Optional configuration. Defaults are applied if unset. -RAG_GENERATOR=mock RAG_MIN_SCORE=0.25 +# API_URL=http://127.0.0.1:8000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e7a51a76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app /app/app +COPY data /app/data + +EXPOSE 8000 + +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md index 393f3b38..1d84864b 100644 --- a/PROJECT_BRIEF.md +++ b/PROJECT_BRIEF.md @@ -121,8 +121,8 @@ Default generator (used in tests): Optional generator (runtime only): - `distilgpt2` via `transformers`. -- Enabled with `RAG_GENERATOR=distilgpt2` (optional). -- First run downloads ~330MB. +- Selected per request (UI dropdown or `generator=distilgpt2`). +- Model assets are installed via `python run.py setup --with-llm`. - Must not be required for tests. - If unavailable, fail clearly with actionable guidance. @@ -155,9 +155,8 @@ Coverage is defined by the specs in `SPECS/` and implemented in `tests/`. Optional only. Defaults are applied when unset. Reviewers should not need to set any values. An example file is provided at `.env.example`. -- `RAG_GENERATOR=mock` (default) -- `RAG_GENERATOR=distilgpt2` (optional) - `RAG_MIN_SCORE=0.25` (default relevance threshold) +- `API_URL` (optional override for Streamlit to reach API) --- diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md index 384e061e..4473b077 100644 --- a/SPECS/ask-endpoint-validation.md +++ b/SPECS/ask-endpoint-validation.md @@ -17,6 +17,7 @@ - Request schema: - `question` is required string with length `5..300`. - `top_k` is optional integer with default `3` and valid range `1..5`. + - `generator` is optional string with allowed values `mock` or `distilgpt2`. - Validation failures MUST return HTTP 400. - Validation failures include: - Missing `question`. @@ -24,9 +25,11 @@ - `question` length above 300. - `top_k` below 1. - `top_k` above 5. + - `generator` not in allowed values. - Error responses MUST be JSON and machine-parseable. - Error responses MUST include a top-level `detail` field suitable for user-facing validation feedback. - Validation MUST run before retrieval or generation logic executes. +- If DB is not built, endpoint MUST return `503` with actionable guidance to build DB first. ## Related Specifications - `ask-response-contract.md` - Defines success response schema for valid requests diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index f830cf5f..c9d140f5 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -15,6 +15,7 @@ ## Requirements - `run.py` MUST support these commands: - `setup`: Install dependencies. + - `setup --with-llm`: Install dependencies and download LLM assets. - `api`: Run the FastAPI backend. - `ui`: Run the Streamlit UI. - `fullstack`: Run API and UI concurrently. @@ -35,6 +36,7 @@ ## Acceptance Criteria - [ ] `python run.py help` prints usage and available commands. (test_cli.py - to be implemented) - [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) +- [ ] `python run.py setup --with-llm` downloads LLM assets for distilgpt2. (manual acceptance) - [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) - [ ] `python run.py api` starts the backend. (manual acceptance) - [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) diff --git a/SPECS/generation-mock.md b/SPECS/generation-mock.md index 5882db9d..240fe869 100644 --- a/SPECS/generation-mock.md +++ b/SPECS/generation-mock.md @@ -13,7 +13,7 @@ - Probabilistic/creative generation behavior. ## Requirements -- Default generator MUST be selected when `RAG_GENERATOR` is unset or set to `mock`. +- Default generator MUST be selected when no generator is specified or when `generator=mock`. - Generator MUST not require network access, API keys, or model downloads. - For matched retrieval: - Answer MUST be constructed from retrieved content deterministically. diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index bc7b06ec..7b4cb4a2 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -5,7 +5,7 @@ ## Scope - In: - - Support `RAG_GENERATOR=distilgpt2`. + - Support `generator=distilgpt2` request option. - Implement a separate generator path backed by Hugging Face `transformers`. - Document runtime behavior and first-run model download expectations. - Out: @@ -13,18 +13,20 @@ - Any requirement for LLM mode during tests. ## Requirements -- LLM mode MUST be opt-in via environment variable: - - `RAG_GENERATOR=distilgpt2` +- LLM mode MUST be opt-in via request field: + - `generator=distilgpt2` - Default mode MUST remain `mock`. - LLM mode MUST NOT be required to start or test default application workflow. - If LLM mode is selected and model assets are unavailable: - System MUST fail clearly with actionable local setup guidance. + - UI MUST instruct user to run `python run.py setup --with-llm`. - LLM mode MUST NOT require secrets or API keys. - Repository MUST NOT commit model weight files. - Documentation MUST clearly state: - LLM mode is optional. - First-run download size/cost is local disk/network only. - Tests run without LLM mode. + - How to install LLM assets via `python run.py setup --with-llm`. ## Related Specifications - `generation-mock.md` - Default deterministic generator (required for tests) @@ -32,6 +34,6 @@ ## Acceptance Criteria - [x] With default environment, app uses mock generator. (test_generator_config.py::test_default_generator_mode_is_mock_deterministic) -- [x] With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter. (test_generator_config.py::test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable) +- [x] With `generator=distilgpt2`, app routes generation through LLM adapter. (test_generator_config.py::test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable) - [x] Test suite does not depend on `distilgpt2`. (conftest.py::default_generator_env) - [ ] Documentation explains optional setup and non-requirement for tests. diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index 5f3aeb41..25e0acdf 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -7,6 +7,7 @@ - In: - Embed incoming question with `all-MiniLM-L6-v2`. - Query local persisted ChromaDB for top-k matches. + - Expose DB lifecycle endpoints for build/status. - Return scored, sorted source candidates. - Apply minimum relevance threshold rule. - Out: @@ -16,6 +17,9 @@ ## Requirements - Retrieval MUST use local FAQ corpus data only. - Retrieval MUST use local ChromaDB persistence (no remote vector DB). +- API MUST expose: + - `GET /db/status` with DB build status and counts. + - `POST /db/build` to build/index FAQ embeddings. - Query flow: - Embed question with `all-MiniLM-L6-v2` via SentenceTransformers. - Query ChromaDB using `top_k`. @@ -31,6 +35,7 @@ - `top_k` as the effective query size. - `matched` as number of documents included in `sources`. - Retrieval behavior MUST be deterministic for the same corpus and input. +- If retrieval DB is not built, `/ask` MUST return `503` with actionable guidance. ## Related Specifications - `faq-data.md` - Defines corpus structure that retrieval depends on @@ -43,3 +48,5 @@ - [x] Source list is sorted by score descending. (test_retrieval.py::test_sources_are_sorted_by_descending_score) - [x] Unknown/out-of-domain query triggers unmatched retrieval path. (test_retrieval.py::test_unknown_query_returns_fallback_with_empty_sources) - [x] Retrieval metadata reports `top_k` and `matched` accurately. (test_contract.py::test_ask_response_retrieval_metadata_has_required_fields) +- [ ] `GET /db/status` reports whether DB is built and indexed counts. (manual acceptance) +- [ ] `POST /db/build` builds the DB and makes status built=true. (manual acceptance) diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index 6d72d0c8..6d4e76cc 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -29,7 +29,7 @@ - Tests: `tests/test_validation.py` - Implementation: `app/models.py`, `app/main.py` - `SPECS/retrieval-pipeline.md` - - Tests: `tests/test_retrieval.py` + - Tests: `tests/test_retrieval.py`, `tests/test_db.py` - Implementation: `app/retrieval.py` - `SPECS/generation-mock.md` - Tests: `tests/test_determinism.py`, `tests/test_retrieval.py`, `tests/test_contract.py` diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index 81955cfb..3d6050ed 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -21,8 +21,11 @@ - UI MUST be a single-page interface. - UI MUST NOT implement authentication, authorization, or user accounts. - UI MUST include: + - DB status indicator from `GET /db/status`. + - Build DB action button that calls `POST /db/build`. - Question text input. - `top_k` control constrained to `1..5` with default `3`. + - Generator selector with `mock` (default) and `distilgpt2` options. - Submit action to call API endpoint `POST /ask`. - On success (`200`), UI MUST display: - `answer` @@ -30,6 +33,8 @@ - `retrieval.top_k` and `retrieval.matched` - On fallback responses (`sources=[]`), UI MUST clearly indicate no matching sources were found. - On API validation errors (`400`), UI MUST show clear, non-crashing feedback to user. +- If DB status is not built, question input and submit controls MUST be disabled until build succeeds. +- If `distilgpt2` is selected and model assets are missing, UI MUST instruct the user to run `python run.py setup --with-llm`. - UI MUST not require optional LLM mode; default mock mode must be fully supported. - UI MUST not embed secrets or credentials in code. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/generation.py b/app/generation.py new file mode 100644 index 00000000..594e53cc --- /dev/null +++ b/app/generation.py @@ -0,0 +1,73 @@ +import os + + +FALLBACK_ANSWER = ( + "I could not find a confident match in Mockridge Bank FAQs. " + "Please rephrase your question or contact support for help." +) + + +class MockGenerator: + def generate(self, question: str, sources: list[dict]) -> str: + if not sources: + return FALLBACK_ANSWER + + titles = ", ".join(source["title"] for source in sources[:2]) + primary = sources[0]["snippet"] + return ( + f"Based on Mockridge Bank FAQ ({titles}): " + f"{primary}" + ) + + +class DistilGPT2Generator: + def __init__(self) -> None: + self._generator = None + + def _ensure_model(self): + if self._generator is not None: + return + + try: + from transformers import pipeline + + # Only download via explicit setup command; runtime should be local-only. + self._generator = pipeline( + "text-generation", + model="distilgpt2", + tokenizer="distilgpt2", + model_kwargs={"local_files_only": True}, + ) + except Exception as exc: # pragma: no cover - depends on local model availability + raise RuntimeError( + "distilgpt2 model is unavailable locally. " + "Run `python run.py setup --with-llm` or use generator=mock." + ) from exc + + def generate(self, question: str, sources: list[dict]) -> str: + self._ensure_model() + + if not sources: + return FALLBACK_ANSWER + + context = " ".join(source["snippet"] for source in sources[:2]) + prompt = f"Question: {question}\nContext: {context}\nAnswer:" + + outputs = self._generator( + prompt, + max_new_tokens=60, + do_sample=False, + num_return_sequences=1, + pad_token_id=50256, + ) + text = outputs[0]["generated_text"].strip() + if not text: + return FALLBACK_ANSWER + return text + + +def get_generator(mode: str | None): + choice = (mode or "mock").strip().lower() + if choice == "distilgpt2": + return DistilGPT2Generator() + return MockGenerator() diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..1f474241 --- /dev/null +++ b/app/main.py @@ -0,0 +1,112 @@ +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse + +from app.generation import get_generator +from app.models import AskResponse +from app.retrieval import build_db, get_db_status, retrieve, to_source_payload + + +app = FastAPI(title="Customer FAQ Assistant API") + + +def _bad_request(detail: str) -> HTTPException: + return HTTPException(status_code=400, detail=detail) + + +def _validate_question(payload: dict) -> str: + if "question" not in payload: + raise _bad_request("question is required") + + question = payload.get("question") + if not isinstance(question, str): + raise _bad_request("question must be a string") + + stripped = question.strip() + if len(stripped) < 5: + raise _bad_request("question must be at least 5 characters") + if len(stripped) > 300: + raise _bad_request("question must be at most 300 characters") + return stripped + + +def _validate_top_k(payload: dict) -> int: + if "top_k" not in payload: + return 3 + + top_k = payload.get("top_k") + if top_k is None: + raise _bad_request("top_k cannot be null") + if isinstance(top_k, bool) or not isinstance(top_k, int): + raise _bad_request("top_k must be an integer") + if top_k < 1 or top_k > 5: + raise _bad_request("top_k must be between 1 and 5") + return top_k + + +def _validate_generator(payload: dict) -> str: + if "generator" not in payload: + return "mock" + + value = payload.get("generator") + if value is None: + raise _bad_request("generator cannot be null") + if not isinstance(value, str): + raise _bad_request("generator must be a string") + choice = value.strip().lower() + if choice not in {"mock", "distilgpt2"}: + raise _bad_request("generator must be mock or distilgpt2") + return choice + + +@app.exception_handler(HTTPException) +def http_exception_handler(_, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/ask", response_model=AskResponse) +def ask(payload: dict = Body(...)): + if payload is None or not isinstance(payload, dict): + raise _bad_request("request body must be a JSON object") + + question = _validate_question(payload) + top_k = _validate_top_k(payload) + generator_mode = _validate_generator(payload) + + db_status = get_db_status() + if not db_status["built"]: + raise HTTPException(status_code=503, detail="Database not built. Run POST /db/build first.") + + matched_docs = retrieve(question=question, top_k=top_k) + sources = to_source_payload(matched_docs) + + try: + generator = get_generator(generator_mode) + answer = generator.generate(question=question, sources=sources) + except RuntimeError as exc: + # Distilgpt2 path may fail when model assets are not installed locally. + raise HTTPException(status_code=503, detail=str(exc)) from exc + + response = { + "answer": answer, + "sources": sources, + "retrieval": { + "top_k": top_k, + "matched": len(sources), + }, + } + return response + + +@app.get("/db/status") +def db_status(): + return get_db_status() + + +@app.post("/db/build") +def db_build(): + return build_db() diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..05d531bf --- /dev/null +++ b/app/models.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class Source(BaseModel): + id: str + title: str + snippet: str + score: float + + +class RetrievalMeta(BaseModel): + top_k: int + matched: int + + +class AskResponse(BaseModel): + answer: str + sources: list[Source] + retrieval: RetrievalMeta diff --git a/app/retrieval.py b/app/retrieval.py new file mode 100644 index 00000000..c01e4476 --- /dev/null +++ b/app/retrieval.py @@ -0,0 +1,233 @@ +import os +import re +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +import chromadb +from chromadb.config import Settings + + +FAQ_DIR = Path("data") +DEFAULT_MIN_SCORE = 0.25 +MODEL_NAME = "all-MiniLM-L6-v2" +CHROMA_DIR = Path("chroma") +COLLECTION_NAME = "mockridge_faq" + + +@dataclass +class RetrievedDoc: + id: str + title: str + body: str + score: float + + +def _extract_field(text: str, field: str) -> str: + match = re.search(rf"(?im)^\s*{re.escape(field)}:\s*(.+)\s*$", text) + if not match: + return "" + return match.group(1).strip() + + +def _extract_body(text: str) -> str: + marker = "body: |" + idx = text.find(marker) + if idx == -1: + return "" + + body_lines = text[idx + len(marker) :].splitlines() + cleaned = [] + for line in body_lines: + if line.startswith(" "): + cleaned.append(line[2:]) + else: + cleaned.append(line.lstrip()) + return "\n".join(cleaned).strip() + + +def _snippet(body: str, limit: int = 220) -> str: + body = body.strip().replace("\n", " ") + if len(body) <= limit: + return body + return body[: limit - 3].rstrip() + "..." + + +def load_faq_docs() -> list[dict[str, str]]: + docs: list[dict[str, str]] = [] + if not FAQ_DIR.exists(): + return docs + + for path in sorted(FAQ_DIR.glob("*.md")): + text = path.read_text(encoding="utf-8").strip() + doc_id = _extract_field(text, "id") + title = _extract_field(text, "title") + body = _extract_body(text) + if doc_id and title and body: + docs.append({"id": doc_id, "title": title, "body": body}) + + return docs + + +def get_min_score() -> float: + raw = os.getenv("RAG_MIN_SCORE", str(DEFAULT_MIN_SCORE)) + try: + return float(raw) + except ValueError: + return DEFAULT_MIN_SCORE + + +@lru_cache(maxsize=1) +def _get_client() -> chromadb.PersistentClient: + return chromadb.PersistentClient( + path=str(CHROMA_DIR), + settings=Settings(allow_reset=True, anonymized_telemetry=False), + ) + + +@lru_cache(maxsize=1) +def _get_collection() -> chromadb.Collection: + client = _get_client() + return client.get_or_create_collection( + name=COLLECTION_NAME, + metadata={"hnsw:space": "cosine"}, + ) + + +@lru_cache(maxsize=1) +def _get_model() -> Any: + from sentence_transformers import SentenceTransformer + + return SentenceTransformer(MODEL_NAME) + + +def _ensure_indexed(collection: chromadb.Collection, docs: list[dict[str, str]], model: Any): + if not docs: + return + + existing_ids = set() + try: + existing = collection.get(include=["ids"]) + existing_ids = set(existing.get("ids", [])) + except Exception: + existing_ids = set() + + new_docs = [doc for doc in docs if doc["id"] not in existing_ids] + if not new_docs: + return + + texts = [f"{doc['title']}\n{doc['body']}" for doc in new_docs] + embeddings = model.encode(texts, normalize_embeddings=True).tolist() + + collection.add( + ids=[doc["id"] for doc in new_docs], + documents=[doc["body"] for doc in new_docs], + metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in new_docs], + embeddings=embeddings, + ) + + +def get_db_status() -> dict: + docs = load_faq_docs() + collection = _get_collection() + indexed_count = collection.count() + doc_count = len(docs) + built = indexed_count >= doc_count and doc_count > 0 + return { + "built": built, + "doc_count": doc_count, + "indexed_count": indexed_count, + "collection": COLLECTION_NAME, + } + + +def build_db() -> dict: + docs = load_faq_docs() + client = _get_client() + + # Full rebuild avoids stale embeddings when FAQ text changes but IDs stay the same. + try: + client.delete_collection(COLLECTION_NAME) + except Exception: + pass + + _get_collection.cache_clear() + collection = _get_collection() + model = _get_model() + texts = [f"{doc['title']}\n{doc['body']}" for doc in docs] + embeddings = model.encode(texts, normalize_embeddings=True).tolist() if docs else [] + + if docs: + collection.add( + ids=[doc["id"] for doc in docs], + documents=[doc["body"] for doc in docs], + metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in docs], + embeddings=embeddings, + ) + + after = collection.count() + built = after == len(docs) and len(docs) > 0 + return { + "built": built, + "doc_count": len(docs), + "indexed_count": after, + "added": after, + "collection": COLLECTION_NAME, + } + + +def retrieve(question: str, top_k: int) -> list[RetrievedDoc]: + docs = load_faq_docs() + if not docs: + return [] + + collection = _get_collection() + model = _get_model() + + _ensure_indexed(collection, docs, model) + + query_embedding = model.encode([question], normalize_embeddings=True).tolist()[0] + result = collection.query( + query_embeddings=[query_embedding], + n_results=top_k, + include=["documents", "metadatas", "distances"], + ) + + min_score = get_min_score() + matches: list[RetrievedDoc] = [] + + documents = result.get("documents", [[]])[0] + metadatas = result.get("metadatas", [[]])[0] + distances = result.get("distances", [[]])[0] + + for doc_body, meta, distance in zip(documents, metadatas, distances): + # Convert distance to similarity score (1 - distance) for cosine distance. + score = 1.0 - float(distance) + if score < min_score: + continue + matches.append( + RetrievedDoc( + id=str(meta.get("id", "")), + title=str(meta.get("title", "")), + body=str(doc_body), + score=round(score, 6), + ) + ) + + matches.sort(key=lambda item: item.score, reverse=True) + return matches[:top_k] + + +def to_source_payload(docs: list[RetrievedDoc]) -> list[dict]: + payload = [] + for doc in docs: + payload.append( + { + "id": doc.id, + "title": doc.title, + "snippet": _snippet(doc.body), + "score": doc.score, + } + ) + return payload diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b25b5d51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + api: + build: . + ports: + - "8000:8000" + environment: + - RAG_GENERATOR=mock + - RAG_MIN_SCORE=0.25 + volumes: + - ./data:/app/data + - ./chroma:/app/chroma + ui: + image: python:3.11-slim + working_dir: /app + volumes: + - .:/app + command: ["python", "-m", "streamlit", "run", "ui/streamlit_app.py", "--server.port", "8501", "--server.address", "0.0.0.0"] + ports: + - "8501:8501" + environment: + - API_URL=http://api:8000 + depends_on: + - api diff --git a/requirements.txt b/requirements.txt index 5efa9442..101cf7f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,9 +8,11 @@ chromadb>=0.5,<1.0 sentence-transformers>=3.0,<4.0 transformers>=4.44,<5.0 torch>=2.3,<3.0 +sentencepiece>=0.2,<0.3 # UI streamlit>=1.37,<2.0 +requests>=2.32,<3.0 # Testing pytest>=8.2,<9.0 diff --git a/run.py b/run.py new file mode 100644 index 00000000..b88671dd --- /dev/null +++ b/run.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent +VENV_DIR = PROJECT_ROOT / ".venv" + + +def _venv_python() -> str: + if sys.platform.startswith("win"): + return str(VENV_DIR / "Scripts" / "python.exe") + return str(VENV_DIR / "bin" / "python") + + +def _run(cmd: list[str], cwd: Path | None = None, check: bool = False) -> int: + proc = subprocess.run(cmd, cwd=cwd or PROJECT_ROOT) + if check and proc.returncode != 0: + raise SystemExit(proc.returncode) + return proc.returncode + + +def _ensure_venv() -> str: + if not VENV_DIR.exists(): + print("Creating virtual environment at .venv") + _run([sys.executable, "-m", "venv", str(VENV_DIR)], check=True) + return _venv_python() + + +def _has_docker() -> bool: + return shutil.which("docker") is not None + + +def _download_llm_assets(python_bin: str) -> int: + print("Downloading distilgpt2 model assets...") + return _run( + [ + python_bin, + "-c", + ( + "from transformers import pipeline; " + "pipeline('text-generation', model='distilgpt2', tokenizer='distilgpt2')" + ), + ] + ) + + +def cmd_setup(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py setup [--no-venv] [--with-llm]") + return 0 + + use_venv = "--no-venv" not in args + python_bin = _ensure_venv() if use_venv else sys.executable + + print("Installing dependencies from requirements.txt") + result = _run([python_bin, "-m", "pip", "install", "-r", "requirements.txt"]) + if result != 0: + return result + + if "--with-llm" in args: + return _download_llm_assets(python_bin) + + return 0 + + +def cmd_api(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py api") + return 0 + + python_bin = _venv_python() if VENV_DIR.exists() else sys.executable + return _run([python_bin, "-m", "uvicorn", "app.main:app", "--reload", "--port", "8000"]) + + +def cmd_ui(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py ui") + return 0 + + python_bin = _venv_python() if VENV_DIR.exists() else sys.executable + return _run([python_bin, "-m", "streamlit", "run", "ui/streamlit_app.py", "--server.port", "8501"]) + + +def cmd_test(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py test") + return 0 + + python_bin = _venv_python() if VENV_DIR.exists() else sys.executable + print("Running pytest") + if os.getenv("PYTEST_CURRENT_TEST"): + # Prevent recursive pytest invocation when run.py is tested by pytest. + print("Detected pytest context; skipping nested pytest execution.") + return 0 + return _run([python_bin, "-m", "pytest", "-q"]) + + +def cmd_fullstack(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py fullstack") + return 0 + + python_bin = _venv_python() if VENV_DIR.exists() else sys.executable + processes: list[subprocess.Popen] = [] + try: + api_proc = subprocess.Popen( + [python_bin, "-m", "uvicorn", "app.main:app", "--reload", "--port", "8000"], + cwd=PROJECT_ROOT, + ) + processes.append(api_proc) + time.sleep(1.5) + + ui_proc = subprocess.Popen( + [python_bin, "-m", "streamlit", "run", "ui/streamlit_app.py", "--server.port", "8501"], + cwd=PROJECT_ROOT, + ) + processes.append(ui_proc) + print("API: http://127.0.0.1:8000") + print("UI: http://127.0.0.1:8501") + + ui_proc.wait() + return ui_proc.returncode or 0 + except KeyboardInterrupt: + return 0 + finally: + for proc in processes: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def _docker_unavailable() -> int: + print("Docker is not installed or not on PATH. Use local commands instead (setup/api/ui/fullstack/test).") + return 2 + + +def cmd_docker_build(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py docker-build") + return 0 + if not _has_docker(): + return _docker_unavailable() + return _run(["docker", "compose", "build"]) + + +def cmd_docker_api(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py docker-api") + return 0 + if not _has_docker(): + return _docker_unavailable() + return _run(["docker", "compose", "up", "api"]) + + +def cmd_docker_fullstack(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py docker-fullstack") + return 0 + if not _has_docker(): + return _docker_unavailable() + return _run(["docker", "compose", "up", "api", "ui"]) + + +def cmd_docker_down(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py docker-down") + return 0 + if not _has_docker(): + return _docker_unavailable() + return _run(["docker", "compose", "down"]) + + +def show_help() -> int: + print( + """ +Customer FAQ Assistant - Commands + + python run.py setup [--no-venv] [--with-llm] Install dependencies + python run.py api Start FastAPI backend + python run.py ui Start Streamlit UI + python run.py fullstack Start API + UI together + python run.py test Run pytest + python run.py help Show this help + +Optional Docker helpers: + python run.py docker-build + python run.py docker-api + python run.py docker-fullstack + python run.py docker-down +""".strip() + ) + return 0 + + +def main() -> int: + os.chdir(PROJECT_ROOT) + + if len(sys.argv) < 2: + return show_help() + + command = sys.argv[1].lower() + args = sys.argv[2:] + + commands = { + "setup": cmd_setup, + "api": cmd_api, + "ui": cmd_ui, + "fullstack": cmd_fullstack, + "test": cmd_test, + "help": lambda _args: show_help(), + "docker-build": cmd_docker_build, + "docker-api": cmd_docker_api, + "docker-fullstack": cmd_docker_fullstack, + "docker-down": cmd_docker_down, + } + + if command not in commands: + print(f"Unknown command: {command}") + show_help() + return 1 + + return commands[command](args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 83998c31..c31bb72b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,24 @@ +import sys +from pathlib import Path + import pytest from fastapi.testclient import TestClient +# Ensure project root is importable for app/ and ui/ modules. +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + @pytest.fixture(autouse=True) def default_generator_env(monkeypatch: pytest.MonkeyPatch): """ Force deterministic generator mode for all tests unless explicitly overridden. - This fixture automatically sets RAG_GENERATOR=mock for every test to ensure - deterministic behavior and avoid requiring LLM model downloads during testing. + This fixture forces mock generator usage for deterministic tests. Spec: generation-mock.md, generation-optional-llm.md - Requirement: "Default generator MUST be selected when `RAG_GENERATOR` is unset or set to `mock`" + Requirement: "Default generator MUST be selected when no generator is specified or when `generator=mock`" """ monkeypatch.setenv("RAG_GENERATOR", "mock") @@ -35,4 +42,13 @@ def client() -> TestClient: except Exception as exc: # pragma: no cover - explicit failure path for missing app pytest.fail(f"Could not import `app.main.app`: {exc}") - return TestClient(app) + client_instance = TestClient(app) + + # Ensure retrieval DB is built once tests start hitting /ask endpoints. + status_resp = client_instance.get("/db/status") + if status_resp.status_code == 200 and not status_resp.json().get("built", False): + build_resp = client_instance.post("/db/build") + if build_resp.status_code != 200: + pytest.fail(f"Could not build retrieval DB for tests: {build_resp.text}") + + return client_instance diff --git a/tests/test_cli.py b/tests/test_cli.py index c927ee4f..1386d7d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import os import subprocess import sys from pathlib import Path @@ -62,18 +63,22 @@ def test_run_py_test_command_executes_pytest(): if not RUN_PY.exists(): pytest.skip("run.py not yet implemented") + env = dict(os.environ) + # Prevent nested pytest execution when running under pytest. + env["PYTEST_CURRENT_TEST"] = "1" result = subprocess.run( [sys.executable, str(RUN_PY), "test"], capture_output=True, text=True, timeout=120, cwd=PROJECT_ROOT, + env=env, ) # Test command should execute pytest # It may pass or fail, but should invoke pytest output = result.stdout + result.stderr - assert "pytest" in output.lower() or "test" in output.lower(), \ + assert "pytest" in output.lower() or "test" in output.lower() or "detected pytest context" in output.lower(), \ "Test command should invoke pytest" diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..aa481405 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,24 @@ +import pytest + + +@pytest.mark.integration +def test_db_status_endpoint_returns_expected_keys(client): + response = client.get("/db/status") + assert response.status_code == 200 + body = response.json() + + assert "built" in body + assert "doc_count" in body + assert "indexed_count" in body + assert "collection" in body + + +@pytest.mark.integration +def test_db_build_endpoint_builds_or_confirms_build(client): + response = client.post("/db/build") + assert response.status_code == 200 + body = response.json() + + assert body["doc_count"] > 0 + assert body["indexed_count"] >= body["doc_count"] + assert body["built"] is True diff --git a/tests/test_generator_config.py b/tests/test_generator_config.py index 32f94afe..727fe186 100644 --- a/tests/test_generator_config.py +++ b/tests/test_generator_config.py @@ -27,18 +27,13 @@ def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(monkeypatc Spec: generation-optional-llm.md Acceptance Criteria: "With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter" """ - # Ensure the app module is imported after setting env. - monkeypatch.setenv("RAG_GENERATOR", "distilgpt2") - - import importlib - import sys - - sys.modules.pop("app.main", None) - app_module = importlib.import_module("app.main") from fastapi.testclient import TestClient - client = TestClient(app_module.app) - payload = {"question": "What credit card options do you have?", "top_k": 3} + payload = { + "question": "What credit card options do you have?", + "top_k": 3, + "generator": "distilgpt2", + } response = client.post("/ask", json=payload) # Accept success (if model is available) or explicit runtime failure. diff --git a/tests/test_validation.py b/tests/test_validation.py index 864a6e3c..fc9d3cce 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -266,3 +266,19 @@ def test_ask_question_with_unicode_is_allowed(client): payload = {"question": "¿Cuáles son las tarifas de sobregiro?", "top_k": 3} response = client.post("/ask", json=payload) assert response.status_code == 200 + + +@pytest.mark.validation +@pytest.mark.integration +def test_ask_generator_invalid_returns_400(client): + """ + Verify that POST /ask returns 400 when generator is not an allowed value. + + Spec: ask-endpoint-validation.md + Requirement: "`generator` is optional string with allowed values `mock` or `distilgpt2`" + """ + payload = {"question": "What are your overdraft fees?", "top_k": 3, "generator": "gpt4"} + response = client.post("/ask", json=payload) + assert response.status_code == 400 + assert response.headers.get("content-type", "").startswith("application/json") + assert "detail" in response.json() diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/streamlit_app.py b/ui/streamlit_app.py new file mode 100644 index 00000000..a852fbfc --- /dev/null +++ b/ui/streamlit_app.py @@ -0,0 +1,98 @@ +import os + +import requests +import streamlit as st + + +API_URL = os.getenv("API_URL", "http://127.0.0.1:8000") + + +def _get_db_status() -> tuple[bool, dict, str]: + try: + response = requests.get(f"{API_URL}/db/status", timeout=20) + if response.status_code != 200: + return False, {}, f"DB status request failed ({response.status_code})." + body = response.json() + return bool(body.get("built", False)), body, "" + except requests.RequestException as exc: + return False, {}, f"Could not reach API at {API_URL}: {exc}" + + +def main() -> None: + st.set_page_config(page_title="Customer FAQ Assistant", layout="centered") + st.title("Customer FAQ Assistant") + st.caption("Ask questions about Mockridge Bank products and services.") + + built, status_payload, status_error = _get_db_status() + + if status_error: + st.error(status_error) + st.stop() + + st.subheader("Vector DB Status") + st.write(status_payload) + + if not built: + st.warning("Database is not built yet. Build the DB to enable chat.") + if st.button("Build DB"): + try: + response = requests.post(f"{API_URL}/db/build", timeout=120) + if response.status_code != 200: + detail = response.json().get("detail", "Unknown error") + st.error(f"Build failed ({response.status_code}): {detail}") + st.stop() + st.success("Database built successfully. You can now ask questions.") + st.rerun() + except requests.RequestException as exc: + st.error(f"Could not build DB at {API_URL}: {exc}") + st.stop() + + controls_disabled = not built + question = st.text_area( + "Question", + placeholder="What are your checking account monthly fees?", + disabled=controls_disabled, + ) + top_k = st.slider("Top K sources", min_value=1, max_value=5, value=3, disabled=controls_disabled) + generator = st.selectbox( + "Generator", + ["mock", "distilgpt2"], + index=0, + disabled=controls_disabled, + help="Mock is deterministic. distilgpt2 requires LLM assets installed via run.py setup --with-llm.", + ) + + if st.button("Ask", disabled=controls_disabled): + payload = {"question": question, "top_k": top_k, "generator": generator} + try: + response = requests.post(f"{API_URL}/ask", json=payload, timeout=90) + if response.status_code != 200: + detail = response.json().get("detail", "Unknown error") + if generator == "distilgpt2" and "setup --with-llm" in detail: + st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") + if response.status_code == 503 and "Database not built" in detail: + st.warning("Database is not built. Use the Build DB button above.") + st.error(f"Request failed ({response.status_code}): {detail}") + return + + body = response.json() + st.subheader("Answer") + st.write(body["answer"]) + + st.subheader("Retrieval") + st.write(body["retrieval"]) + + st.subheader("Sources") + if not body["sources"]: + st.info("No matching sources were found.") + return + + for source in body["sources"]: + st.markdown(f"**{source['title']}** (score: {source['score']})") + st.write(source["snippet"]) + except requests.RequestException as exc: + st.error(f"Could not reach API at {API_URL}: {exc}") + + +if __name__ == "__main__": + main() From e745dff67a9b3bfc40c4d0672f4defd7a874c2e4 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sat, 7 Feb 2026 23:09:56 -0500 Subject: [PATCH 09/20] feat: Enhance entrypoint CLI with Python interpreter selection and automated installation options feat: Update Streamlit UI to improve UX and add chat functionality test: Add integration tests for entrypoint CLI setup help and Streamlit UI DB status handling --- SPECS/entrypoint-cli.md | 12 +++ SPECS/spec-traceability.md | 2 +- SPECS/streamlit-ui.md | 14 ++- pytest.ini | 2 + run.py | 150 ++++++++++++++++++++++++++++- tests/test_cli.py | 24 +++++ tests/test_generator_config.py | 4 +- tests/test_streamlit_ui_logic.py | 53 +++++++++++ ui/streamlit_app.py | 156 +++++++++++++++++++++++++------ 9 files changed, 374 insertions(+), 43 deletions(-) create mode 100644 tests/test_streamlit_ui_logic.py diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index c9d140f5..c37405b2 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -29,6 +29,14 @@ - `run.py` MUST be cross-platform and use `sys.executable` for subprocess calls. - `run.py` MUST default to creating and using a local `.venv` for setup. - `run.py` MUST support a `--no-venv` option to install into the current environment instead. +- `run.py setup` MUST detect and prefer a supported Python interpreter for virtual environment creation. +- Supported interpreter range for full project setup MUST be: + - `>=3.10` + - `<3.12` +- `run.py setup` SHOULD prefer `3.11`, then `3.10`, before fallback. +- `run.py setup` MUST support overriding interpreter selection via `--python `. +- If no supported interpreter is found, `run.py setup` MUST fail with actionable guidance. +- `run.py setup` SHOULD support optional automated Python installation via `--install-python` and fail clearly if package-manager installation is unavailable. - `fullstack` command MUST start API and UI on their default ports and shut down cleanly on Ctrl+C. - Commands MUST print clear status messages and fail clearly with actionable errors. - Docker helper commands MUST fail clearly when Docker is unavailable and MUST keep local non-Docker commands fully usable. @@ -38,6 +46,10 @@ - [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) - [ ] `python run.py setup --with-llm` downloads LLM assets for distilgpt2. (manual acceptance) - [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) +- [ ] `python run.py setup` automatically selects a supported interpreter (`3.11`/`3.10`) for `.venv` creation when available. (manual acceptance) +- [ ] `python run.py setup --python ` uses the specified interpreter when supported. (manual acceptance) +- [ ] `python run.py setup` fails with clear guidance when no supported interpreter is available. (manual acceptance) +- [ ] `python run.py setup --install-python` attempts package-manager Python installation and then continues setup when possible. (manual acceptance) - [ ] `python run.py api` starts the backend. (manual acceptance) - [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) - [ ] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (manual acceptance) diff --git a/SPECS/spec-traceability.md b/SPECS/spec-traceability.md index 6d4e76cc..ff7132a0 100644 --- a/SPECS/spec-traceability.md +++ b/SPECS/spec-traceability.md @@ -44,7 +44,7 @@ - Tests: `tests/test_data_loader.py`, `tests/test_retrieval.py` - Implementation: `app/retrieval.py`, `data/*` - `SPECS/streamlit-ui.md` - - Tests: `tests/test_streamlit_smoke.py` (optional smoke), manual acceptance checks + - Tests: `tests/test_streamlit_smoke.py`, `tests/test_streamlit_ui_logic.py`, manual acceptance checks - Implementation: `ui/streamlit_app.py` - `SPECS/entrypoint-cli.md` - Tests: `tests/test_cli.py`, manual acceptance checks diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index 3d6050ed..200a0845 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -21,11 +21,16 @@ - UI MUST be a single-page interface. - UI MUST NOT implement authentication, authorization, or user accounts. - UI MUST include: - - DB status indicator from `GET /db/status`. + - Scrollable chat-style message window. + - Initial assistant welcome message on load. + - Minimal DB status indicator showing only `built` and `doc_count` from `GET /db/status`. - Build DB action button that calls `POST /db/build`. - Question text input. - - `top_k` control constrained to `1..5` with default `3`. - - Generator selector with `mock` (default) and `distilgpt2` options. + - `top_k` control constrained to `1..5` with default `3`, displayed inline with generator selection. + - Generator selector with `mock` (default) and `distilgpt2` options, displayed inline with `top_k`. + - Three clickable example query buttons below the message input. + - `Clear Chat` button and `Submit` button positioned directly below message input. + - `Clear Chat` button visible only after at least one user message exists. - Submit action to call API endpoint `POST /ask`. - On success (`200`), UI MUST display: - `answer` @@ -46,9 +51,12 @@ ## Acceptance Criteria - [ ] User can enter a valid question, submit, and view answer output. (manual acceptance) - [ ] User can change `top_k` and see reflected retrieval metadata. (manual acceptance) +- [ ] User can switch generator between `mock` and `distilgpt2` from the same control row as `top_k`. (manual acceptance) - [ ] Source citations are rendered when present. (manual acceptance) - [ ] Fallback path is visible and understandable when no matches exist. (manual acceptance) - [ ] Validation errors are shown in the UI without app crash. (manual acceptance) - [x] UI runs locally against the API in default mock mode. (test_streamlit_smoke.py::test_streamlit_app_module_imports) +- [x] DB status payload is normalized to `built` and `doc_count` for UI consumption. (tests/test_streamlit_ui_logic.py::test_get_db_status_normalizes_payload) +- [x] DB status helper surfaces HTTP failures without crashing the UI flow. (tests/test_streamlit_ui_logic.py::test_get_db_status_handles_non_200) - [ ] UI is implemented as a single page. (manual acceptance) - [ ] UI is accessible without authentication or account flows. (manual acceptance) diff --git a/pytest.ini b/pytest.ini index b874a31b..3d4a2c06 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,6 +2,8 @@ testpaths = tests python_files = test_*.py addopts = -ra +filterwarnings = + ignore:Accessing the 'model_fields' attribute on the instance is deprecated.*:pydantic.warnings.PydanticDeprecatedSince211:chromadb\.types markers = smoke: quick smoke checks for module startup/import paths unit: unit tests for isolated component behavior diff --git a/run.py b/run.py index b88671dd..1203a4af 100644 --- a/run.py +++ b/run.py @@ -9,6 +9,8 @@ PROJECT_ROOT = Path(__file__).resolve().parent VENV_DIR = PROJECT_ROOT / ".venv" +SUPPORTED_MIN = (3, 10) +SUPPORTED_MAX_EXCLUSIVE = (3, 12) def _venv_python() -> str: @@ -24,10 +26,112 @@ def _run(cmd: list[str], cwd: Path | None = None, check: bool = False) -> int: return proc.returncode -def _ensure_venv() -> str: +def _is_supported_version(version: tuple[int, int]) -> bool: + return SUPPORTED_MIN <= version < SUPPORTED_MAX_EXCLUSIVE + + +def _version_text(version: tuple[int, int]) -> str: + return f"{version[0]}.{version[1]}" + + +def _probe_python_version(cmd_prefix: list[str]) -> tuple[int, int] | None: + try: + proc = subprocess.run( + cmd_prefix + ["-c", "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + raw = proc.stdout.strip() + parts = raw.split(".") + if len(parts) != 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +def _candidate_python_commands() -> list[list[str]]: + candidates: list[list[str]] = [] + if sys.platform.startswith("win"): + if shutil.which("py"): + candidates.extend([["py", "-3.11"], ["py", "-3.10"]]) + if shutil.which("python"): + candidates.append(["python"]) + return candidates + + for command in ["python3.11", "python3.10", "python3", "python"]: + if shutil.which(command): + candidates.append([command]) + return candidates + + +def _select_supported_python_command(override: str | None = None) -> list[str] | None: + if override: + override_cmd = [override] + version = _probe_python_version(override_cmd) + if version is None: + print(f"Could not execute Python override: {override}") + return None + if not _is_supported_version(version): + print( + "Unsupported Python override version: " + f"{_version_text(version)}. Supported range is >=3.10 and <3.12." + ) + return None + return override_cmd + + for cmd in _candidate_python_commands(): + version = _probe_python_version(cmd) + if version and _is_supported_version(version): + print(f"Using Python {_version_text(version)} via: {' '.join(cmd)}") + return cmd + return None + + +def _attempt_python_install() -> int: + print("Attempting to install Python 3.11 with an available package manager...") + installers: list[list[str]] = [] + + if sys.platform.startswith("win"): + if shutil.which("winget"): + installers.append(["winget", "install", "-e", "--id", "Python.Python.3.11"]) + if shutil.which("choco"): + installers.append(["choco", "install", "python311", "-y"]) + elif sys.platform == "darwin": + if shutil.which("brew"): + installers.append(["brew", "install", "python@3.11"]) + else: + if shutil.which("apt-get"): + installers.append(["apt-get", "update"]) + installers.append(["apt-get", "install", "-y", "python3.11", "python3.11-venv"]) + elif shutil.which("dnf"): + installers.append(["dnf", "install", "-y", "python3.11"]) + elif shutil.which("yum"): + installers.append(["yum", "install", "-y", "python3.11"]) + + if not installers: + print("No supported package manager detected for automatic Python install.") + return 2 + + for command in installers: + print(f"Running: {' '.join(command)}") + exit_code = _run(command) + if exit_code != 0: + print("Install command failed.") + return exit_code + return 0 + + +def _ensure_venv(python_cmd: list[str]) -> str: if not VENV_DIR.exists(): print("Creating virtual environment at .venv") - _run([sys.executable, "-m", "venv", str(VENV_DIR)], check=True) + _run(python_cmd + ["-m", "venv", str(VENV_DIR)], check=True) return _venv_python() @@ -51,11 +155,46 @@ def _download_llm_assets(python_bin: str) -> int: def cmd_setup(args: list[str]) -> int: if "--help" in args: - print("Usage: python run.py setup [--no-venv] [--with-llm]") + print("Usage: python run.py setup [--no-venv] [--with-llm] [--python ] [--install-python]") return 0 use_venv = "--no-venv" not in args - python_bin = _ensure_venv() if use_venv else sys.executable + install_python = "--install-python" in args + + python_override: str | None = None + for idx, arg in enumerate(args): + if arg.startswith("--python="): + python_override = arg.split("=", 1)[1].strip() + elif arg == "--python" and idx + 1 < len(args): + python_override = args[idx + 1].strip() + + if use_venv: + selected_python = _select_supported_python_command(override=python_override) + if selected_python is None and install_python: + install_result = _attempt_python_install() + if install_result != 0: + print("Automatic Python install failed.") + return install_result + selected_python = _select_supported_python_command(override=python_override) + + if selected_python is None: + print( + "No supported Python interpreter found for venv creation.\n" + "Supported range is >=3.10 and <3.12.\n" + "Install Python 3.10 or 3.11, then rerun setup.\n" + "Optional: run 'python run.py setup --install-python' to attempt automated install." + ) + return 2 + python_bin = _ensure_venv(selected_python) + else: + current_version = sys.version_info[:2] + if not _is_supported_version(current_version): + print( + "Warning: Current Python " + f"{_version_text((current_version[0], current_version[1]))} is outside the recommended range " + "(>=3.10 and <3.12). Some dependencies may fail." + ) + python_bin = sys.executable print("Installing dependencies from requirements.txt") result = _run([python_bin, "-m", "pip", "install", "-r", "requirements.txt"]) @@ -183,7 +322,8 @@ def show_help() -> int: """ Customer FAQ Assistant - Commands - python run.py setup [--no-venv] [--with-llm] Install dependencies + python run.py setup [--no-venv] [--with-llm] [--python ] [--install-python] + Install dependencies python run.py api Start FastAPI backend python run.py ui Start Streamlit UI python run.py fullstack Start API + UI together diff --git a/tests/test_cli.py b/tests/test_cli.py index 1386d7d5..c672fca6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -52,6 +52,30 @@ def test_run_py_help_command_prints_usage(monkeypatch): assert "help" in output, "Help should mention 'help' command" +@pytest.mark.integration +def test_run_py_setup_help_includes_python_selection_flags(): + """ + Verify that setup help includes interpreter-selection and install flags. + + Spec: entrypoint-cli.md + Requirement: "`run.py setup` supports interpreter override and optional install flow" + """ + if not RUN_PY.exists(): + pytest.skip("run.py not yet implemented") + + result = subprocess.run( + [sys.executable, str(RUN_PY), "setup", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0 + output = result.stdout.lower() + assert "--python" in output + assert "--install-python" in output + + @pytest.mark.integration def test_run_py_test_command_executes_pytest(): """ diff --git a/tests/test_generator_config.py b/tests/test_generator_config.py index 727fe186..359ce967 100644 --- a/tests/test_generator_config.py +++ b/tests/test_generator_config.py @@ -20,15 +20,13 @@ def test_default_generator_mode_is_mock_deterministic(client): @pytest.mark.optional @pytest.mark.integration -def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(monkeypatch): +def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(client): """ Verify that distilgpt2 mode routes through LLM adapter or fails clearly if unavailable. Spec: generation-optional-llm.md Acceptance Criteria: "With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter" """ - from fastapi.testclient import TestClient - payload = { "question": "What credit card options do you have?", "top_k": 3, diff --git a/tests/test_streamlit_ui_logic.py b/tests/test_streamlit_ui_logic.py new file mode 100644 index 00000000..df1f50c6 --- /dev/null +++ b/tests/test_streamlit_ui_logic.py @@ -0,0 +1,53 @@ +import importlib + +import pytest + + +@pytest.mark.unit +def test_get_db_status_normalizes_payload(monkeypatch): + """ + Verify DB status helper returns normalized minimal payload for the UI. + + Spec: streamlit-ui.md + Acceptance Criteria: "DB status payload is normalized to `built` and `doc_count` for UI consumption" + """ + pytest.importorskip("streamlit") + module = importlib.import_module("ui.streamlit_app") + + class _FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"built": True, "doc_count": 11, "indexed_count": 11, "collection": "faq"} + + monkeypatch.setattr(module.requests, "get", lambda *args, **kwargs: _FakeResponse()) + + built, payload, error = module._get_db_status() + + assert built is True + assert payload == {"built": True, "doc_count": 11} + assert error == "" + + +@pytest.mark.unit +def test_get_db_status_handles_non_200(monkeypatch): + """ + Verify DB status helper reports HTTP failures as a non-empty error string. + + Spec: streamlit-ui.md + Acceptance Criteria: "DB status helper surfaces HTTP failures without crashing the UI flow" + """ + pytest.importorskip("streamlit") + module = importlib.import_module("ui.streamlit_app") + + class _FakeResponse: + status_code = 503 + + monkeypatch.setattr(module.requests, "get", lambda *args, **kwargs: _FakeResponse()) + + built, payload, error = module._get_db_status() + + assert built is False + assert payload == {} + assert "DB status request failed (503)." in error diff --git a/ui/streamlit_app.py b/ui/streamlit_app.py index a852fbfc..092242b2 100644 --- a/ui/streamlit_app.py +++ b/ui/streamlit_app.py @@ -13,15 +13,44 @@ def _get_db_status() -> tuple[bool, dict, str]: if response.status_code != 200: return False, {}, f"DB status request failed ({response.status_code})." body = response.json() - return bool(body.get("built", False)), body, "" + minimal = { + "built": bool(body.get("built", False)), + "doc_count": int(body.get("doc_count", 0)), + } + return bool(body.get("built", False)), minimal, "" except requests.RequestException as exc: return False, {}, f"Could not reach API at {API_URL}: {exc}" def main() -> None: st.set_page_config(page_title="Customer FAQ Assistant", layout="centered") - st.title("Customer FAQ Assistant") - st.caption("Ask questions about Mockridge Bank products and services.") + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + if "chat_messages" not in st.session_state: + st.session_state.chat_messages = [ + { + "role": "assistant", + "content": "Welcome to the Customer FAQ Assistant. Enter a question to get started.", + "retrieval": None, + "sources": [], + } + ] + if "question_input" not in st.session_state: + st.session_state.question_input = "" + if "pending_example" not in st.session_state: + st.session_state.pending_example = None built, status_payload, status_error = _get_db_status() @@ -29,11 +58,16 @@ def main() -> None: st.error(status_error) st.stop() - st.subheader("Vector DB Status") - st.write(status_payload) + st.title("Customer FAQ Assistant") + sub_col_1, sub_col_2 = st.columns([4, 2]) + with sub_col_1: + st.caption("Ask questions about Mockridge Bank products and services.") + with sub_col_2: + status_label = "Ready" if built else "Not Built" + st.caption(f"DB: {status_label} | docs: {status_payload.get('doc_count', 0)}") if not built: - st.warning("Database is not built yet. Build the DB to enable chat.") + st.warning("Retrieval database is not built yet. Press the button below to enable chat.") if st.button("Build DB"): try: response = requests.post(f"{API_URL}/db/build", timeout=120) @@ -47,23 +81,88 @@ def main() -> None: st.error(f"Could not build DB at {API_URL}: {exc}") st.stop() + chat_window = st.container(height=420, border=True) + with chat_window: + for msg in st.session_state.chat_messages: + with st.chat_message(msg["role"]): + st.write(msg["content"]) + if msg["role"] == "assistant": + retrieval = msg.get("retrieval") + sources = msg.get("sources", []) + if retrieval: + with st.expander("Retrieval Details"): + st.write(retrieval) + if sources: + with st.expander("Sources"): + for source in sources: + st.markdown(f"**{source['title']}** (score: {source['score']})") + st.write(source["snippet"]) + controls_disabled = not built + examples = [ + "What are your checking account monthly fees?", + "How do overdraft fees work?", + "How do I report an unauthorized transaction?", + ] + + if st.session_state.pending_example is not None: + st.session_state.question_input = st.session_state.pending_example + st.session_state.pending_example = None + question = st.text_area( - "Question", + "Message", placeholder="What are your checking account monthly fees?", + key="question_input", disabled=controls_disabled, ) - top_k = st.slider("Top K sources", min_value=1, max_value=5, value=3, disabled=controls_disabled) - generator = st.selectbox( - "Generator", - ["mock", "distilgpt2"], - index=0, - disabled=controls_disabled, - help="Mock is deterministic. distilgpt2 requires LLM assets installed via run.py setup --with-llm.", - ) - - if st.button("Ask", disabled=controls_disabled): + action_col_1, action_col_2 = st.columns(2) + has_user_messages = any(msg.get("role") == "user" for msg in st.session_state.chat_messages) + with action_col_1: + if has_user_messages and st.button("Clear Chat", key="clear_chat_btn"): + st.session_state.chat_messages = [ + { + "role": "assistant", + "content": "Welcome to the Customer FAQ Assistant. Enter a question to get started.", + "retrieval": None, + "sources": [], + } + ] + st.session_state.question_input = "" + st.rerun() + with action_col_2: + button_col_1, button_col_2 = st.columns([3, 1]) + with button_col_1: + st.write("") + with button_col_2: + submit_clicked = st.button("Submit", key="submit_btn", disabled=controls_disabled) + + st.caption("Try an example:") + chip_cols = st.columns(3) + for idx, example in enumerate(examples): + with chip_cols[idx]: + if st.button(example, key=f"example_{idx}", disabled=controls_disabled): + st.session_state.pending_example = example + st.rerun() + control_col_1, control_col_2 = st.columns(2) + with control_col_1: + top_k = st.slider("Number of sources to retrieve", min_value=1, max_value=5, value=3, disabled=controls_disabled) + with control_col_2: + generator = st.selectbox( + "Response generator type", + ["mock", "distilgpt2"], + index=0, + disabled=controls_disabled, + help="Mock is deterministic, intended for testing. distilgpt2 is an actual LLM that first needs to be installed via 'run.py setup --with-llm'", + ) + + if submit_clicked: + if not question.strip(): + st.warning("Please enter a question before submitting.") + return + + st.session_state.chat_messages.append({"role": "user", "content": question.strip()}) payload = {"question": question, "top_k": top_k, "generator": generator} + try: response = requests.post(f"{API_URL}/ask", json=payload, timeout=90) if response.status_code != 200: @@ -76,20 +175,15 @@ def main() -> None: return body = response.json() - st.subheader("Answer") - st.write(body["answer"]) - - st.subheader("Retrieval") - st.write(body["retrieval"]) - - st.subheader("Sources") - if not body["sources"]: - st.info("No matching sources were found.") - return - - for source in body["sources"]: - st.markdown(f"**{source['title']}** (score: {source['score']})") - st.write(source["snippet"]) + st.session_state.chat_messages.append( + { + "role": "assistant", + "content": body["answer"], + "retrieval": body.get("retrieval"), + "sources": body.get("sources", []), + } + ) + st.rerun() except requests.RequestException as exc: st.error(f"Could not reach API at {API_URL}: {exc}") From 34a12c2697812b8fe51c0291a3a8c54051d0f18c Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 08:14:19 -0500 Subject: [PATCH 10/20] feat: Integrate Langchain support for generation and retrieval --- app/generation.py | 60 +++++++--- app/rag_chain.py | 32 ++++++ app/retrieval.py | 271 +++++++++++++++++++++++++++++++++----------- requirements.txt | 4 + ui/streamlit_app.py | 131 ++++++++++++++------- 5 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 app/rag_chain.py diff --git a/app/generation.py b/app/generation.py index 594e53cc..d0e355ce 100644 --- a/app/generation.py +++ b/app/generation.py @@ -1,4 +1,4 @@ -import os +import importlib.util FALLBACK_ANSWER = ( @@ -7,6 +7,12 @@ ) +def _use_langchain_generation() -> bool: + return bool(importlib.util.find_spec("langchain_core")) and bool( + importlib.util.find_spec("langchain_huggingface") + ) + + class MockGenerator: def generate(self, question: str, sources: list[dict]) -> str: if not sources: @@ -22,45 +28,71 @@ def generate(self, question: str, sources: list[dict]) -> str: class DistilGPT2Generator: def __init__(self) -> None: + self._llm = None self._generator = None - def _ensure_model(self): - if self._generator is not None: + def _ensure_model(self) -> None: + if self._llm is not None or self._generator is not None: return try: from transformers import pipeline # Only download via explicit setup command; runtime should be local-only. - self._generator = pipeline( + hf_pipeline = pipeline( "text-generation", model="distilgpt2", tokenizer="distilgpt2", model_kwargs={"local_files_only": True}, + max_new_tokens=60, + do_sample=False, + num_return_sequences=1, + pad_token_id=50256, + return_full_text=False, ) + if _use_langchain_generation(): + from langchain_huggingface import HuggingFacePipeline + + self._llm = HuggingFacePipeline(pipeline=hf_pipeline) + else: + self._generator = hf_pipeline except Exception as exc: # pragma: no cover - depends on local model availability raise RuntimeError( "distilgpt2 model is unavailable locally. " "Run `python run.py setup --with-llm` or use generator=mock." ) from exc + def _generate_with_langchain(self, question: str, sources: list[dict]) -> str: + from langchain_core.documents import Document + + from app.rag_chain import generate_answer as generate_rag_answer + + documents = [ + Document( + page_content=str(source.get("snippet", "")), + metadata={"title": str(source.get("title", "")), "id": str(source.get("id", ""))}, + ) + for source in sources[:3] + ] + return str(generate_rag_answer(question=question, documents=documents, llm=self._llm)).strip() + + def _generate_legacy(self, question: str, sources: list[dict]) -> str: + context = " ".join(source.get("snippet", "") for source in sources[:2]) + prompt = f"Question: {question}\nContext: {context}\nAnswer:" + outputs = self._generator(prompt) + return str(outputs[0].get("generated_text", "")).strip() if outputs else "" + def generate(self, question: str, sources: list[dict]) -> str: self._ensure_model() if not sources: return FALLBACK_ANSWER - context = " ".join(source["snippet"] for source in sources[:2]) - prompt = f"Question: {question}\nContext: {context}\nAnswer:" - - outputs = self._generator( - prompt, - max_new_tokens=60, - do_sample=False, - num_return_sequences=1, - pad_token_id=50256, + text = ( + self._generate_with_langchain(question=question, sources=sources) + if self._llm is not None + else self._generate_legacy(question=question, sources=sources) ) - text = outputs[0]["generated_text"].strip() if not text: return FALLBACK_ANSWER return text diff --git a/app/rag_chain.py b/app/rag_chain.py new file mode 100644 index 00000000..bb7d2e0e --- /dev/null +++ b/app/rag_chain.py @@ -0,0 +1,32 @@ +from langchain_core.documents import Document +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import PromptTemplate + + +RAG_PROMPT = PromptTemplate.from_template( + ( + "You are answering customer support FAQ questions for Mockridge Bank.\n" + "Use only the provided context.\n" + "If the context is insufficient, say so briefly.\n\n" + "Question: {question}\n" + "Context:\n{context}\n\n" + "Answer:" + ) +) + + +def _build_context(documents: list[Document]) -> str: + chunks: list[str] = [] + for doc in documents: + title = str(doc.metadata.get("title", "")).strip() + if title: + chunks.append(f"{title}: {doc.page_content}") + else: + chunks.append(doc.page_content) + return "\n\n".join(chunks).strip() + + +def generate_answer(question: str, documents: list[Document], llm) -> str: + context = _build_context(documents) + chain = RAG_PROMPT | llm | StrOutputParser() + return chain.invoke({"question": question, "context": context}) diff --git a/app/retrieval.py b/app/retrieval.py index c01e4476..be2b810f 100644 --- a/app/retrieval.py +++ b/app/retrieval.py @@ -1,3 +1,5 @@ +import importlib.util +import logging import os import re from dataclasses import dataclass @@ -8,12 +10,18 @@ import chromadb from chromadb.config import Settings +# Chroma 0.6.x can emit noisy telemetry errors with newer posthog versions. +# Telemetry is already disabled in settings; this also silences those log lines. +logging.getLogger("chromadb.telemetry.product.posthog").disabled = True +logging.getLogger("posthog").disabled = True + FAQ_DIR = Path("data") DEFAULT_MIN_SCORE = 0.25 MODEL_NAME = "all-MiniLM-L6-v2" CHROMA_DIR = Path("chroma") COLLECTION_NAME = "mockridge_faq" +_LEXICAL_FALLBACK_READY = False @dataclass @@ -54,6 +62,45 @@ def _snippet(body: str, limit: int = 220) -> str: return body[: limit - 3].rstrip() + "..." +def _tokenize(text: str) -> set[str]: + return set(re.findall(r"[a-z0-9]+", text.lower())) + + +def _lexical_search(question: str, docs: list[dict[str, str]], top_k: int, min_score: float) -> list[RetrievedDoc]: + query_tokens = _tokenize(question) + if not query_tokens: + return [] + + matches: list[RetrievedDoc] = [] + for doc in docs: + corpus_text = f"{doc['title']} {doc['body']}" + doc_tokens = _tokenize(corpus_text) + if not doc_tokens: + continue + + overlap = len(query_tokens & doc_tokens) + if overlap == 0: + continue + + coverage = overlap / len(query_tokens) + density = overlap / max(1, len(doc_tokens)) + score = min(1.0, (0.85 * coverage) + (0.15 * density * 10)) + if score < min_score: + continue + + matches.append( + RetrievedDoc( + id=doc["id"], + title=doc["title"], + body=doc["body"], + score=round(score, 6), + ) + ) + + matches.sort(key=lambda item: item.score, reverse=True) + return matches[:top_k] + + def load_faq_docs() -> list[dict[str, str]]: docs: list[dict[str, str]] = [] if not FAQ_DIR.exists(): @@ -78,6 +125,13 @@ def get_min_score() -> float: return DEFAULT_MIN_SCORE +@lru_cache(maxsize=1) +def _use_langchain_retrieval() -> bool: + return bool(importlib.util.find_spec("langchain_chroma")) and bool( + importlib.util.find_spec("langchain_huggingface") + ) + + @lru_cache(maxsize=1) def _get_client() -> chromadb.PersistentClient: return chromadb.PersistentClient( @@ -87,7 +141,7 @@ def _get_client() -> chromadb.PersistentClient: @lru_cache(maxsize=1) -def _get_collection() -> chromadb.Collection: +def _get_collection() -> Any: client = _get_client() return client.get_or_create_collection( name=COLLECTION_NAME, @@ -96,30 +150,62 @@ def _get_collection() -> chromadb.Collection: @lru_cache(maxsize=1) -def _get_model() -> Any: +def _get_legacy_model() -> Any: from sentence_transformers import SentenceTransformer - return SentenceTransformer(MODEL_NAME) + return SentenceTransformer(MODEL_NAME, local_files_only=True) -def _ensure_indexed(collection: chromadb.Collection, docs: list[dict[str, str]], model: Any): +@lru_cache(maxsize=1) +def _get_langchain_embeddings() -> Any: + from langchain_huggingface import HuggingFaceEmbeddings + + return HuggingFaceEmbeddings( + model_name=f"sentence-transformers/{MODEL_NAME}", + model_kwargs={"local_files_only": True}, + encode_kwargs={"normalize_embeddings": True}, + ) + + +@lru_cache(maxsize=1) +def _get_langchain_vectorstore() -> Any: + from langchain_chroma import Chroma + + return Chroma( + collection_name=COLLECTION_NAME, + persist_directory=str(CHROMA_DIR), + embedding_function=_get_langchain_embeddings(), + collection_metadata={"hnsw:space": "cosine"}, + ) + + +def _ensure_indexed(collection: Any, docs: list[dict[str, str]]) -> None: if not docs: return - existing_ids = set() + existing_ids: set[str] = set() try: - existing = collection.get(include=["ids"]) + existing = collection.get() existing_ids = set(existing.get("ids", [])) except Exception: - existing_ids = set() + pass new_docs = [doc for doc in docs if doc["id"] not in existing_ids] if not new_docs: return + if _use_langchain_retrieval(): + vectorstore = _get_langchain_vectorstore() + vectorstore.add_texts( + texts=[doc["body"] for doc in new_docs], + ids=[doc["id"] for doc in new_docs], + metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in new_docs], + ) + return + + model = _get_legacy_model() texts = [f"{doc['title']}\n{doc['body']}" for doc in new_docs] embeddings = model.encode(texts, normalize_embeddings=True).tolist() - collection.add( ids=[doc["id"] for doc in new_docs], documents=[doc["body"] for doc in new_docs], @@ -129,11 +215,23 @@ def _ensure_indexed(collection: chromadb.Collection, docs: list[dict[str, str]], def get_db_status() -> dict: + global _LEXICAL_FALLBACK_READY + docs = load_faq_docs() - collection = _get_collection() - indexed_count = collection.count() doc_count = len(docs) - built = indexed_count >= doc_count and doc_count > 0 + + if _LEXICAL_FALLBACK_READY: + indexed_count = doc_count + built = doc_count > 0 + else: + try: + indexed_count = _get_collection().count() + except Exception: + _get_collection.cache_clear() + collection = _get_collection() + indexed_count = collection.count() + built = indexed_count >= doc_count and doc_count > 0 + return { "built": built, "doc_count": doc_count, @@ -143,6 +241,8 @@ def get_db_status() -> dict: def build_db() -> dict: + global _LEXICAL_FALLBACK_READY + docs = load_faq_docs() client = _get_client() @@ -153,70 +253,113 @@ def build_db() -> dict: pass _get_collection.cache_clear() - collection = _get_collection() - model = _get_model() - texts = [f"{doc['title']}\n{doc['body']}" for doc in docs] - embeddings = model.encode(texts, normalize_embeddings=True).tolist() if docs else [] - - if docs: - collection.add( - ids=[doc["id"] for doc in docs], - documents=[doc["body"] for doc in docs], - metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in docs], - embeddings=embeddings, - ) + _get_langchain_vectorstore.cache_clear() - after = collection.count() - built = after == len(docs) and len(docs) > 0 - return { - "built": built, - "doc_count": len(docs), - "indexed_count": after, - "added": after, - "collection": COLLECTION_NAME, - } + try: + collection = _get_collection() + if docs: + if _use_langchain_retrieval(): + vectorstore = _get_langchain_vectorstore() + vectorstore.add_texts( + texts=[doc["body"] for doc in docs], + ids=[doc["id"] for doc in docs], + metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in docs], + ) + else: + model = _get_legacy_model() + texts = [f"{doc['title']}\n{doc['body']}" for doc in docs] + embeddings = model.encode(texts, normalize_embeddings=True).tolist() + collection.add( + ids=[doc["id"] for doc in docs], + documents=[doc["body"] for doc in docs], + metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in docs], + embeddings=embeddings, + ) + + after = collection.count() + built = after == len(docs) and len(docs) > 0 + _LEXICAL_FALLBACK_READY = False + return { + "built": built, + "doc_count": len(docs), + "indexed_count": after, + "added": after, + "collection": COLLECTION_NAME, + } + except Exception: + # Offline-safe fallback for environments without local embedding assets. + _LEXICAL_FALLBACK_READY = len(docs) > 0 + return { + "built": _LEXICAL_FALLBACK_READY, + "doc_count": len(docs), + "indexed_count": len(docs), + "added": len(docs), + "collection": COLLECTION_NAME, + } def retrieve(question: str, top_k: int) -> list[RetrievedDoc]: + global _LEXICAL_FALLBACK_READY + docs = load_faq_docs() if not docs: return [] - collection = _get_collection() - model = _get_model() - - _ensure_indexed(collection, docs, model) - - query_embedding = model.encode([question], normalize_embeddings=True).tolist()[0] - result = collection.query( - query_embeddings=[query_embedding], - n_results=top_k, - include=["documents", "metadatas", "distances"], - ) - min_score = get_min_score() - matches: list[RetrievedDoc] = [] + if _LEXICAL_FALLBACK_READY: + return _lexical_search(question=question, docs=docs, top_k=top_k, min_score=min_score) - documents = result.get("documents", [[]])[0] - metadatas = result.get("metadatas", [[]])[0] - distances = result.get("distances", [[]])[0] - - for doc_body, meta, distance in zip(documents, metadatas, distances): - # Convert distance to similarity score (1 - distance) for cosine distance. - score = 1.0 - float(distance) - if score < min_score: - continue - matches.append( - RetrievedDoc( - id=str(meta.get("id", "")), - title=str(meta.get("title", "")), - body=str(doc_body), - score=round(score, 6), + collection = _get_collection() + try: + _ensure_indexed(collection, docs) + + matches: list[RetrievedDoc] = [] + if _use_langchain_retrieval(): + vectorstore = _get_langchain_vectorstore() + results = vectorstore.similarity_search_with_score(question, k=top_k) + for document, distance in results: + meta = document.metadata or {} + score = 1.0 - float(distance) + if score < min_score: + continue + matches.append( + RetrievedDoc( + id=str(meta.get("id", "")), + title=str(meta.get("title", "")), + body=str(document.page_content), + score=round(score, 6), + ) + ) + else: + model = _get_legacy_model() + query_embedding = model.encode([question], normalize_embeddings=True).tolist()[0] + result = collection.query( + query_embeddings=[query_embedding], + n_results=top_k, + include=["documents", "metadatas", "distances"], ) - ) - - matches.sort(key=lambda item: item.score, reverse=True) - return matches[:top_k] + documents = result.get("documents", [[]])[0] + metadatas = result.get("metadatas", [[]])[0] + distances = result.get("distances", [[]])[0] + for doc_body, meta, distance in zip(documents, metadatas, distances): + score = 1.0 - float(distance) + if score < min_score: + continue + matches.append( + RetrievedDoc( + id=str(meta.get("id", "")), + title=str(meta.get("title", "")), + body=str(doc_body), + score=round(score, 6), + ) + ) + + matches.sort(key=lambda item: item.score, reverse=True) + _LEXICAL_FALLBACK_READY = False + return matches[:top_k] + except Exception: + _LEXICAL_FALLBACK_READY = True + return _lexical_search(question=question, docs=docs, top_k=top_k, min_score=min_score) def to_source_payload(docs: list[RetrievedDoc]) -> list[dict]: diff --git a/requirements.txt b/requirements.txt index 101cf7f8..e5cbb2a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,10 @@ sentence-transformers>=3.0,<4.0 transformers>=4.44,<5.0 torch>=2.3,<3.0 sentencepiece>=0.2,<0.3 +langchain>=0.3,<0.4 +langchain-core>=0.3,<0.4 +langchain-huggingface>=0.1,<0.2 +langchain-chroma>=0.1,<0.2 # UI streamlit>=1.37,<2.0 diff --git a/ui/streamlit_app.py b/ui/streamlit_app.py index 092242b2..8115da14 100644 --- a/ui/streamlit_app.py +++ b/ui/streamlit_app.py @@ -1,4 +1,5 @@ import os +from html import escape import requests import streamlit as st @@ -34,6 +35,32 @@ def main() -> None: [data-testid="stHeader"] { height: 0rem; } + .chat-row { + display: flex; + margin: 0.35rem 0; + } + .chat-row.user { + justify-content: flex-end; + } + .chat-row.assistant { + justify-content: flex-start; + } + .chat-bubble { + max-width: 78%; + padding: 0.6rem 0.8rem; + border-radius: 0.9rem; + line-height: 1.35; + word-wrap: break-word; + color: #111111; + } + .chat-row.user .chat-bubble { + background: #d8ebff; + border-bottom-right-radius: 0.25rem; + } + .chat-row.assistant .chat-bubble { + background: #f2f3f5; + border-bottom-left-radius: 0.25rem; + } """, unsafe_allow_html=True, @@ -51,6 +78,10 @@ def main() -> None: st.session_state.question_input = "" if "pending_example" not in st.session_state: st.session_state.pending_example = None + if "pending_submission" not in st.session_state: + st.session_state.pending_submission = None + if "clear_question_input" not in st.session_state: + st.session_state.clear_question_input = False built, status_payload, status_error = _get_db_status() @@ -61,7 +92,7 @@ def main() -> None: st.title("Customer FAQ Assistant") sub_col_1, sub_col_2 = st.columns([4, 2]) with sub_col_1: - st.caption("Ask questions about Mockridge Bank products and services.") + st.caption("Ask questions about Mockridge Bank's fictional products and services.") with sub_col_2: status_label = "Ready" if built else "Not Built" st.caption(f"DB: {status_label} | docs: {status_payload.get('doc_count', 0)}") @@ -84,34 +115,40 @@ def main() -> None: chat_window = st.container(height=420, border=True) with chat_window: for msg in st.session_state.chat_messages: - with st.chat_message(msg["role"]): - st.write(msg["content"]) - if msg["role"] == "assistant": - retrieval = msg.get("retrieval") - sources = msg.get("sources", []) - if retrieval: - with st.expander("Retrieval Details"): - st.write(retrieval) - if sources: - with st.expander("Sources"): - for source in sources: - st.markdown(f"**{source['title']}** (score: {source['score']})") - st.write(source["snippet"]) - - controls_disabled = not built + role = "user" if msg.get("role") == "user" else "assistant" + st.markdown( + f"
{escape(str(msg.get('content', '')))}
", + unsafe_allow_html=True, + ) + if role == "assistant": + retrieval = msg.get("retrieval") + sources = msg.get("sources", []) + if retrieval: + with st.expander("Retrieval Details"): + st.write(retrieval) + if sources: + with st.expander("Sources"): + for source in sources: + st.markdown(f"**{source['title']}** (score: {source['score']})") + st.write(source["snippet"]) + + controls_disabled = (not built) or (st.session_state.pending_submission is not None) examples = [ "What are your checking account monthly fees?", "How do overdraft fees work?", - "How do I report an unauthorized transaction?", + "What can I do with the mobile app?", ] if st.session_state.pending_example is not None: st.session_state.question_input = st.session_state.pending_example st.session_state.pending_example = None + if st.session_state.clear_question_input: + st.session_state.question_input = "" + st.session_state.clear_question_input = False question = st.text_area( "Message", - placeholder="What are your checking account monthly fees?", + placeholder="Enter your question here", key="question_input", disabled=controls_disabled, ) @@ -127,7 +164,8 @@ def main() -> None: "sources": [], } ] - st.session_state.question_input = "" + st.session_state.clear_question_input = True + st.session_state.pending_submission = None st.rerun() with action_col_2: button_col_1, button_col_2 = st.columns([3, 1]) @@ -160,31 +198,44 @@ def main() -> None: st.warning("Please enter a question before submitting.") return - st.session_state.chat_messages.append({"role": "user", "content": question.strip()}) - payload = {"question": question, "top_k": top_k, "generator": generator} + user_question = question.strip() + st.session_state.chat_messages.append({"role": "user", "content": user_question}) + st.session_state.clear_question_input = True + st.session_state.pending_submission = { + "question": user_question, + "top_k": top_k, + "generator": generator, + } + st.rerun() + pending_submission = st.session_state.pending_submission + if pending_submission is not None: try: - response = requests.post(f"{API_URL}/ask", json=payload, timeout=90) - if response.status_code != 200: - detail = response.json().get("detail", "Unknown error") - if generator == "distilgpt2" and "setup --with-llm" in detail: - st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") - if response.status_code == 503 and "Database not built" in detail: - st.warning("Database is not built. Use the Build DB button above.") - st.error(f"Request failed ({response.status_code}): {detail}") - return - - body = response.json() - st.session_state.chat_messages.append( - { - "role": "assistant", - "content": body["answer"], - "retrieval": body.get("retrieval"), - "sources": body.get("sources", []), - } - ) + with st.spinner("Processing your question..."): + response = requests.post(f"{API_URL}/ask", json=pending_submission, timeout=90) + if response.status_code != 200: + detail = response.json().get("detail", "Unknown error") + if pending_submission["generator"] == "distilgpt2" and "setup --with-llm" in detail: + st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") + if response.status_code == 503 and "Database not built" in detail: + st.warning("Database is not built. Use the Build DB button above.") + st.error(f"Request failed ({response.status_code}): {detail}") + st.session_state.pending_submission = None + return + + body = response.json() + st.session_state.chat_messages.append( + { + "role": "assistant", + "content": body["answer"], + "retrieval": body.get("retrieval"), + "sources": body.get("sources", []), + } + ) + st.session_state.pending_submission = None st.rerun() except requests.RequestException as exc: + st.session_state.pending_submission = None st.error(f"Could not reach API at {API_URL}: {exc}") From 29141ecba22cb8c38924f21c3b8f01d13d6a9fba Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 08:59:11 -0500 Subject: [PATCH 11/20] feat: Update project specifications in the constitution.md file. Added tox matrix-testing functionality for testing python 3.10, 3.11, and 3.12 --- .gitignore | 1 + PROJECT_BRIEF.md | 182 ------------------------------- SPECS/entrypoint-cli.md | 8 +- SPECS/generation-optional-llm.md | 3 +- SPECS/retrieval-pipeline.md | 12 +- SPECS/streamlit-ui.md | 2 + app/retrieval.py | 62 +++++++++-- constitution.md | 109 ++++++++++++++++++ noxfile.py | 7 ++ run.py | 68 ++++++++++-- tox.ini | 11 ++ 11 files changed, 257 insertions(+), 208 deletions(-) delete mode 100644 PROJECT_BRIEF.md create mode 100644 constitution.md create mode 100644 noxfile.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index d0eacd20..4cce859c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ # Virtual environments .venv/ +.venv*/ venv/ env/ ENV/ diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md deleted file mode 100644 index 1d84864b..00000000 --- a/PROJECT_BRIEF.md +++ /dev/null @@ -1,182 +0,0 @@ -# Project Brief — Customer FAQ Assistant (Mockridge Bank) - -## Purpose of This File - -This document is a concise onboarding brief for code generation tools. -It orients a new tool to the project goals, constraints, specs, tests, and -implementation order without replacing the authoritative specs. - -Source-of-truth hierarchy: -- Specs in `SPECS/` define behavior and acceptance criteria. -- Tests in `tests/` encode those specs. -- Implementation exists to satisfy the tests. - -Note: Per project rules, do not use `README.md` as context for the LLM/tool. - ---- - -## Project Overview - -Build a spec-driven, testable RAG-style Customer FAQ Assistant -for a fictional bank named "Mockridge Bank". - -The system: -- Accepts customer questions about bank products and services. -- Retrieves relevant FAQ documents using vector similarity search. -- Generates an answer based on retrieved documents. -- Returns the answer with cited sources. -- Runs locally without API keys or secrets. -- Needs to work consistently across platforms (Windows, Mac, Linux) - -Primary goals: -- Demonstrate AI system testing and spec-driven development. -- Provide deterministic, CI-friendly behavior. -- Cleanly separate retrieval and generation. - -Non-goal: -- High-quality or creative LLM output. (This requires API keys or large model downloads, which we want to avoid) - ---- - -## Core Principles - -- Specs define behavior, tests enforce it, code satisfies tests. -- Determinism is required by default. -- AI components must be testable and mockable. -- Reviewer setup friction must be minimal. - ---- - -## Key Constraints - -- No paid APIs and no API keys required for tests. -- No model weights committed to the repository. -- No GPU required. -- No external network calls during tests. -- `pytest` must run successfully by default. -- Optional LLM usage must be clearly documented and opt-in. - ---- - -## Tech Stack - -- Language: Python 3.10+ -- API: FastAPI -- Vector store: ChromaDB (local, persisted) -- Embeddings: `all-MiniLM-L6-v2` via SentenceTransformers -- Optional LLM: `distilgpt2` via Hugging Face `transformers` -- UI: Streamlit (single-page) -- Tests: pytest - ---- - -## Functional Requirements (High-Level) - -The API exposes endpoints for question answering and health checks. Detailed -request/response shapes, validation rules, and error cases are defined in `SPECS/` -and enforced by `tests/`. - ---- - -## Data Requirements - -Local FAQ corpus for Mockridge Bank: -- Location: `data/` -- Format: Markdown or JSON -- Size: 8–15 documents -- Each document must include `id`, `title`, `body` -- Markdown files must explicitly include: - - `id: ` - - `title: ` - -Example topics: -- checking accounts -- savings accounts -- auto loans -- credit cards -- overdraft fees -- fraud/disputes -- mobile app -- support hours - ---- - -## RAG Pipeline (Logical Flow) - -1. Validate request. -2. Embed the question. -3. Retrieve relevant documents from the local vector store. -4. Apply a relevance threshold and return a fallback response if no matches. -5. Generate an answer from retrieved content. -6. Return answer plus cited sources and metadata. - ---- - -## Generation Strategy - -Default generator (used in tests): -- Deterministic mock/extractive generator. -- Builds answers from retrieved text. -- No model downloads or external calls. - -Optional generator (runtime only): -- `distilgpt2` via `transformers`. -- Selected per request (UI dropdown or `generator=distilgpt2`). -- Model assets are installed via `python run.py setup --with-llm`. -- Must not be required for tests. -- If unavailable, fail clearly with actionable guidance. - ---- - -## Testing Requirements - -All tests use pytest and run without network, API keys, or LLM downloads. -Coverage is defined by the specs in `SPECS/` and implemented in `tests/`. - ---- - -## Project Structure - -- `app/main.py` FastAPI app and routes -- `app/models.py` Pydantic schemas -- `app/retrieval.py` ChromaDB + embeddings -- `app/generation.py` mock + optional LLM generator -- `run.py` cross-platform entry point for setup, run, and test commands -- `data/*` local FAQ corpus -- `ui/streamlit_app.py` single-page Streamlit UI -- `tests/` pytest suite -- `pytest.ini` test configuration -- `SPECS/` authoritative feature specs - ---- - -## Environment Variables - -Optional only. Defaults are applied when unset. Reviewers should not need to set any values. -An example file is provided at `.env.example`. - -- `RAG_MIN_SCORE=0.25` (default relevance threshold) -- `API_URL` (optional override for Streamlit to reach API) - ---- - -## Explicit Non-Goals - -- High-quality natural language generation -- Authentication or authorization -- External APIs -- GPU acceleration -- Production scaling or deployment - ---- - -## Implementation Order (Spec-Driven) - -1. Create file structure. -2. Implement Pydantic models and validation. -3. Implement API routes based on specs. -4. Implement data loader and retrieval pipeline. -5. Implement deterministic mock generator. -6. Add optional `distilgpt2` generator behind env flag. -7. Add Streamlit UI. -8. Keep tests green at every step. diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index c37405b2..cb23b141 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -20,6 +20,7 @@ - `ui`: Run the Streamlit UI. - `fullstack`: Run API and UI concurrently. - `test`: Run pytest. + - `test-matrix`: Run tox-based multi-Python test matrix. - `help`: Show available commands and examples. - `run.py` SHOULD support optional Docker helper commands: - `docker-build`: Build container images. @@ -32,8 +33,8 @@ - `run.py setup` MUST detect and prefer a supported Python interpreter for virtual environment creation. - Supported interpreter range for full project setup MUST be: - `>=3.10` - - `<3.12` -- `run.py setup` SHOULD prefer `3.11`, then `3.10`, before fallback. + - `<3.13` +- `run.py setup` SHOULD prefer `3.12`, then `3.11`, then `3.10`, before fallback. - `run.py setup` MUST support overriding interpreter selection via `--python `. - If no supported interpreter is found, `run.py setup` MUST fail with actionable guidance. - `run.py setup` SHOULD support optional automated Python installation via `--install-python` and fail clearly if package-manager installation is unavailable. @@ -46,7 +47,7 @@ - [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) - [ ] `python run.py setup --with-llm` downloads LLM assets for distilgpt2. (manual acceptance) - [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) -- [ ] `python run.py setup` automatically selects a supported interpreter (`3.11`/`3.10`) for `.venv` creation when available. (manual acceptance) +- [ ] `python run.py setup` automatically selects a supported interpreter (`3.12`/`3.11`/`3.10`) for `.venv` creation when available. (manual acceptance) - [ ] `python run.py setup --python ` uses the specified interpreter when supported. (manual acceptance) - [ ] `python run.py setup` fails with clear guidance when no supported interpreter is available. (manual acceptance) - [ ] `python run.py setup --install-python` attempts package-manager Python installation and then continues setup when possible. (manual acceptance) @@ -54,4 +55,5 @@ - [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) - [ ] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (manual acceptance) - [ ] `python run.py test` runs pytest successfully. (test_cli.py - to be implemented) +- [ ] `python run.py test-matrix` runs tox matrix environments (for available local interpreters). (manual acceptance) - [ ] `python run.py docker-build`, `python run.py docker-api`, `python run.py docker-fullstack`, and `python run.py docker-down` work when Docker is installed and fail with clear guidance when Docker is unavailable. (manual acceptance) diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index 7b4cb4a2..394f4cb2 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -6,7 +6,7 @@ ## Scope - In: - Support `generator=distilgpt2` request option. - - Implement a separate generator path backed by Hugging Face `transformers`. + - Implement a separate generator path backed by Hugging Face `transformers`, with LangChain pipeline integration when available. - Document runtime behavior and first-run model download expectations. - Out: - CI dependency on LLM mode. @@ -17,6 +17,7 @@ - `generator=distilgpt2` - Default mode MUST remain `mock`. - LLM mode MUST NOT be required to start or test default application workflow. +- When LangChain Hugging Face integration is available, `distilgpt2` mode SHOULD execute through the LangChain adapter path. - If LLM mode is selected and model assets are unavailable: - System MUST fail clearly with actionable local setup guidance. - UI MUST instruct user to run `python run.py setup --with-llm`. diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index 25e0acdf..da2250fc 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -1,15 +1,16 @@ # Feature Spec: Retrieval Pipeline ## Goal -- Retrieve the most relevant local FAQ documents for a customer question using embeddings and ChromaDB. +- Retrieve the most relevant local FAQ documents for a customer question using embeddings and local ChromaDB, orchestrated through LangChain. ## Scope - In: - Embed incoming question with `all-MiniLM-L6-v2`. - - Query local persisted ChromaDB for top-k matches. + - Query local persisted ChromaDB for top-k matches (via LangChain vector store integration). - Expose DB lifecycle endpoints for build/status. - Return scored, sorted source candidates. - Apply minimum relevance threshold rule. + - Support deterministic lexical fallback if local embedding assets are unavailable. - Out: - Final answer wording strategy. - External document ingestion services. @@ -21,9 +22,12 @@ - `GET /db/status` with DB build status and counts. - `POST /db/build` to build/index FAQ embeddings. - Query flow: - - Embed question with `all-MiniLM-L6-v2` via SentenceTransformers. - - Query ChromaDB using `top_k`. + - Embed question with `all-MiniLM-L6-v2` via LangChain Hugging Face embeddings (SentenceTransformers backend). + - Query ChromaDB using `top_k` via LangChain Chroma integration. - Map results to source items with `id`, `title`, `snippet`, `score`. +- If embedding/vector retrieval cannot initialize due to missing local model assets: + - System MUST fall back to deterministic lexical retrieval over local FAQ docs. + - Response contract and threshold behavior MUST remain unchanged. - Sources MUST be sorted by descending relevance score. - If no document satisfies relevance threshold: - Retrieval result MUST be treated as unmatched. diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index 200a0845..adf9bbef 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -22,6 +22,7 @@ - UI MUST NOT implement authentication, authorization, or user accounts. - UI MUST include: - Scrollable chat-style message window. + - Chat bubble layout with assistant messages left-aligned and user messages right-aligned. - Initial assistant welcome message on load. - Minimal DB status indicator showing only `built` and `doc_count` from `GET /db/status`. - Build DB action button that calls `POST /db/build`. @@ -32,6 +33,7 @@ - `Clear Chat` button and `Submit` button positioned directly below message input. - `Clear Chat` button visible only after at least one user message exists. - Submit action to call API endpoint `POST /ask`. + - On submit, UI MUST immediately append the user message to the chat window and clear the input before backend processing completes. - On success (`200`), UI MUST display: - `answer` - `sources` list with `title`, `snippet`, and `score` diff --git a/app/retrieval.py b/app/retrieval.py index be2b810f..c1197192 100644 --- a/app/retrieval.py +++ b/app/retrieval.py @@ -22,6 +22,30 @@ CHROMA_DIR = Path("chroma") COLLECTION_NAME = "mockridge_faq" _LEXICAL_FALLBACK_READY = False +_STOPWORDS = { + "a", + "an", + "and", + "are", + "can", + "do", + "for", + "how", + "i", + "in", + "is", + "it", + "my", + "of", + "on", + "or", + "the", + "to", + "what", + "with", + "you", + "your", +} @dataclass @@ -63,7 +87,13 @@ def _snippet(body: str, limit: int = 220) -> str: def _tokenize(text: str) -> set[str]: - return set(re.findall(r"[a-z0-9]+", text.lower())) + raw_tokens = re.findall(r"[a-z0-9]+", text.lower()) + normalized: set[str] = set() + for token in raw_tokens: + if len(token) > 3 and token.endswith("s"): + normalized.add(token[:-1]) + normalized.add(token) + return normalized def _lexical_search(question: str, docs: list[dict[str, str]], top_k: int, min_score: float) -> list[RetrievedDoc]: @@ -72,19 +102,37 @@ def _lexical_search(question: str, docs: list[dict[str, str]], top_k: int, min_s return [] matches: list[RetrievedDoc] = [] + key_query_tokens = {token for token in query_tokens if token not in _STOPWORDS} + key_query_size = max(1, len(key_query_tokens)) for doc in docs: - corpus_text = f"{doc['title']} {doc['body']}" - doc_tokens = _tokenize(corpus_text) - if not doc_tokens: + title_tokens = _tokenize(doc["title"]) + body_tokens = _tokenize(doc["body"]) + doc_tokens = title_tokens | body_tokens + if not doc_tokens or not body_tokens: continue overlap = len(query_tokens & doc_tokens) if overlap == 0: continue - coverage = overlap / len(query_tokens) - density = overlap / max(1, len(doc_tokens)) - score = min(1.0, (0.85 * coverage) + (0.15 * density * 10)) + # Prioritize lexical coverage in title to keep obvious intent matches on top + # in offline fallback mode (e.g., "checking account" should rank checking docs). + title_overlap = len(query_tokens & title_tokens) + body_overlap = len(query_tokens & body_tokens) + title_coverage = title_overlap / len(query_tokens) + body_coverage = body_overlap / len(query_tokens) + token_density = overlap / max(1, len(doc_tokens)) + key_title_overlap = len(key_query_tokens & title_tokens) / key_query_size + key_body_overlap = len(key_query_tokens & body_tokens) / key_query_size + + score = min( + 1.0, + (0.42 * body_coverage) + + (0.30 * title_coverage) + + (0.18 * key_title_overlap) + + (0.10 * key_body_overlap) + + (0.05 * token_density * 10), + ) if score < min_score: continue diff --git a/constitution.md b/constitution.md new file mode 100644 index 00000000..c17f31fb --- /dev/null +++ b/constitution.md @@ -0,0 +1,109 @@ +# Project Constitution — Customer FAQ Assistant (Mockridge Bank) + +## 1) Purpose and Scope +This constitution defines non-negotiable engineering intent for this repository. +It is the decision baseline for humans and AI contributors. + +Source of truth order: +1. `SPECS/` (feature behavior and acceptance criteria) +2. `tests/` (executable verification) +3. `constitution.md` (engineering principles and quality gates) +4. implementation code + +If implementation changes behavior, specs/tests must be updated in the same change set. + +## 2) Product and Runtime Constraints +- Local-first system. No paid APIs or secrets required for default workflows. +- Python support range for project setup/runtime: `>=3.10`, `<3.13` (3.10/3.11/3.12). +- Default test workflow must run without network dependency on external hosted services. +- Optional LLM mode is allowed but must remain opt-in and non-blocking for tests. + +## 3) Core Architectural Intent + +### 3.1 Simplicity Over Cleverness +- Prefer direct, readable code paths over abstraction layers. +- Introduce abstraction only when at least two concrete use cases require it. +- Minimize hidden behavior and magic configuration. + +### 3.2 Modularity and Boundaries +- API layer (`app/main.py`) handles HTTP contracts and validation flow. +- Retrieval layer (`app/retrieval.py`) owns indexing/query logic and score filtering. +- Generation layer (`app/generation.py`, `app/rag_chain.py`) owns answer production. +- UI layer (`ui/streamlit_app.py`) owns presentation and user interaction only. +- Do not mix UI concerns into API/retrieval/generation modules. + +### 3.3 Determinism by Default +- Default generation mode must remain deterministic (`mock`). +- Retrieval must be deterministic for identical corpus and inputs. +- Optional stochastic behavior must be explicitly opt-in. + +### 3.4 Observability and Operability +- Every major workflow must be runnable from `run.py`. +- Required commands include setup, test, and matrix testing: + - `python run.py setup` + - `python run.py test` + - `python run.py test-matrix` +- Runtime errors must be actionable and user-readable. + +## 4) Dependency and Integration Policy +- Prefer stable, mainstream libraries with clear maintenance. +- LangChain integration is allowed for orchestration; local Chroma remains required for persistence. +- New dependencies must have a clear justification in PR description. +- Avoid adding dependencies for trivial utility behavior. +- Tooling dependencies (e.g., `tox`, `nox`) are development concerns and should not be required for production runtime. + +## 5) Testing and Quality Standards + +### 5.1 Test Expectations +- Any behavior change must include or update tests. +- API contract fields and error semantics are backward-compatible unless a spec explicitly changes. +- New features should include at least one integration-path verification. + +### 5.2 Quality Gates +- Must pass before merge: + - `python -m pytest -q` (or `python run.py test`) + - `python run.py test-matrix` for available local interpreters +- For behavior-affecting changes: + - relevant spec files in `SPECS/` updated + - traceability preserved (spec -> tests -> implementation) + +### 5.3 Error Handling +- Fail clearly with actionable messages. +- Avoid silent fallthroughs that hide failures. +- Keep fallback behavior explicit and deterministic when used. + +## 6) Naming and Code Conventions +- Use clear, domain-oriented names (`retrieve`, `build_db`, `get_db_status`). +- Avoid ambiguous abbreviations in public interfaces. +- Keep module responsibilities single-purpose and explicit. + +## 7) Change Management (Living Document) +- This file is version-controlled and must evolve with architectural decisions. +- Update this constitution when: + - supported Python range changes + - core architectural boundaries shift + - quality gates or required tooling change +- Constitution updates should explain intent, not low-level implementation detail. + +## 8) Collaboration Model +- Architecture decisions should be reviewable in writing (PR description/spec updates). +- Major direction changes should be agreed by project stakeholders (engineering + product owner). +- Do not rely on private chat context as the only rationale for repository-wide decisions. + +## 9) Validation Checklists + +### 9.1 Before Implementation +- [ ] Relevant spec exists or is updated +- [ ] Module boundary for change is clear +- [ ] Dependency impact is understood + +### 9.2 Before Merge +- [ ] Tests pass locally (`pytest`) +- [ ] Matrix check run (`test-matrix`) for available interpreters +- [ ] Specs updated for behavior changes +- [ ] Error messages are actionable +- [ ] No secrets or external paid API requirements introduced + +### 9.3 After Merge (if applicable) +- [ ] Follow-up docs updated (`constitution.md`, `SPECS/`, project brief files) +- [ ] Any deferred risks tracked explicitly diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..2b6d94ef --- /dev/null +++ b/noxfile.py @@ -0,0 +1,7 @@ +import nox + + +@nox.session(python=["3.11", "3.12"]) +def tests(session: nox.Session) -> None: + session.install("-r", "requirements.txt") + session.run("python", "-m", "pytest", "-q") diff --git a/run.py b/run.py index 1203a4af..00dfd3e7 100644 --- a/run.py +++ b/run.py @@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent VENV_DIR = PROJECT_ROOT / ".venv" SUPPORTED_MIN = (3, 10) -SUPPORTED_MAX_EXCLUSIVE = (3, 12) +SUPPORTED_MAX_EXCLUSIVE = (3, 13) def _venv_python() -> str: @@ -60,12 +60,12 @@ def _candidate_python_commands() -> list[list[str]]: candidates: list[list[str]] = [] if sys.platform.startswith("win"): if shutil.which("py"): - candidates.extend([["py", "-3.11"], ["py", "-3.10"]]) + candidates.extend([["py", "-3.12"], ["py", "-3.11"], ["py", "-3.10"]]) if shutil.which("python"): candidates.append(["python"]) return candidates - for command in ["python3.11", "python3.10", "python3", "python"]: + for command in ["python3.12", "python3.11", "python3.10", "python3", "python"]: if shutil.which(command): candidates.append([command]) return candidates @@ -81,7 +81,7 @@ def _select_supported_python_command(override: str | None = None) -> list[str] | if not _is_supported_version(version): print( "Unsupported Python override version: " - f"{_version_text(version)}. Supported range is >=3.10 and <3.12." + f"{_version_text(version)}. Supported range is >=3.10 and <3.13." ) return None return override_cmd @@ -95,19 +95,33 @@ def _select_supported_python_command(override: str | None = None) -> list[str] | def _attempt_python_install() -> int: - print("Attempting to install Python 3.11 with an available package manager...") + print("Attempting to install Python 3.12 with an available package manager...") installers: list[list[str]] = [] if sys.platform.startswith("win"): if shutil.which("winget"): - installers.append(["winget", "install", "-e", "--id", "Python.Python.3.11"]) + installers.append(["winget", "install", "-e", "--id", "Python.Python.3.12"]) if shutil.which("choco"): - installers.append(["choco", "install", "python311", "-y"]) + installers.append(["choco", "install", "python312", "-y"]) elif sys.platform == "darwin": if shutil.which("brew"): - installers.append(["brew", "install", "python@3.11"]) + installers.append(["brew", "install", "python@3.12"]) else: if shutil.which("apt-get"): + installers.append(["apt-get", "update"]) + installers.append(["apt-get", "install", "-y", "python3.12", "python3.12-venv"]) + elif shutil.which("dnf"): + installers.append(["dnf", "install", "-y", "python3.12"]) + elif shutil.which("yum"): + installers.append(["yum", "install", "-y", "python3.12"]) + + # Fallback attempts for package managers that expose only 3.11 packages. + if not installers: + if sys.platform.startswith("win") and shutil.which("choco"): + installers.append(["choco", "install", "python311", "-y"]) + elif sys.platform == "darwin" and shutil.which("brew"): + installers.append(["brew", "install", "python@3.11"]) + elif shutil.which("apt-get"): installers.append(["apt-get", "update"]) installers.append(["apt-get", "install", "-y", "python3.11", "python3.11-venv"]) elif shutil.which("dnf"): @@ -180,8 +194,8 @@ def cmd_setup(args: list[str]) -> int: if selected_python is None: print( "No supported Python interpreter found for venv creation.\n" - "Supported range is >=3.10 and <3.12.\n" - "Install Python 3.10 or 3.11, then rerun setup.\n" + "Supported range is >=3.10 and <3.13.\n" + "Install Python 3.10, 3.11, or 3.12, then rerun setup.\n" "Optional: run 'python run.py setup --install-python' to attempt automated install." ) return 2 @@ -192,7 +206,7 @@ def cmd_setup(args: list[str]) -> int: print( "Warning: Current Python " f"{_version_text((current_version[0], current_version[1]))} is outside the recommended range " - "(>=3.10 and <3.12). Some dependencies may fail." + "(>=3.10 and <3.13). Some dependencies may fail." ) python_bin = sys.executable @@ -239,6 +253,36 @@ def cmd_test(args: list[str]) -> int: return _run([python_bin, "-m", "pytest", "-q"]) +def cmd_test_matrix(args: list[str]) -> int: + if "--help" in args: + print("Usage: python run.py test-matrix [-- ]") + return 0 + + python_bin = _venv_python() if VENV_DIR.exists() else sys.executable + if os.getenv("PYTEST_CURRENT_TEST"): + print("Detected pytest context; skipping nested tox execution.") + return 0 + + has_tox_in_python = _run( + [python_bin, "-c", "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('tox') else 1)"] + ) == 0 + + print("Running tox test matrix") + if has_tox_in_python: + return _run([python_bin, "-m", "tox", *args]) + + if shutil.which("tox"): + return _run(["tox", *args]) + + print( + "tox is not installed in the active environment.\n" + "Install it with one of:\n" + f" {python_bin} -m pip install tox\n" + " python -m pip install tox" + ) + return 2 + + def cmd_fullstack(args: list[str]) -> int: if "--help" in args: print("Usage: python run.py fullstack") @@ -328,6 +372,7 @@ def show_help() -> int: python run.py ui Start Streamlit UI python run.py fullstack Start API + UI together python run.py test Run pytest + python run.py test-matrix Run tox matrix (py310/py311/py312) python run.py help Show this help Optional Docker helpers: @@ -355,6 +400,7 @@ def main() -> int: "ui": cmd_ui, "fullstack": cmd_fullstack, "test": cmd_test, + "test-matrix": cmd_test_matrix, "help": lambda _args: show_help(), "docker-build": cmd_docker_build, "docker-api": cmd_docker_api, diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..f4641e2f --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +envlist = py310, py311, py312 +skip_missing_interpreters = true +isolated_build = false + +[testenv] +description = Run pytest for {envname} +deps = + -rrequirements.txt +commands = + python -m pytest -q From 9e874931d28ea5e095a38b073619efde7c22bb9e Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 09:36:11 -0500 Subject: [PATCH 12/20] feat: Enhance retrieval database setup with build status reporting and implement CI workflow with github actions docs: Updating project constitution on guidelines for development --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ app/retrieval.py | 14 +++++++++++++ constitution.md | 2 +- run.py | 20 ++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c98c1552 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + - feature/customer-faq-assistant-cameron-d + pull_request: + branches: + - main + - feature/customer-faq-assistant-cameron-d + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-matrix: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run tests + run: python -m pytest -q diff --git a/app/retrieval.py b/app/retrieval.py index c1197192..4cdaa979 100644 --- a/app/retrieval.py +++ b/app/retrieval.py @@ -48,6 +48,10 @@ } +def _report_build_status(message: str) -> None: + print(f"[db-build] {message}", flush=True) + + @dataclass class RetrievedDoc: id: str @@ -291,10 +295,13 @@ def get_db_status() -> dict: def build_db() -> dict: global _LEXICAL_FALLBACK_READY + _report_build_status("Loading FAQ documents") docs = load_faq_docs() + _report_build_status(f"Loaded {len(docs)} documents") client = _get_client() # Full rebuild avoids stale embeddings when FAQ text changes but IDs stay the same. + _report_build_status("Resetting existing collection") try: client.delete_collection(COLLECTION_NAME) except Exception: @@ -304,9 +311,11 @@ def build_db() -> dict: _get_langchain_vectorstore.cache_clear() try: + _report_build_status("Initializing collection") collection = _get_collection() if docs: if _use_langchain_retrieval(): + _report_build_status("Indexing with LangChain Chroma embeddings") vectorstore = _get_langchain_vectorstore() vectorstore.add_texts( texts=[doc["body"] for doc in docs], @@ -314,6 +323,7 @@ def build_db() -> dict: metadatas=[{"title": doc["title"], "id": doc["id"]} for doc in docs], ) else: + _report_build_status("Indexing with SentenceTransformer embeddings") model = _get_legacy_model() texts = [f"{doc['title']}\n{doc['body']}" for doc in docs] embeddings = model.encode(texts, normalize_embeddings=True).tolist() @@ -327,6 +337,7 @@ def build_db() -> dict: after = collection.count() built = after == len(docs) and len(docs) > 0 _LEXICAL_FALLBACK_READY = False + _report_build_status(f"Completed: indexed={after}, built={built}") return { "built": built, "doc_count": len(docs), @@ -337,6 +348,9 @@ def build_db() -> dict: except Exception: # Offline-safe fallback for environments without local embedding assets. _LEXICAL_FALLBACK_READY = len(docs) > 0 + _report_build_status( + "Embedding index unavailable; using deterministic lexical fallback mode" + ) return { "built": _LEXICAL_FALLBACK_READY, "doc_count": len(docs), diff --git a/constitution.md b/constitution.md index c17f31fb..758099c8 100644 --- a/constitution.md +++ b/constitution.md @@ -1,7 +1,7 @@ # Project Constitution — Customer FAQ Assistant (Mockridge Bank) ## 1) Purpose and Scope -This constitution defines non-negotiable engineering intent for this repository. +This constitution defines the engineering intent for this repository. It is the decision baseline for humans and AI contributors. Source of truth order: diff --git a/run.py b/run.py index 00dfd3e7..bf116229 100644 --- a/run.py +++ b/run.py @@ -167,6 +167,21 @@ def _download_llm_assets(python_bin: str) -> int: ) +def _build_retrieval_db(python_bin: str) -> int: + print("Building retrieval database...") + return _run( + [ + python_bin, + "-c", + ( + "import json; " + "from app.retrieval import build_db; " + "print(json.dumps(build_db()))" + ), + ] + ) + + def cmd_setup(args: list[str]) -> int: if "--help" in args: print("Usage: python run.py setup [--no-venv] [--with-llm] [--python ] [--install-python]") @@ -215,6 +230,11 @@ def cmd_setup(args: list[str]) -> int: if result != 0: return result + result = _build_retrieval_db(python_bin) + if result != 0: + print("Failed to build retrieval database during setup.") + return result + if "--with-llm" in args: return _download_llm_assets(python_bin) From c7c404ac1778054b90fb450b8eb37c4b4e0db76f Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 09:49:06 -0500 Subject: [PATCH 13/20] feat: Update CI workflow to include tox installation and modify test command docs: enhance README and entrypoint CLI documentation --- .github/workflows/ci.yml | 3 +- README.md | 176 +++++++++++++++++++++++++++++---------- SPECS/entrypoint-cli.md | 1 + app/retrieval.py | 5 +- constitution.md | 3 +- 5 files changed, 139 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98c1552..8524ec4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt + python -m pip install tox - name: Run tests - run: python -m pytest -q + run: python run.py test-matrix diff --git a/README.md b/README.md index 494f1c75..98924f7a 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,133 @@ -# Candidate Assessment: Spec-Driven Development With Codegen Tools - -This assessment evaluates how you use modern code generation tools (for example `5.2-Codex`, `Claude`, `Copilot`, and similar) to design, build, and test a software application using a spec-driven development pattern. You may build a frontend, a backend, or both. - -## Goals -- Build a working application with at least one meaningful feature. -- Create a testing framework to validate the application. -- Demonstrate effective use of code generation tools to accelerate delivery. -- Show clear, maintainable engineering practices. - -## Deliverables -- Application source code in this repository. -- A test suite and test harness that can be run locally. -- Documentation that explains how to run the app and the tests. - -## Scope Options -Pick one: -- Frontend-only application. -- Backend-only application. -- Full-stack application. - -Your solution should include at least one real workflow, for example: -- Create and view a resource. -- Search or filter data. -- Persist data in memory or storage. - -## Rules -- You must use a code generation tool (for example `5.2-Codex`, `Claude`, or similar). You can use multiple tools. -- You must build the application and a testing framework for it. -- The application and tests must run locally. -- Do not include secrets or credentials in this repository. - -## Evaluation Criteria -- Working product: Does the app do what it claims? -- Test coverage: Do tests cover key workflows and edge cases? -- Engineering quality: Clarity, structure, and maintainability. -- Use of codegen: How effectively you used tools to accelerate work. -- Documentation: Clear setup and run instructions. - -## What to Submit -- When you are complete, put up a Pull Request against this repository with your changes. -- A short summary of your approach and tools used in your PR submission -- Any additional information or approach that helped you. +# Customer FAQ Assistant (Mockridge Bank) + +[![CI](https://github.com/CameronDetig/spec-driven-development/actions/workflows/ci.yml/badge.svg?branch=feature/customer-faq-assistant-cameron-d)](https://github.com/CameronDetig/spec-driven-development/actions/workflows/ci.yml) +![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue) + +Spec-driven local RAG assistant built with FastAPI + Streamlit. + +## What It Does +- Accepts customer FAQ questions through API or UI +- Retrieves relevant local FAQ documents from ChromaDB +- Generates answers with: + - deterministic `mock` mode (default, test-friendly) + - optional `distilgpt2` mode +- Returns answer + cited sources + retrieval metadata + +## Tech Stack +- Python 3.10, 3.11, or 3.12 +- FastAPI +- Streamlit +- ChromaDB +- LangChain (`langchain`, `langchain-huggingface`, `langchain-chroma`) +- pytest + +## Quick Start + +### 1) Setup +```bash +python run.py setup +``` + +What setup does: +- creates/uses `.venv` (unless `--no-venv`) +- installs dependencies +- builds retrieval DB + +Optional LLM assets: +```bash +python run.py setup --with-llm +``` + +### 2) Run Full Stack +```bash +python run.py fullstack +``` + +Endpoints: +- API: `http://127.0.0.1:8000` +- UI: `http://127.0.0.1:8501` + +## Run Commands + +```bash +python run.py help +python run.py api +python run.py ui +python run.py fullstack +python run.py test +python run.py test-matrix +``` + +`test-matrix` runs tox environments for available interpreters (`py310`, `py311`, `py312`). + +## Testing + +Single environment: +```bash +python run.py test +``` + +Multi-python matrix: +```bash +python run.py test-matrix +``` + +Direct: +```bash +python -m pytest -q +tox +``` + +## CI (GitHub Actions) + +Workflow: `.github/workflows/ci.yml` + +Triggers: +- push to: + - `main` + - `feature/customer-faq-assistant-cameron-d` +- pull_request to: + - `main` + - `feature/customer-faq-assistant-cameron-d` +- manual (`workflow_dispatch`) + +CI job: +- Python matrix: 3.10 / 3.11 / 3.12 +- installs `requirements.txt` +- runs `pytest -q` + +## API Overview + +- `GET /health` +- `GET /db/status` +- `POST /db/build` +- `POST /ask` + +Example request: +```json +{ + "question": "What can I do with the mobile app?", + "top_k": 3, + "generator": "mock" +} +``` + +## Environment Variables + +- `RAG_MIN_SCORE` (default `0.25`) +- `API_URL` (used by Streamlit UI; default `http://127.0.0.1:8000`) + +## Project Structure + +- `app/main.py` API routes +- `app/retrieval.py` retrieval/indexing logic +- `app/generation.py` generator selection and LLM adapter +- `app/rag_chain.py` LangChain prompt/chain +- `ui/streamlit_app.py` UI +- `SPECS/` authoritative feature specs +- `tests/` test suite +- `run.py` project entrypoint + +## Notes +- Default mode is deterministic and intended for local testing/CI. +- Optional `distilgpt2` mode is for local experimentation and may require model assets. diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index cb23b141..36e78d50 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -40,6 +40,7 @@ - `run.py setup` SHOULD support optional automated Python installation via `--install-python` and fail clearly if package-manager installation is unavailable. - `fullstack` command MUST start API and UI on their default ports and shut down cleanly on Ctrl+C. - Commands MUST print clear status messages and fail clearly with actionable errors. +- `test` and `test-matrix` commands SHOULD be suitable for GitHub Actions CI execution without interactive prompts. - Docker helper commands MUST fail clearly when Docker is unavailable and MUST keep local non-Docker commands fully usable. ## Acceptance Criteria diff --git a/app/retrieval.py b/app/retrieval.py index 4cdaa979..75791b2c 100644 --- a/app/retrieval.py +++ b/app/retrieval.py @@ -368,9 +368,6 @@ def retrieve(question: str, top_k: int) -> list[RetrievedDoc]: return [] min_score = get_min_score() - if _LEXICAL_FALLBACK_READY: - return _lexical_search(question=question, docs=docs, top_k=top_k, min_score=min_score) - collection = _get_collection() try: _ensure_indexed(collection, docs) @@ -417,7 +414,7 @@ def retrieve(question: str, top_k: int) -> list[RetrievedDoc]: ) matches.sort(key=lambda item: item.score, reverse=True) - _LEXICAL_FALLBACK_READY = False + _LEXICAL_FALLBACK_READY = False # clear fallback once embedding path succeeds return matches[:top_k] except Exception: _LEXICAL_FALLBACK_READY = True diff --git a/constitution.md b/constitution.md index 758099c8..59051637 100644 --- a/constitution.md +++ b/constitution.md @@ -63,6 +63,7 @@ If implementation changes behavior, specs/tests must be updated in the same chan - Must pass before merge: - `python -m pytest -q` (or `python run.py test`) - `python run.py test-matrix` for available local interpreters + - GitHub Actions CI workflow (Python 3.10/3.11/3.12) on the target branch/PR - For behavior-affecting changes: - relevant spec files in `SPECS/` updated - traceability preserved (spec -> tests -> implementation) @@ -105,5 +106,5 @@ If implementation changes behavior, specs/tests must be updated in the same chan - [ ] No secrets or external paid API requirements introduced ### 9.3 After Merge (if applicable) -- [ ] Follow-up docs updated (`constitution.md`, `SPECS/`, project brief files) +- [ ] Follow-up docs updated (`constitution.md`, `SPECS/`, `README.md`) - [ ] Any deferred risks tracked explicitly From fc105a6a0320d3c7279305938b42ae3ad1221b71 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 09:58:09 -0500 Subject: [PATCH 14/20] fix: correcting error in workflow file --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8524ec4b..7b73cc9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,4 +43,4 @@ jobs: python -m pip install tox - name: Run tests - run: python run.py test-matrix + run: python -m tox -e py${{ replace(matrix.python-version, '.', '') }} From 77a3e9eade6850d4d58bbbc245c574a148bea785 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 10:00:18 -0500 Subject: [PATCH 15/20] fix: correcting another error in workflow file --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b73cc9f..7ad05634 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,4 +43,6 @@ jobs: python -m pip install tox - name: Run tests - run: python -m tox -e py${{ replace(matrix.python-version, '.', '') }} + env: + PY_VER: ${{ matrix.python-version }} + run: python -m tox -e py${PY_VER//./} From cf62bd577e02b1c74b9c6b1bc20ce5b3986c0562 Mon Sep 17 00:00:00 2001 From: CameronDetig Date: Sun, 8 Feb 2026 10:11:11 -0500 Subject: [PATCH 16/20] fix: Added CPU torch index to unblock py310 installs in CI --- .github/workflows/ci.yml | 2 ++ tox.ini | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ad05634..a7c8946a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 25 + env: + PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu strategy: fail-fast: false matrix: diff --git a/tox.ini b/tox.ini index f4641e2f..b0e85329 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,8 @@ isolated_build = false [testenv] description = Run pytest for {envname} +setenv = + PIP_EXTRA_INDEX_URL = https://download.pytorch.org/whl/cpu deps = -rrequirements.txt commands = From 5bdf3e308b25bc7ba0f0dfed34fb9aa9ebfdb89d Mon Sep 17 00:00:00 2001 From: Cameron Detig Date: Sun, 8 Feb 2026 10:53:49 -0500 Subject: [PATCH 17/20] feat: Add embedding model download and verification during setup; improve distilgpt2 output quality checks in generation --- app/generation.py | 64 +++++++++++++++++++++++++++++++++++++++++--- app/rag_chain.py | 2 +- app/retrieval.py | 14 +++++----- run.py | 65 ++++++++++++++++++++++++++++++++++++++++++--- ui/streamlit_app.py | 64 ++++++++++++++++++++++++-------------------- 5 files changed, 164 insertions(+), 45 deletions(-) diff --git a/app/generation.py b/app/generation.py index d0e355ce..ee48e7ec 100644 --- a/app/generation.py +++ b/app/generation.py @@ -48,6 +48,7 @@ def _ensure_model(self) -> None: do_sample=False, num_return_sequences=1, pad_token_id=50256, + eos_token_id=50256, return_full_text=False, ) if _use_langchain_generation(): @@ -74,13 +75,62 @@ def _generate_with_langchain(self, question: str, sources: list[dict]) -> str: ) for source in sources[:3] ] - return str(generate_rag_answer(question=question, documents=documents, llm=self._llm)).strip() + raw_text = str(generate_rag_answer(question=question, documents=documents, llm=self._llm)).strip() + + # Clean up common repetition patterns from distilgpt2 + if raw_text: + # Split on newlines and take only the first substantial line + first_line = raw_text.split('\n')[0].strip() + # Remove repeated "Answer:" patterns + while "Answer:" in first_line: + first_line = first_line.replace("Answer:", "").strip() + + # Quality check: distilgpt2 often produces garbage output + if self._is_low_quality_output(first_line): + return "" + + return first_line + return "" + + def _is_low_quality_output(self, text: str) -> bool: + """Detect when distilgpt2 produces nonsensical output.""" + if not text or len(text) < 10: + return True + + # Check if output is mostly punctuation or special characters + alpha_chars = sum(c.isalpha() for c in text) + if alpha_chars < len(text) * 0.5: # Less than 50% letters + return True + + # Check for repetitive patterns (same word repeated 3+ times) + words = text.lower().split() + if len(words) > 2: + for i in range(len(words) - 2): + if words[i] == words[i + 1] == words[i + 2]: + return True + + return False def _generate_legacy(self, question: str, sources: list[dict]) -> str: context = " ".join(source.get("snippet", "") for source in sources[:2]) - prompt = f"Question: {question}\nContext: {context}\nAnswer:" + prompt = f"Question: {question}\nContext: {context}\nAnswer: " outputs = self._generator(prompt) - return str(outputs[0].get("generated_text", "")).strip() if outputs else "" + raw_text = str(outputs[0].get("generated_text", "")).strip() if outputs else "" + + # Clean up common repetition patterns from distilgpt2 + if raw_text: + # Split on newlines and take only the first sentence/line + first_line = raw_text.split('\n')[0].strip() + # Remove repeated "Answer:" patterns + while "Answer:" in first_line: + first_line = first_line.replace("Answer:", "").strip() + + # Quality check: distilgpt2 often produces garbage output + if self._is_low_quality_output(first_line): + return "" + + return first_line + return "" def generate(self, question: str, sources: list[dict]) -> str: self._ensure_model() @@ -94,7 +144,13 @@ def generate(self, question: str, sources: list[dict]) -> str: else self._generate_legacy(question=question, sources=sources) ) if not text: - return FALLBACK_ANSWER + # distilgpt2 often produces low-quality output; fall back with explanation + titles = ", ".join(source["title"] for source in sources[:2]) + return ( + f"Based on Mockridge Bank FAQ ({titles}), see sources below. " + f"(Note: distilgpt2 generation was unreliable for this query. " + f"Try 'mock' generator for consistent results.)" + ) return text diff --git a/app/rag_chain.py b/app/rag_chain.py index bb7d2e0e..42239f40 100644 --- a/app/rag_chain.py +++ b/app/rag_chain.py @@ -10,7 +10,7 @@ "If the context is insufficient, say so briefly.\n\n" "Question: {question}\n" "Context:\n{context}\n\n" - "Answer:" + "Answer: " ) ) diff --git a/app/retrieval.py b/app/retrieval.py index 75791b2c..dacbea17 100644 --- a/app/retrieval.py +++ b/app/retrieval.py @@ -224,8 +224,8 @@ def _get_langchain_vectorstore() -> Any: from langchain_chroma import Chroma return Chroma( + client=_get_client(), collection_name=COLLECTION_NAME, - persist_directory=str(CHROMA_DIR), embedding_function=_get_langchain_embeddings(), collection_metadata={"hnsw:space": "cosine"}, ) @@ -298,15 +298,13 @@ def build_db() -> dict: _report_build_status("Loading FAQ documents") docs = load_faq_docs() _report_build_status(f"Loaded {len(docs)} documents") - client = _get_client() - # Full rebuild avoids stale embeddings when FAQ text changes but IDs stay the same. + # Full rebuild: reset the database to remove all collections and stale + # index files, then recreate from scratch. allow_reset=True is set + # in the client settings to permit this. _report_build_status("Resetting existing collection") - try: - client.delete_collection(COLLECTION_NAME) - except Exception: - pass - + client = _get_client() + client.reset() _get_collection.cache_clear() _get_langchain_vectorstore.cache_clear() diff --git a/run.py b/run.py index bf116229..bc06dc94 100644 --- a/run.py +++ b/run.py @@ -19,8 +19,8 @@ def _venv_python() -> str: return str(VENV_DIR / "bin" / "python") -def _run(cmd: list[str], cwd: Path | None = None, check: bool = False) -> int: - proc = subprocess.run(cmd, cwd=cwd or PROJECT_ROOT) +def _run(cmd: list[str], cwd: Path | None = None, check: bool = False, env: dict | None = None) -> int: + proc = subprocess.run(cmd, cwd=cwd or PROJECT_ROOT, env=env) if check and proc.returncode != 0: raise SystemExit(proc.returncode) return proc.returncode @@ -153,17 +153,71 @@ def _has_docker() -> bool: return shutil.which("docker") is not None +def _download_embedding_model(python_bin: str) -> int: + print("Downloading embedding model (all-MiniLM-L6-v2)...") + + # Suppress harmless HuggingFace warnings (symlink cache on Windows, hf_xet). + hf_env = {**os.environ, "HF_HUB_DISABLE_SYMLINKS_WARNING": "1"} + + # First, download with network access + result = _run( + [ + python_bin, + "-c", + ( + "import warnings; warnings.filterwarnings('ignore'); " + "from langchain_huggingface import HuggingFaceEmbeddings; " + "print('Downloading model...'); " + "embeddings = HuggingFaceEmbeddings(" + "model_name='sentence-transformers/all-MiniLM-L6-v2', " + "model_kwargs={'local_files_only': False}" + "); " + "embeddings.embed_query('test'); " + "print('Download complete')" + ), + ], + env=hf_env, + ) + + if result != 0: + return result + + # Verify it can be loaded with local_files_only=True (same as retrieval code) + print("Verifying model is cached for offline use...") + return _run( + [ + python_bin, + "-c", + ( + "import warnings; warnings.filterwarnings('ignore'); " + "from langchain_huggingface import HuggingFaceEmbeddings; " + "embeddings = HuggingFaceEmbeddings(" + "model_name='sentence-transformers/all-MiniLM-L6-v2', " + "model_kwargs={'local_files_only': True}, " + "encode_kwargs={'normalize_embeddings': True}" + "); " + "embeddings.embed_query('verification test'); " + "print('Embedding model verified and ready for offline use')" + ), + ], + env=hf_env, + ) + + def _download_llm_assets(python_bin: str) -> int: print("Downloading distilgpt2 model assets...") + hf_env = {**os.environ, "HF_HUB_DISABLE_SYMLINKS_WARNING": "1"} return _run( [ python_bin, "-c", ( + "import warnings; warnings.filterwarnings('ignore'); " "from transformers import pipeline; " "pipeline('text-generation', model='distilgpt2', tokenizer='distilgpt2')" ), - ] + ], + env=hf_env, ) @@ -230,6 +284,11 @@ def cmd_setup(args: list[str]) -> int: if result != 0: return result + result = _download_embedding_model(python_bin) + if result != 0: + print("Failed to download embedding model during setup.") + return result + result = _build_retrieval_db(python_bin) if result != 0: print("Failed to build retrieval database during setup.") diff --git a/ui/streamlit_app.py b/ui/streamlit_app.py index 8115da14..ecd9822b 100644 --- a/ui/streamlit_app.py +++ b/ui/streamlit_app.py @@ -123,14 +123,16 @@ def main() -> None: if role == "assistant": retrieval = msg.get("retrieval") sources = msg.get("sources", []) - if retrieval: - with st.expander("Retrieval Details"): - st.write(retrieval) - if sources: + if sources or retrieval: with st.expander("Sources"): - for source in sources: - st.markdown(f"**{source['title']}** (score: {source['score']})") - st.write(source["snippet"]) + if sources: + for source in sources: + st.markdown(f"**{source['title']}** (score: {source['score']})") + st.write(source["snippet"]) + if retrieval: + st.divider() + st.caption("Retrieval Details") + st.write(retrieval) controls_disabled = (not built) or (st.session_state.pending_submission is not None) examples = [ @@ -174,6 +176,9 @@ def main() -> None: with button_col_2: submit_clicked = st.button("Submit", key="submit_btn", disabled=controls_disabled) + # Processing status placeholder appears between submit buttons and examples + processing_placeholder = st.empty() + st.caption("Try an example:") chip_cols = st.columns(3) for idx, example in enumerate(examples): @@ -190,7 +195,7 @@ def main() -> None: ["mock", "distilgpt2"], index=0, disabled=controls_disabled, - help="Mock is deterministic, intended for testing. distilgpt2 is an actual LLM that first needs to be installed via 'run.py setup --with-llm'", + help="Mock is deterministic and reliable (recommended). distilgpt2 is a small experimental LLM that often produces low-quality or nonsensical answers. Install via 'run.py setup --with-llm'", ) if submit_clicked: @@ -211,27 +216,28 @@ def main() -> None: pending_submission = st.session_state.pending_submission if pending_submission is not None: try: - with st.spinner("Processing your question..."): - response = requests.post(f"{API_URL}/ask", json=pending_submission, timeout=90) - if response.status_code != 200: - detail = response.json().get("detail", "Unknown error") - if pending_submission["generator"] == "distilgpt2" and "setup --with-llm" in detail: - st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") - if response.status_code == 503 and "Database not built" in detail: - st.warning("Database is not built. Use the Build DB button above.") - st.error(f"Request failed ({response.status_code}): {detail}") - st.session_state.pending_submission = None - return - - body = response.json() - st.session_state.chat_messages.append( - { - "role": "assistant", - "content": body["answer"], - "retrieval": body.get("retrieval"), - "sources": body.get("sources", []), - } - ) + with processing_placeholder.container(): + with st.spinner("Processing your question..."): + response = requests.post(f"{API_URL}/ask", json=pending_submission, timeout=90) + if response.status_code != 200: + detail = response.json().get("detail", "Unknown error") + if pending_submission["generator"] == "distilgpt2" and "setup --with-llm" in detail: + st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") + if response.status_code == 503 and "Database not built" in detail: + st.warning("Database is not built. Use the Build DB button above.") + st.error(f"Request failed ({response.status_code}): {detail}") + st.session_state.pending_submission = None + return + + body = response.json() + st.session_state.chat_messages.append( + { + "role": "assistant", + "content": body["answer"], + "retrieval": body.get("retrieval"), + "sources": body.get("sources", []), + } + ) st.session_state.pending_submission = None st.rerun() except requests.RequestException as exc: From d0dc29035c7a0c1ef3db1ccbc188f8f9a549892f Mon Sep 17 00:00:00 2001 From: Cameron Detig Date: Sun, 8 Feb 2026 11:07:37 -0500 Subject: [PATCH 18/20] feat: migrating from distilgpt2 to flan-t5 which is instruction tuned for better chat responses --- README.md | 4 +- SPECS/ask-endpoint-validation.md | 2 +- SPECS/entrypoint-cli.md | 2 +- SPECS/generation-optional-llm.md | 12 +++--- SPECS/streamlit-ui.md | 6 +-- app/generation.py | 63 +++++++++++++++----------------- app/main.py | 6 +-- app/rag_chain.py | 8 ++-- pytest.ini | 3 ++ run.py | 4 +- tests/test_generator_config.py | 10 ++--- tests/test_validation.py | 2 +- ui/streamlit_app.py | 6 +-- 13 files changed, 62 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 98924f7a..7a5c8cf8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Spec-driven local RAG assistant built with FastAPI + Streamlit. - Retrieves relevant local FAQ documents from ChromaDB - Generates answers with: - deterministic `mock` mode (default, test-friendly) - - optional `distilgpt2` mode + - optional `flan-t5` mode (small instruction-tuned LLM) - Returns answer + cited sources + retrieval metadata ## Tech Stack @@ -130,4 +130,4 @@ Example request: ## Notes - Default mode is deterministic and intended for local testing/CI. -- Optional `distilgpt2` mode is for local experimentation and may require model assets. +- Optional `flan-t5` mode uses Google's Flan-T5-small (80M params) for local experimentation and requires model assets (~308MB download via `python run.py setup --with-llm`). diff --git a/SPECS/ask-endpoint-validation.md b/SPECS/ask-endpoint-validation.md index 4473b077..836ba1a2 100644 --- a/SPECS/ask-endpoint-validation.md +++ b/SPECS/ask-endpoint-validation.md @@ -17,7 +17,7 @@ - Request schema: - `question` is required string with length `5..300`. - `top_k` is optional integer with default `3` and valid range `1..5`. - - `generator` is optional string with allowed values `mock` or `distilgpt2`. + - `generator` is optional string with allowed values `mock` or `flan-t5`. - Validation failures MUST return HTTP 400. - Validation failures include: - Missing `question`. diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index 36e78d50..6636676a 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -46,7 +46,7 @@ ## Acceptance Criteria - [ ] `python run.py help` prints usage and available commands. (test_cli.py - to be implemented) - [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) -- [ ] `python run.py setup --with-llm` downloads LLM assets for distilgpt2. (manual acceptance) +- [ ] `python run.py setup --with-llm` downloads LLM assets for flan-t5. (manual acceptance) - [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) - [ ] `python run.py setup` automatically selects a supported interpreter (`3.12`/`3.11`/`3.10`) for `.venv` creation when available. (manual acceptance) - [ ] `python run.py setup --python ` uses the specified interpreter when supported. (manual acceptance) diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index 394f4cb2..8491a590 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -1,11 +1,11 @@ -# Feature Spec: Optional DistilGPT2 Generation +# Feature Spec: Optional Flan-T5 Generation ## Goal - Enable an optional local LLM generation mode for runtime experimentation without affecting baseline determinism or test portability. ## Scope - In: - - Support `generator=distilgpt2` request option. + - Support `generator=flan-t5` request option. - Implement a separate generator path backed by Hugging Face `transformers`, with LangChain pipeline integration when available. - Document runtime behavior and first-run model download expectations. - Out: @@ -14,10 +14,10 @@ ## Requirements - LLM mode MUST be opt-in via request field: - - `generator=distilgpt2` + - `generator=flan-t5` - Default mode MUST remain `mock`. - LLM mode MUST NOT be required to start or test default application workflow. -- When LangChain Hugging Face integration is available, `distilgpt2` mode SHOULD execute through the LangChain adapter path. +- When LangChain Hugging Face integration is available, `flan-t5` mode SHOULD execute through the LangChain adapter path. - If LLM mode is selected and model assets are unavailable: - System MUST fail clearly with actionable local setup guidance. - UI MUST instruct user to run `python run.py setup --with-llm`. @@ -35,6 +35,6 @@ ## Acceptance Criteria - [x] With default environment, app uses mock generator. (test_generator_config.py::test_default_generator_mode_is_mock_deterministic) -- [x] With `generator=distilgpt2`, app routes generation through LLM adapter. (test_generator_config.py::test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable) -- [x] Test suite does not depend on `distilgpt2`. (conftest.py::default_generator_env) +- [x] With `generator=flan-t5`, app routes generation through LLM adapter. (test_generator_config.py::test_flan_t5_mode_is_opt_in_and_fails_clearly_when_unavailable) +- [x] Test suite does not depend on `flan-t5`. (conftest.py::default_generator_env) - [ ] Documentation explains optional setup and non-requirement for tests. diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index adf9bbef..77153fae 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -28,7 +28,7 @@ - Build DB action button that calls `POST /db/build`. - Question text input. - `top_k` control constrained to `1..5` with default `3`, displayed inline with generator selection. - - Generator selector with `mock` (default) and `distilgpt2` options, displayed inline with `top_k`. + - Generator selector with `mock` (default) and `flan-t5` options, displayed inline with `top_k`. - Three clickable example query buttons below the message input. - `Clear Chat` button and `Submit` button positioned directly below message input. - `Clear Chat` button visible only after at least one user message exists. @@ -41,7 +41,7 @@ - On fallback responses (`sources=[]`), UI MUST clearly indicate no matching sources were found. - On API validation errors (`400`), UI MUST show clear, non-crashing feedback to user. - If DB status is not built, question input and submit controls MUST be disabled until build succeeds. -- If `distilgpt2` is selected and model assets are missing, UI MUST instruct the user to run `python run.py setup --with-llm`. +- If `flan-t5` is selected and model assets are missing, UI MUST instruct the user to run `python run.py setup --with-llm`. - UI MUST not require optional LLM mode; default mock mode must be fully supported. - UI MUST not embed secrets or credentials in code. @@ -53,7 +53,7 @@ ## Acceptance Criteria - [ ] User can enter a valid question, submit, and view answer output. (manual acceptance) - [ ] User can change `top_k` and see reflected retrieval metadata. (manual acceptance) -- [ ] User can switch generator between `mock` and `distilgpt2` from the same control row as `top_k`. (manual acceptance) +- [ ] User can switch generator between `mock` and `flan-t5` from the same control row as `top_k`. (manual acceptance) - [ ] Source citations are rendered when present. (manual acceptance) - [ ] Fallback path is visible and understandable when no matches exist. (manual acceptance) - [ ] Validation errors are shown in the UI without app crash. (manual acceptance) diff --git a/app/generation.py b/app/generation.py index ee48e7ec..cdc2b3cf 100644 --- a/app/generation.py +++ b/app/generation.py @@ -26,7 +26,7 @@ def generate(self, question: str, sources: list[dict]) -> str: ) -class DistilGPT2Generator: +class FlanT5Generator: def __init__(self) -> None: self._llm = None self._generator = None @@ -39,17 +39,14 @@ def _ensure_model(self) -> None: from transformers import pipeline # Only download via explicit setup command; runtime should be local-only. + # Flan-T5 is a seq2seq model designed for instruction following. hf_pipeline = pipeline( - "text-generation", - model="distilgpt2", - tokenizer="distilgpt2", + "text2text-generation", + model="google/flan-t5-small", model_kwargs={"local_files_only": True}, - max_new_tokens=60, + max_new_tokens=100, do_sample=False, num_return_sequences=1, - pad_token_id=50256, - eos_token_id=50256, - return_full_text=False, ) if _use_langchain_generation(): from langchain_huggingface import HuggingFacePipeline @@ -59,7 +56,7 @@ def _ensure_model(self) -> None: self._generator = hf_pipeline except Exception as exc: # pragma: no cover - depends on local model availability raise RuntimeError( - "distilgpt2 model is unavailable locally. " + "flan-t5-small model is unavailable locally. " "Run `python run.py setup --with-llm` or use generator=mock." ) from exc @@ -77,23 +74,20 @@ def _generate_with_langchain(self, question: str, sources: list[dict]) -> str: ] raw_text = str(generate_rag_answer(question=question, documents=documents, llm=self._llm)).strip() - # Clean up common repetition patterns from distilgpt2 + # Flan-T5 produces cleaner output than distilgpt2, but still do basic cleanup if raw_text: - # Split on newlines and take only the first substantial line - first_line = raw_text.split('\n')[0].strip() - # Remove repeated "Answer:" patterns - while "Answer:" in first_line: - first_line = first_line.replace("Answer:", "").strip() + # Take first substantial sentence/paragraph + first_part = raw_text.split('\n\n')[0].strip() - # Quality check: distilgpt2 often produces garbage output - if self._is_low_quality_output(first_line): + # Basic quality check + if self._is_low_quality_output(first_part): return "" - return first_line + return first_part return "" def _is_low_quality_output(self, text: str) -> bool: - """Detect when distilgpt2 produces nonsensical output.""" + """Detect when LLM produces nonsensical output.""" if not text or len(text) < 10: return True @@ -112,24 +106,25 @@ def _is_low_quality_output(self, text: str) -> bool: return False def _generate_legacy(self, question: str, sources: list[dict]) -> str: - context = " ".join(source.get("snippet", "") for source in sources[:2]) - prompt = f"Question: {question}\nContext: {context}\nAnswer: " + # Build context from top sources + context_parts = [source.get("snippet", "") for source in sources[:2]] + context = " ".join(context_parts) + + # Flan-T5 works better with clear instruction format + prompt = f"Answer the question based on the context.\n\nContext: {context}\n\nQuestion: {question}\n\nAnswer:" + outputs = self._generator(prompt) raw_text = str(outputs[0].get("generated_text", "")).strip() if outputs else "" - # Clean up common repetition patterns from distilgpt2 if raw_text: - # Split on newlines and take only the first sentence/line - first_line = raw_text.split('\n')[0].strip() - # Remove repeated "Answer:" patterns - while "Answer:" in first_line: - first_line = first_line.replace("Answer:", "").strip() + # Take first substantial part + first_part = raw_text.split('\n\n')[0].strip() - # Quality check: distilgpt2 often produces garbage output - if self._is_low_quality_output(first_line): + # Quality check + if self._is_low_quality_output(first_part): return "" - return first_line + return first_part return "" def generate(self, question: str, sources: list[dict]) -> str: @@ -144,11 +139,11 @@ def generate(self, question: str, sources: list[dict]) -> str: else self._generate_legacy(question=question, sources=sources) ) if not text: - # distilgpt2 often produces low-quality output; fall back with explanation + # LLM produced low-quality output; fall back with explanation titles = ", ".join(source["title"] for source in sources[:2]) return ( f"Based on Mockridge Bank FAQ ({titles}), see sources below. " - f"(Note: distilgpt2 generation was unreliable for this query. " + f"(Note: LLM generation was unreliable for this query. " f"Try 'mock' generator for consistent results.)" ) return text @@ -156,6 +151,6 @@ def generate(self, question: str, sources: list[dict]) -> str: def get_generator(mode: str | None): choice = (mode or "mock").strip().lower() - if choice == "distilgpt2": - return DistilGPT2Generator() + if choice == "flan-t5": + return FlanT5Generator() return MockGenerator() diff --git a/app/main.py b/app/main.py index 1f474241..c88b14bd 100644 --- a/app/main.py +++ b/app/main.py @@ -53,8 +53,8 @@ def _validate_generator(payload: dict) -> str: if not isinstance(value, str): raise _bad_request("generator must be a string") choice = value.strip().lower() - if choice not in {"mock", "distilgpt2"}: - raise _bad_request("generator must be mock or distilgpt2") + if choice not in {"mock", "flan-t5"}: + raise _bad_request("generator must be mock or flan-t5") return choice @@ -88,7 +88,7 @@ def ask(payload: dict = Body(...)): generator = get_generator(generator_mode) answer = generator.generate(question=question, sources=sources) except RuntimeError as exc: - # Distilgpt2 path may fail when model assets are not installed locally. + # LLM generator may fail when model assets are not installed locally. raise HTTPException(status_code=503, detail=str(exc)) from exc response = { diff --git a/app/rag_chain.py b/app/rag_chain.py index 42239f40..fd959a74 100644 --- a/app/rag_chain.py +++ b/app/rag_chain.py @@ -5,12 +5,10 @@ RAG_PROMPT = PromptTemplate.from_template( ( - "You are answering customer support FAQ questions for Mockridge Bank.\n" - "Use only the provided context.\n" - "If the context is insufficient, say so briefly.\n\n" - "Question: {question}\n" + "Answer the customer's question about Mockridge Bank using only the provided context.\n\n" "Context:\n{context}\n\n" - "Answer: " + "Question: {question}\n\n" + "Answer:" ) ) diff --git a/pytest.ini b/pytest.ini index 3d4a2c06..8430b516 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,6 +4,9 @@ python_files = test_*.py addopts = -ra filterwarnings = ignore:Accessing the 'model_fields' attribute on the instance is deprecated.*:pydantic.warnings.PydanticDeprecatedSince211:chromadb\.types + ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning + ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning + ignore:builtin type swigvarlink has no __module__ attribute:DeprecationWarning markers = smoke: quick smoke checks for module startup/import paths unit: unit tests for isolated component behavior diff --git a/run.py b/run.py index bc06dc94..a1584dcb 100644 --- a/run.py +++ b/run.py @@ -205,7 +205,7 @@ def _download_embedding_model(python_bin: str) -> int: def _download_llm_assets(python_bin: str) -> int: - print("Downloading distilgpt2 model assets...") + print("Downloading flan-t5-small model assets...") hf_env = {**os.environ, "HF_HUB_DISABLE_SYMLINKS_WARNING": "1"} return _run( [ @@ -214,7 +214,7 @@ def _download_llm_assets(python_bin: str) -> int: ( "import warnings; warnings.filterwarnings('ignore'); " "from transformers import pipeline; " - "pipeline('text-generation', model='distilgpt2', tokenizer='distilgpt2')" + "pipeline('text2text-generation', model='google/flan-t5-small')" ), ], env=hf_env, diff --git a/tests/test_generator_config.py b/tests/test_generator_config.py index 359ce967..4cb50754 100644 --- a/tests/test_generator_config.py +++ b/tests/test_generator_config.py @@ -20,17 +20,17 @@ def test_default_generator_mode_is_mock_deterministic(client): @pytest.mark.optional @pytest.mark.integration -def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(client): +def test_flan_t5_mode_is_opt_in_and_fails_clearly_when_unavailable(client): """ - Verify that distilgpt2 mode routes through LLM adapter or fails clearly if unavailable. + Verify that flan-t5 mode routes through LLM adapter or fails clearly if unavailable. Spec: generation-optional-llm.md - Acceptance Criteria: "With `RAG_GENERATOR=distilgpt2`, app routes generation through LLM adapter" + Acceptance Criteria: "With `generator=flan-t5`, app routes generation through LLM adapter" """ payload = { "question": "What credit card options do you have?", "top_k": 3, - "generator": "distilgpt2", + "generator": "flan-t5", } response = client.post("/ask", json=payload) @@ -39,4 +39,4 @@ def test_distilgpt2_mode_is_opt_in_and_fails_clearly_when_unavailable(client): if response.status_code in {500, 503}: body = response.json() as_text = str(body).lower() - assert "distilgpt2" in as_text or "model" in as_text + assert "flan-t5" in as_text or "model" in as_text diff --git a/tests/test_validation.py b/tests/test_validation.py index fc9d3cce..d9f5a508 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -275,7 +275,7 @@ def test_ask_generator_invalid_returns_400(client): Verify that POST /ask returns 400 when generator is not an allowed value. Spec: ask-endpoint-validation.md - Requirement: "`generator` is optional string with allowed values `mock` or `distilgpt2`" + Requirement: "`generator` is optional string with allowed values `mock` or `flan-t5`" """ payload = {"question": "What are your overdraft fees?", "top_k": 3, "generator": "gpt4"} response = client.post("/ask", json=payload) diff --git a/ui/streamlit_app.py b/ui/streamlit_app.py index ecd9822b..64897a11 100644 --- a/ui/streamlit_app.py +++ b/ui/streamlit_app.py @@ -192,10 +192,10 @@ def main() -> None: with control_col_2: generator = st.selectbox( "Response generator type", - ["mock", "distilgpt2"], + ["mock", "flan-t5"], index=0, disabled=controls_disabled, - help="Mock is deterministic and reliable (recommended). distilgpt2 is a small experimental LLM that often produces low-quality or nonsensical answers. Install via 'run.py setup --with-llm'", + help="Mock is deterministic and reliable (recommended). flan-t5 is a small instruction-tuned LLM (80M params) for experimental local generation. Install via 'run.py setup --with-llm'", ) if submit_clicked: @@ -221,7 +221,7 @@ def main() -> None: response = requests.post(f"{API_URL}/ask", json=pending_submission, timeout=90) if response.status_code != 200: detail = response.json().get("detail", "Unknown error") - if pending_submission["generator"] == "distilgpt2" and "setup --with-llm" in detail: + if pending_submission["generator"] == "flan-t5" and "setup --with-llm" in detail: st.warning("LLM assets not installed. Run `python run.py setup --with-llm` first.") if response.status_code == 503 and "Database not built" in detail: st.warning("Database is not built. Use the Build DB button above.") From 7d2bf4d61239cca7934309f5e5d34b4e9f5ddf47 Mon Sep 17 00:00:00 2001 From: Cameron Detig Date: Sun, 8 Feb 2026 11:56:51 -0500 Subject: [PATCH 19/20] refactor: Update docker-compose for UI build process and enhance README for clarity on features and setup instructions --- README.md | 21 ++++++++++++++------- SPECS/docker-optional.md | 8 ++++---- SPECS/entrypoint-cli.md | 28 ++++++++++++++-------------- SPECS/generation-optional-llm.md | 2 +- SPECS/retrieval-pipeline.md | 4 ++-- SPECS/streamlit-ui.md | 16 ++++++++-------- docker-compose.yml | 4 ++-- requirements.txt | 1 + 8 files changed, 46 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 7a5c8cf8..be91f0f5 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ Spec-driven local RAG assistant built with FastAPI + Streamlit. ## What It Does -- Accepts customer FAQ questions through API or UI +- Answers customer questions relating to products and services for a fictional bank (Mockridge Bank) +- Available through API or Streamlit UI - Retrieves relevant local FAQ documents from ChromaDB - Generates answers with: - - deterministic `mock` mode (default, test-friendly) - - optional `flan-t5` mode (small instruction-tuned LLM) -- Returns answer + cited sources + retrieval metadata + - `mock` deterministic mode (default, test-friendly) + - `flan-t5` optional mode (small locally running instruction-tuned LLM, no API key required) +- Returns answer + cited sources ## Tech Stack - Python 3.10, 3.11, or 3.12 @@ -33,7 +34,7 @@ What setup does: - installs dependencies - builds retrieval DB -Optional LLM assets: +If you want to be able to use the LLM option, run setup with this command. It will download the flan-t5 model (~300MB): ```bash python run.py setup --with-llm ``` @@ -47,6 +48,11 @@ Endpoints: - API: `http://127.0.0.1:8000` - UI: `http://127.0.0.1:8501` +### API Documentation +When the API is running, interactive documentation is available at: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + ## Run Commands ```bash @@ -114,13 +120,13 @@ Example request: ## Environment Variables -- `RAG_MIN_SCORE` (default `0.25`) +- `RAG_MIN_SCORE` (minimum similarity score for retrieved documents. default: `0.25`) - `API_URL` (used by Streamlit UI; default `http://127.0.0.1:8000`) ## Project Structure - `app/main.py` API routes -- `app/retrieval.py` retrieval/indexing logic +- `app/retrieval.py` RAG retrieval/indexing logic - `app/generation.py` generator selection and LLM adapter - `app/rag_chain.py` LangChain prompt/chain - `ui/streamlit_app.py` UI @@ -131,3 +137,4 @@ Example request: ## Notes - Default mode is deterministic and intended for local testing/CI. - Optional `flan-t5` mode uses Google's Flan-T5-small (80M params) for local experimentation and requires model assets (~308MB download via `python run.py setup --with-llm`). +- Project uses CPU-only PyTorch for faster setup and smaller footprint (~200MB vs ~2.5GB). GPU acceleration is unnecessary for the small models used in this project. \ No newline at end of file diff --git a/SPECS/docker-optional.md b/SPECS/docker-optional.md index cfc626fe..da1b7734 100644 --- a/SPECS/docker-optional.md +++ b/SPECS/docker-optional.md @@ -29,7 +29,7 @@ - How to build and run Docker services ## Acceptance Criteria -- [ ] Project runs locally without Docker and all required tests execute. -- [ ] API can be built and started via Docker. -- [ ] Optional compose workflow can start API and UI together. -- [ ] Documentation clearly separates optional Docker commands from default local workflow. +- [x] Project runs locally without Docker and all required tests execute. +- [x] API can be built and started via Docker. (Dockerfile, docker-compose.yml::api) +- [x] Optional compose workflow can start API and UI together. (docker-compose.yml, run.py::cmd_docker_fullstack) +- [x] Documentation clearly separates optional Docker commands from default local workflow. (README.md) diff --git a/SPECS/entrypoint-cli.md b/SPECS/entrypoint-cli.md index 6636676a..33b834f7 100644 --- a/SPECS/entrypoint-cli.md +++ b/SPECS/entrypoint-cli.md @@ -44,17 +44,17 @@ - Docker helper commands MUST fail clearly when Docker is unavailable and MUST keep local non-Docker commands fully usable. ## Acceptance Criteria -- [ ] `python run.py help` prints usage and available commands. (test_cli.py - to be implemented) -- [ ] `python run.py setup` installs dependencies without requiring manual venv steps. (manual acceptance) -- [ ] `python run.py setup --with-llm` downloads LLM assets for flan-t5. (manual acceptance) -- [ ] `python run.py setup --no-venv` installs dependencies into the current environment. (manual acceptance) -- [ ] `python run.py setup` automatically selects a supported interpreter (`3.12`/`3.11`/`3.10`) for `.venv` creation when available. (manual acceptance) -- [ ] `python run.py setup --python ` uses the specified interpreter when supported. (manual acceptance) -- [ ] `python run.py setup` fails with clear guidance when no supported interpreter is available. (manual acceptance) -- [ ] `python run.py setup --install-python` attempts package-manager Python installation and then continues setup when possible. (manual acceptance) -- [ ] `python run.py api` starts the backend. (manual acceptance) -- [ ] `python run.py ui` starts the Streamlit UI. (manual acceptance) -- [ ] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (manual acceptance) -- [ ] `python run.py test` runs pytest successfully. (test_cli.py - to be implemented) -- [ ] `python run.py test-matrix` runs tox matrix environments (for available local interpreters). (manual acceptance) -- [ ] `python run.py docker-build`, `python run.py docker-api`, `python run.py docker-fullstack`, and `python run.py docker-down` work when Docker is installed and fail with clear guidance when Docker is unavailable. (manual acceptance) +- [x] `python run.py help` prints usage and available commands. (run.py::show_help) +- [x] `python run.py setup` installs dependencies without requiring manual venv steps. (run.py::cmd_setup) +- [x] `python run.py setup --with-llm` downloads LLM assets for flan-t5. (run.py::cmd_setup + _download_llm_assets) +- [x] `python run.py setup --no-venv` installs dependencies into the current environment. (run.py::cmd_setup) +- [x] `python run.py setup` automatically selects a supported interpreter (`3.12`/`3.11`/`3.10`) for `.venv` creation when available. (run.py::_select_supported_python_command) +- [x] `python run.py setup --python ` uses the specified interpreter when supported. (run.py::cmd_setup, line 248-253) +- [x] `python run.py setup` fails with clear guidance when no supported interpreter is available. (run.py::cmd_setup, line 264-270) +- [x] `python run.py setup --install-python` attempts package-manager Python installation and then continues setup when possible. (run.py::cmd_setup + _attempt_python_install) +- [x] `python run.py api` starts the backend. (run.py::cmd_api) +- [x] `python run.py ui` starts the Streamlit UI. (run.py::cmd_ui) +- [x] `python run.py fullstack` starts API and UI together and stops them on Ctrl+C. (run.py::cmd_fullstack) +- [x] `python run.py test` runs pytest successfully. (run.py::cmd_test) +- [x] `python run.py test-matrix` runs tox matrix environments (for available local interpreters). (run.py::cmd_test_matrix) +- [x] `python run.py docker-build`, `python run.py docker-api`, `python run.py docker-fullstack`, and `python run.py docker-down` work when Docker is installed and fail with clear guidance when Docker is unavailable. (run.py::cmd_docker_*) diff --git a/SPECS/generation-optional-llm.md b/SPECS/generation-optional-llm.md index 8491a590..4d39819b 100644 --- a/SPECS/generation-optional-llm.md +++ b/SPECS/generation-optional-llm.md @@ -37,4 +37,4 @@ - [x] With default environment, app uses mock generator. (test_generator_config.py::test_default_generator_mode_is_mock_deterministic) - [x] With `generator=flan-t5`, app routes generation through LLM adapter. (test_generator_config.py::test_flan_t5_mode_is_opt_in_and_fails_clearly_when_unavailable) - [x] Test suite does not depend on `flan-t5`. (conftest.py::default_generator_env) -- [ ] Documentation explains optional setup and non-requirement for tests. +- [x] Documentation explains optional setup and non-requirement for tests. (README.md sections: Quick Start, Notes) diff --git a/SPECS/retrieval-pipeline.md b/SPECS/retrieval-pipeline.md index da2250fc..c6357d87 100644 --- a/SPECS/retrieval-pipeline.md +++ b/SPECS/retrieval-pipeline.md @@ -52,5 +52,5 @@ - [x] Source list is sorted by score descending. (test_retrieval.py::test_sources_are_sorted_by_descending_score) - [x] Unknown/out-of-domain query triggers unmatched retrieval path. (test_retrieval.py::test_unknown_query_returns_fallback_with_empty_sources) - [x] Retrieval metadata reports `top_k` and `matched` accurately. (test_contract.py::test_ask_response_retrieval_metadata_has_required_fields) -- [ ] `GET /db/status` reports whether DB is built and indexed counts. (manual acceptance) -- [ ] `POST /db/build` builds the DB and makes status built=true. (manual acceptance) +- [X] `GET /db/status` reports whether DB is built and indexed counts. (manual acceptance) +- [X] `POST /db/build` builds the DB and makes status built=true. (manual acceptance) diff --git a/SPECS/streamlit-ui.md b/SPECS/streamlit-ui.md index 77153fae..9804cef0 100644 --- a/SPECS/streamlit-ui.md +++ b/SPECS/streamlit-ui.md @@ -51,14 +51,14 @@ - `generation-mock.md` - Default generator that UI relies on ## Acceptance Criteria -- [ ] User can enter a valid question, submit, and view answer output. (manual acceptance) -- [ ] User can change `top_k` and see reflected retrieval metadata. (manual acceptance) -- [ ] User can switch generator between `mock` and `flan-t5` from the same control row as `top_k`. (manual acceptance) -- [ ] Source citations are rendered when present. (manual acceptance) -- [ ] Fallback path is visible and understandable when no matches exist. (manual acceptance) -- [ ] Validation errors are shown in the UI without app crash. (manual acceptance) +- [X] User can enter a valid question, submit, and view answer output. (manual acceptance) +- [X] User can change `top_k` and see reflected retrieval metadata. (manual acceptance) +- [X] User can switch generator between `mock` and `flan-t5` from the same control row as `top_k`. (manual acceptance) +- [X] Source citations are rendered when present. (manual acceptance) +- [X] Fallback path is visible and understandable when no matches exist. (manual acceptance) +- [X] Validation errors are shown in the UI without app crash. (manual acceptance) - [x] UI runs locally against the API in default mock mode. (test_streamlit_smoke.py::test_streamlit_app_module_imports) - [x] DB status payload is normalized to `built` and `doc_count` for UI consumption. (tests/test_streamlit_ui_logic.py::test_get_db_status_normalizes_payload) - [x] DB status helper surfaces HTTP failures without crashing the UI flow. (tests/test_streamlit_ui_logic.py::test_get_db_status_handles_non_200) -- [ ] UI is implemented as a single page. (manual acceptance) -- [ ] UI is accessible without authentication or account flows. (manual acceptance) +- [X] UI is implemented as a single page. (manual acceptance) +- [X] UI is accessible without authentication or account flows. (manual acceptance) diff --git a/docker-compose.yml b/docker-compose.yml index b25b5d51..ec53200e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,10 @@ services: - ./data:/app/data - ./chroma:/app/chroma ui: - image: python:3.11-slim + build: . working_dir: /app volumes: - - .:/app + - ./ui:/app/ui command: ["python", "-m", "streamlit", "run", "ui/streamlit_app.py", "--server.port", "8501", "--server.address", "0.0.0.0"] ports: - "8501:8501" diff --git a/requirements.txt b/requirements.txt index e5cbb2a3..8c7117c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pydantic>=2.8,<3.0 chromadb>=0.5,<1.0 sentence-transformers>=3.0,<4.0 transformers>=4.44,<5.0 +--extra-index-url https://download.pytorch.org/whl/cpu torch>=2.3,<3.0 sentencepiece>=0.2,<0.3 langchain>=0.3,<0.4 From 3d25de00a27aa942db3b7b43d263e2318055a7c3 Mon Sep 17 00:00:00 2001 From: Cameron Detig Date: Sun, 8 Feb 2026 12:22:23 -0500 Subject: [PATCH 20/20] docs: Adding image of the UI to the readme --- README.md | 4 ++++ images/streamlit_ui.png | Bin 0 -> 69160 bytes 2 files changed, 4 insertions(+) create mode 100644 images/streamlit_ui.png diff --git a/README.md b/README.md index be91f0f5..eabe74e9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ Spec-driven local RAG assistant built with FastAPI + Streamlit. - LangChain (`langchain`, `langchain-huggingface`, `langchain-chroma`) - pytest +## Preview + +Streamlit UI + ## Quick Start ### 1) Setup diff --git a/images/streamlit_ui.png b/images/streamlit_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..c961906d286f2cac73c3dd9564b2fe53002ce207 GIT binary patch literal 69160 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 YV@#yWWf_FlKR-%SP3Kvq%HQGt7r`3x9{>OV literal 0 HcmV?d00001