Skip to content

Commit f74a94a

Browse files
authored
Merge pull request #45 from Leon-Sander/repo_structure_refactor
Tests, CI, and src/ layout - Added a contract-level test suite (20 tests) over the SQLite layer, utilities, and API request construction. See TESTING.md. - CI runs the suite on Ubuntu and Windows (Python 3.10). - Moved all Python source into `src/` for a clearer repo root. ### Breaking - Run commands changed: `streamlit run src/app.py` and `python3 src/database_operations.py`. Docker/compose updated; update local clones and forks.
2 parents 3e0444d + bf3a71a commit f74a94a

21 files changed

Lines changed: 682 additions & 15 deletions

.github/workflows/tests.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
tests:
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
os: [ubuntu-latest, windows-latest]
15+
python-version: ["3.10"]
16+
runs-on: ${{ matrix.os }}
17+
timeout-minutes: 20
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
cache: pip
25+
cache-dependency-path: requirements*.txt
26+
27+
- name: Install dependencies
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install -r requirements.txt -r requirements-dev.txt
31+
32+
# Catches import-time breakage (config paths, heavy deps) with a clear
33+
# error before the test run. torch is deliberately NOT installed: only
34+
# audio_handler needs it, and audio stays manual (see TESTING.md).
35+
- name: Smoke-import the modules under test
36+
run: python -c "import chat_api_handler, database_operations, utils"
37+
env:
38+
PYTHONPATH: src
39+
40+
- name: Run tests
41+
run: pytest

README.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ Local Multimodal AI Chat integrates several AI models behind a single Streamlit
3434

3535
```mermaid
3636
flowchart TD
37-
U["User"] --> UI["Streamlit UI (app.py)"]
38-
UI -->|"record / upload audio"| W["Whisper ASR (audio_handler)"]
37+
U["User"] --> UI["Streamlit UI (src/app.py)"]
38+
UI -->|"record / upload audio"| W["Whisper ASR (src/audio_handler)"]
3939
W --> R["ChatAPIHandler router"]
4040
UI -->|"text / image / PDF / commands"| R
4141
UI -->|"upload PDF"| P["PDF handler: pypdfium2 + chunking"]
@@ -50,15 +50,17 @@ flowchart TD
5050

5151
**Module layout**
5252

53+
All Python source lives under `src/`.
54+
5355
| File | Responsibility |
5456
|------|----------------|
55-
| `app.py` | Streamlit UI, session state, orchestration |
56-
| `chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls |
57-
| `vectordb_handler.py` | Chroma client + Ollama embeddings |
58-
| `pdf_handler.py` | PDF text extraction, chunking, indexing |
59-
| `audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) |
60-
| `database_operations.py` | SQLite repositories (messages, settings) |
61-
| `utils.py` / `prompt_templates.py` / `html_templates.py` | Helpers, prompts, UI styling |
57+
| `src/app.py` | Streamlit UI, session state, orchestration |
58+
| `src/chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls |
59+
| `src/vectordb_handler.py` | Chroma client + Ollama embeddings |
60+
| `src/pdf_handler.py` | PDF text extraction, chunking, indexing |
61+
| `src/audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) |
62+
| `src/database_operations.py` | SQLite repositories (messages, settings) |
63+
| `src/utils.py` / `src/prompt_templates.py` / `src/html_templates.py` | Helpers, prompts, UI styling |
6264

6365
## Tech Stack
6466

@@ -105,15 +107,32 @@ Running Ollama inside Docker is slow on Windows (system calls are translated bet
105107
```
106108
4. **Run**:
107109
```bash
108-
python3 database_operations.py # initializes the SQLite database
109-
streamlit run app.py
110+
python3 src/database_operations.py # initializes the SQLite database
111+
streamlit run src/app.py
110112
```
111113
5. **Pull models** as described above.
112114

113115
## Configuration
114116

115117
Key settings live in `config.yaml` (Ollama base URL and embedding model, Whisper model, Chroma path/collection, chat-sessions DB path). Runtime options (endpoint, model, chunk size/overlap, retrieved chunks, chat memory) are adjustable in the sidebar and persisted per user.
116118

119+
## Testing
120+
121+
A small contract-level test suite covers the SQLite layer, utility functions, and API request construction (UI, audio, and real model calls stay manual by design):
122+
123+
```bash
124+
pip install -r requirements-dev.txt
125+
pytest
126+
```
127+
128+
Or run it inside the app container (no local Python setup, matches the CI environment):
129+
130+
```bash
131+
docker compose -f docker-compose_without_ollama.yml run --rm app sh -c "pip install --no-cache-dir -q pytest && pytest"
132+
```
133+
134+
CI runs the suite on Ubuntu and Windows. See [TESTING.md](./TESTING.md) for the philosophy and what is deliberately not tested.
135+
117136
## Roadmap
118137

