Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10"]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- 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
pip install -r requirements.txt -r requirements-dev.txt

# Catches import-time breakage (config paths, heavy deps) with a clear
# error before the test run. torch is deliberately NOT installed: only
# audio_handler needs it, and audio stays manual (see TESTING.md).
- name: Smoke-import the modules under test
run: python -c "import chat_api_handler, database_operations, utils"
env:
PYTHONPATH: src

- name: Run tests
run: pytest
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ Local Multimodal AI Chat integrates several AI models behind a single Streamlit

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

**Module layout**

All Python source lives under `src/`.

| File | Responsibility |
|------|----------------|
| `app.py` | Streamlit UI, session state, orchestration |
| `chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls |
| `vectordb_handler.py` | Chroma client + Ollama embeddings |
| `pdf_handler.py` | PDF text extraction, chunking, indexing |
| `audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) |
| `database_operations.py` | SQLite repositories (messages, settings) |
| `utils.py` / `prompt_templates.py` / `html_templates.py` | Helpers, prompts, UI styling |
| `src/app.py` | Streamlit UI, session state, orchestration |
| `src/chat_api_handler.py` | Endpoint router (Ollama / OpenAI), text + image + RAG calls |
| `src/vectordb_handler.py` | Chroma client + Ollama embeddings |
| `src/pdf_handler.py` | PDF text extraction, chunking, indexing |
| `src/audio_handler.py` | Whisper transcription (webm→wav via ffmpeg) |
| `src/database_operations.py` | SQLite repositories (messages, settings) |
| `src/utils.py` / `src/prompt_templates.py` / `src/html_templates.py` | Helpers, prompts, UI styling |

## Tech Stack

Expand Down Expand Up @@ -105,15 +107,32 @@ Running Ollama inside Docker is slow on Windows (system calls are translated bet
```
4. **Run**:
```bash
python3 database_operations.py # initializes the SQLite database
streamlit run app.py
python3 src/database_operations.py # initializes the SQLite database
streamlit run src/app.py
```
5. **Pull models** as described above.

## Configuration

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.

## Testing

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):

```bash
pip install -r requirements-dev.txt
pytest
```

Or run it inside the app container (no local Python setup, matches the CI environment):

```bash
docker compose -f docker-compose_without_ollama.yml run --rm app sh -c "pip install --no-cache-dir -q pytest && pytest"
```

CI runs the suite on Ubuntu and Windows. See [TESTING.md](./TESTING.md) for the philosophy and what is deliberately not tested.

## Roadmap

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

- **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`.
- **2025-05** (v2.5.0): file upload via the chat bar.
- **2024-09**: Model serving moved to the Ollama API; OpenAI API added.
- **2024-08**: Docker Compose added.
Expand Down
60 changes: 60 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Testing

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.

## Philosophy: contracts, not structure

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.

A few places accept **medium churn** deliberately, each documented inline where it occurs:

- `test_pdf_chat_stuffs_retrieved_context_into_prompt` patches the internal `load_vectordb` seam (the alternative is a real Chroma + Ollama instance).
- `test_command_dispatch` patches the internal `pull_model_in_background` seam (the alternative is asyncio + a live Ollama pull).
- 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.

## What is covered

| File | Contracts |
|------|-----------|
| `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 |
| `tests/test_utils.py` (3) | base64/data-URL wire format, `/command` dispatch, config save/load roundtrip |
| `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) |

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.

## What deliberately stays untested (and why)

- **UI structure and CSS** (`app.py`, `html_templates.py`) — Streamlit class names churn every release; tests would break without any behavior change.
- **Audio transcription** (`audio_handler.py`) — needs Whisper model downloads; the browser microphone path can't be unit-tested anyway.
- **Real model calls / PDF ingestion** — network- and model-dependent; the request *construction* is tested, the live call is not.
- **Trivial helpers** (`convert_ns_to_seconds`, `get_avatar`, timestamp formatting) — near-zero regression value for the count they add.
- **The manual smoke test remains**: launch the app, send a text message, record audio once. That is the irreducible browser part.

## Running

From the **repo root**:

```bash
pip install -r requirements-dev.txt # includes requirements.txt
pytest
```

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.

## Gotchas when writing new tests

The src modules bind several things **at import time** — patch the right namespace:

| To fake | Patch | Why |
|---------|-------|-----|
| OpenAI key | `chat_api_handler.openai_api_key` | `os.getenv` ran at import; `monkeypatch.setenv` does nothing |
| Ollama base URL | `chat_api_handler.config` | module-level `load_config()` froze it |
| Vector DB | `chat_api_handler.load_vectordb` | from-import binding; patching `vectordb_handler` does nothing |
| HTTP | `requests.post` (module attribute) | call-time lookup, reaches all src modules |
| Session state | `streamlit.session_state` → `FakeSessionState` (conftest) | never rely on bare-mode session_state |

Other invariants:

- 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.
- 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`.
- `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.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ services:
- "8501:8501"
environment:
- PYTHONUNBUFFERED=1
command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll"
command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll"
depends_on:
- ollama