119138
- [ ] Additional model providers (Gemini, others)
@@ -124,6 +143,7 @@ Key settings live in `config.yaml` (Ollama base URL and embedding model, Whisper
124143
<details>
125144
<summary>Changelog (highlights)</summary>
126145

146+
- **2026-07** (v2.6.0): Contract-level test suite (21 tests) plus GitHub Actions CI on Ubuntu and Windows; moved all source into `src/` for a cleaner root. Breaking: run commands are now `streamlit run src/app.py`.
127147
- **2025-05** (v2.5.0): file upload via the chat bar.
128148
- **2024-09**: Model serving moved to the Ollama API; OpenAI API added.
129149
- **2024-08**: Docker Compose added.

TESTING.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Testing
2+
3+
This repo has a small, deliberate test suite: **21 contract tests** over the parts of the code whose behavior is stable by design. It is meant to stay cheap to maintain — including for forks.
4+
5+
## Philosophy: contracts, not structure
6+
7+
A test here pins a **behavioral promise** (what a function guarantees to its callers), never an implementation detail. The intended property: **a test only fails when behavior actually changed.** If you fork this repo, rewrite internals freely — a red test means you changed something user-visible, which is exactly when you want to know.
8+
9+
A few places accept **medium churn** deliberately, each documented inline where it occurs:
10+
11+
- `test_pdf_chat_stuffs_retrieved_context_into_prompt` patches the internal `load_vectordb` seam (the alternative is a real Chroma + Ollama instance).
12+
- `test_command_dispatch` patches the internal `pull_model_in_background` seam (the alternative is asyncio + a live Ollama pull).
13+
- The handler tests patch two values that src binds **at import time** (`chat_api_handler.config`, `chat_api_handler.openai_api_key`). A behavior-preserving refactor to call-time lookups would require updating the two fixtures at the top of `tests/test_chat_api_handler.py` — a two-line fix, accepted as the cost of avoiding real env vars and network.
14+
15+
## What is covered
16+
17+
| File | Contracts |
18+
|------|-----------|
19+
| `tests/test_database_operations.py` (10) | message roundtrips (text + binary), LLM-context feed ordering/filtering, per-session delete isolation, session id listing, settings read-through defaults + persistence, restart durability |
20+
| `tests/test_utils.py` (3) | base64/data-URL wire format, `/command` dispatch, config save/load roundtrip |
21+
| `tests/test_chat_api_handler.py` (8) | endpoint routing (ollama/openai/unknown), request bodies + auth headers, image wire formats (Ollama bare-b64 vs OpenAI data URL), RAG context stuffing, error-shape handling (string vs nested object) |
22+
23+
Counts are executed test cases, not test functions: `test_database_operations.py` has 9 test functions but one is parametrized over `["image", "audio"]`, so it runs 10 cases — 21 in total.
24+
25+
## What deliberately stays untested (and why)
26+
27+
- **UI structure and CSS** (`app.py`, `html_templates.py`) — Streamlit class names churn every release; tests would break without any behavior change.
28+
- **Audio transcription** (`audio_handler.py`) — needs Whisper model downloads; the browser microphone path can't be unit-tested anyway.
29+
- **Real model calls / PDF ingestion** — network- and model-dependent; the request *construction* is tested, the live call is not.
30+
- **Trivial helpers** (`convert_ns_to_seconds`, `get_avatar`, timestamp formatting) — near-zero regression value for the count they add.
31+
- **The manual smoke test remains**: launch the app, send a text message, record audio once. That is the irreducible browser part.
32+
33+
## Running
34+
35+
From the **repo root**:
36+
37+
```bash
38+
pip install -r requirements-dev.txt # includes requirements.txt
39+
pytest
40+
```
41+
42+
CI runs the same suite on Ubuntu and Windows (Python 3.10) on every push/PR to `main` — which also doubles as a "does it still install on Windows" check.
43+
44+
## Gotchas when writing new tests
45+
46+
The src modules bind several things **at import time** — patch the right namespace:
47+
48+
| To fake | Patch | Why |
49+
|---------|-------|-----|
50+
| OpenAI key | `chat_api_handler.openai_api_key` | `os.getenv` ran at import; `monkeypatch.setenv` does nothing |
51+
| Ollama base URL | `chat_api_handler.config` | module-level `load_config()` froze it |
52+
| Vector DB | `chat_api_handler.load_vectordb` | from-import binding; patching `vectordb_handler` does nothing |
53+
| HTTP | `requests.post` (module attribute) | call-time lookup, reaches all src modules |
54+
| Session state | `streamlit.session_state``FakeSessionState` (conftest) | never rely on bare-mode session_state |
55+
56+
Other invariants:
57+
58+
- An autouse `no_network` fixture replaces `requests.get/post` and blocks raw socket connects (which also covers `aiohttp` and `requests.Session`) — a test that reaches for the network fails loudly instead of silently hitting a real endpoint.
59+
- DB tests build their own `DatabaseManager` on `tmp_path` and must `close()` it (Windows cannot delete open sqlite files). Never assert through the module-level singleton in `database_operations`.
60+
- `tests/conftest.py` chdirs into a throwaway **sandbox** (with its own minimal `config.yaml` and `chat_sessions/`) before anything imports the src modules — their import-time side effects (config read, sqlite creation) land in the sandbox, the repo's real `config.yaml` and `chat_sessions/` are never read or written, and pytest works from any invocation directory.

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ services:
99
- "8501:8501"
1010
environment:
1111
- PYTHONUNBUFFERED=1
12-
command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll"
12+
command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll"
1313
depends_on:
1414
- ollama
1515

docker-compose_without_ollama.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ services:
77
- ./:/app
88
ports:
99
- "8501:8501"
10-
- "11434:11434"
1110
environment:
1211
- PYTHONUNBUFFERED=1
13-
command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll"
12+
command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll"

docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ RUN pip install --upgrade pip
1010
RUN pip install --no-cache-dir -r requirements.txt
1111
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
1212
EXPOSE 8501
13-
CMD ["streamlit","run", "app.py"]
13+
CMD ["streamlit","run", "src/app.py"]

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
pythonpath = src
3+
testpaths = tests

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-r requirements.txt
2+
pytest==9.1.1
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)