Expand Down
3 changes: 1 addition & 2 deletions docker-compose_without_ollama.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
- ./:/app
ports:
- "8501:8501"
- "11434:11434"
environment:
- PYTHONUNBUFFERED=1
command: /bin/sh -c "python3 database_operations.py && streamlit run app.py --server.fileWatcherType poll"
command: /bin/sh -c "python3 src/database_operations.py && streamlit run src/app.py --server.fileWatcherType poll"
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
EXPOSE 8501
CMD ["streamlit","run", "app.py"]
CMD ["streamlit","run", "src/app.py"]
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = src
testpaths = tests
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
149 changes: 149 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Shared fixtures for the contract-level test suite.

IMPORTANT - import-time side effects in src/ are load-bearing: utils.py,
chat_api_handler.py and database_operations.py read the CWD-relative
config.yaml when first imported, and database_operations.py opens a sqlite
database at the configured path at import time. The module-level code below
therefore chdirs into a throwaway sandbox seeded with a minimal config.yaml
BEFORE any test module (and hence any src module) is imported: every
import-time side effect lands in the sandbox, the repo's real config.yaml and
chat_sessions/ are never read or written, and the suite works from any
invocation directory.
"""
import atexit
import os
import shutil
import socket
import tempfile
from pathlib import Path

import pytest
import requests

_SANDBOX = Path(tempfile.mkdtemp(prefix="local-multimodal-chat-tests-"))
(_SANDBOX / "chat_sessions").mkdir()
(_SANDBOX / "config.yaml").write_text(
"\n".join(
[
"ollama:",
' embedding_model: "sandbox-embed"',
" base_url: http://sandbox-ollama:11434",
"",
'whisper_model: "sandbox-whisper"',
"",
"chromadb:",
' chromadb_path: "chroma_db"',
' collection_name: "pdfs"',
"",
'chat_sessions_database_path: "./chat_sessions/chat_sessions.db"',
"",
]
)
)
# Best-effort cleanup; ignore_errors because the module-level singleton in
# database_operations holds its sandbox db open for the whole session.
atexit.register(shutil.rmtree, _SANDBOX, ignore_errors=True)


def pytest_configure(config):
# chdir in the configure hook, not at conftest import: pytest resolves
# testpaths against the CWD first, and collection (which imports the test
# modules and therefore the src modules) only starts afterwards - so the
# import-time side effects still land in the sandbox.
os.chdir(_SANDBOX)


class FakeSessionState(dict):
"""Stand-in for st.session_state covering the four access patterns used in
src/: st.session_state["key"], .get("key", default), attribute access, and
membership tests. Never rely on Streamlit's bare-mode session_state - it is
a process-global singleton with undocumented semantics."""

def __getattr__(self, name):
try:
return self[name]
except KeyError:
raise AttributeError(name) from None

def __setattr__(self, name, value):
self[name] = value


class DummyResponse:
"""Minimal requests.Response stand-in; .json() is repeatably callable."""

def __init__(self, payload):
self._payload = payload

def json(self):
return self._payload


class RecordingPost:
"""Callable that records every requests.post(...) call and returns a
canned response. The signature mirrors the real
requests.post(url, data=None, json=None, ...) so that a refactor in src to
positional arguments cannot silently record the body under the wrong key."""

def __init__(self, response_payload):
self.response_payload = response_payload
self.calls = []

def __call__(self, url=None, data=None, json=None, headers=None, **kwargs):
self.calls.append({"url": url, "data": data, "json": json, "headers": headers})
return DummyResponse(self.response_payload)


@pytest.fixture(autouse=True)
def no_network(monkeypatch):
"""Fail loudly if any test hits the network without explicitly patching
it: requests.get/post are replaced for a clear error message, and raw
socket connects are blocked as a catch-all (this also covers aiohttp and
requests.Session). captured_post overrides requests.post for tests that
expect an HTTP call."""

def _blocked(*args, **kwargs):
raise RuntimeError(
"Network access is disabled in tests; use the captured_post "
"fixture or patch the network call explicitly."
)

monkeypatch.setattr(requests, "get", _blocked)
monkeypatch.setattr(requests, "post", _blocked)
monkeypatch.setattr(socket.socket, "connect", _blocked)


@pytest.fixture
def captured_post(monkeypatch):
"""Factory: captured_post(payload) patches requests.post to record calls
and answer with `payload`. Works because src modules do `import requests`
and look up requests.post at call time."""

def _install(response_payload):
recorder = RecordingPost(response_payload)
monkeypatch.setattr(requests, "post", recorder)
return recorder

return _install


@pytest.fixture
def fake_session_state(monkeypatch):
"""Replace st.session_state for every src module at once (they all do
`import streamlit as st`, so patching the module attribute reaches all)."""
import streamlit as st

state = FakeSessionState()
monkeypatch.setattr(st, "session_state", state)
return state


@pytest.fixture
def db_manager(tmp_path):
"""A DatabaseManager on a throwaway sqlite file. close() in teardown is
mandatory: an open handle makes tmp_path cleanup fail on Windows."""
from database_operations import DatabaseManager

manager = DatabaseManager(str(tmp_path / "test.db"))
yield manager
manager.close()
Loading
Loading