# Phase 7: Redo and Optimize LLM Integration - Pattern Map **Mapped:** 2026-06-02 **Files analyzed:** 14 new/modified files **Analogs found:** 13 / 14 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---| | `backend/ai/provider_config.py` | config/model | transform | `backend/api/admin.py` Pydantic models (lines 74–119) | role-match | | `backend/ai/generic_openai_provider.py` | service | request-response | `backend/ai/openai_provider.py` | exact | | `backend/ai/openai_provider.py` (modify) | service | request-response | self (current file) | exact | | `backend/ai/anthropic_provider.py` (modify) | service | request-response | `backend/ai/openai_provider.py` | exact | | `backend/ai/ollama_provider.py` (modify) | service | request-response | self + `backend/ai/ollama_provider.py` | exact | | `backend/ai/lmstudio_provider.py` (modify) | service | request-response | `backend/ai/ollama_provider.py` | exact | | `backend/ai/__init__.py` (refactor) | factory | request-response | self (current file) | exact | | `backend/db/models.py` (add SystemSettings) | model | CRUD | existing models in `backend/db/models.py` (CloudConnection, lines 299–318) | exact | | `backend/migrations/versions/0005_system_settings.py` | migration | batch | `backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py` | exact | | `backend/services/ai_config.py` | service | CRUD + encrypt | `backend/storage/cloud_utils.py` | exact | | `backend/services/classifier.py` (refactor) | service | request-response | self (current file) | exact | | `backend/tasks/document_tasks.py` (refactor) | task | event-driven | self (current file) | exact | | `frontend/src/components/admin/AdminAiConfigTab.vue` (modify) | component | request-response | self + `frontend/src/components/admin/AdminUsersTab.vue` | exact | | `frontend/src/components/documents/DocumentCard.vue` (modify) | component | request-response | self (current file) | exact | --- ## Pattern Assignments ### `backend/ai/provider_config.py` (NEW — config model) **Analog:** `backend/api/admin.py` Pydantic request models (lines 74–119) **Imports pattern** (from analog lines 27–32): ```python from __future__ import annotations from pydantic import BaseModel, field_validator from typing import Optional ``` **Core model pattern** (analog lines 74–119 — `AiConfigUpdate`, `QuotaUpdate`): ```python # From backend/api/admin.py lines 102–105 class AiConfigUpdate(BaseModel): ai_provider: Optional[str] = None ai_model: Optional[str] = None ``` New file follows same `BaseModel` subclass pattern with typed fields and defaults. No `from_attributes` needed (not an ORM response model). **No analog for `PROVIDER_DEFAULTS` dict** — use RESEARCH.md Pattern 2 directly (the dict of known base_urls and context_chars per provider_id). --- ### `backend/ai/generic_openai_provider.py` (NEW — service, request-response) **Analog:** `backend/ai/openai_provider.py` (entire file — 72 lines) **Imports pattern** (analog lines 1–4): ```python from openai import AsyncOpenAI from ai.base import AIProvider, ClassificationResult from ai.utils import parse_classification, parse_suggestions ``` **Subclass pattern** (analog lines 8–16): ```python # From backend/ai/openai_provider.py lines 8–16 class OpenAIProvider(AIProvider): def __init__(self, api_key: str, model: str = "gpt-4o", base_url=None): self._api_key = api_key self._model = model self._base_url = base_url def _client(self) -> AsyncOpenAI: return AsyncOpenAI(api_key=self._api_key or "placeholder", base_url=self._base_url) ``` `GenericOpenAIProvider` subclasses `OpenAIProvider`. The constructor adds `context_chars: int` and `supports_json_mode: bool = True`. The `_client()` method is replaced by `self._client = AsyncOpenAI(...)` stored in `__init__` (D-07 singleton fix). The `api_key` placeholder changes from `"placeholder"` to `"not-needed"` (openai SDK 2.34+ pitfall). **Core classify pattern** (analog lines 17–37 — override with json_mode): ```python # From backend/ai/openai_provider.py lines 17–37 async def classify(self, document_text, existing_topics, system_prompt): topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" user_msg = ( f"Existing topics: [{topics_str}]\n\n" f"Document text:\n{document_text[:MAX_AI_CHARS]}" ) response = await self._client().chat.completions.create( model=self._model, max_tokens=1024, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}, ], ) raw = response.choices[0].message.content or "" return parse_classification(raw) ``` `GenericOpenAIProvider` overrides this to: (1) call `self._truncate(document_text)` instead of `[:MAX_AI_CHARS]`, (2) add `response_format={"type": "json_object"}` when `self.supports_json_mode` is `True`, (3) call `self._client.chat.completions.create(...)` on the stored singleton (no `()`). **`_truncate` helper pattern** — no existing analog; copy verbatim from RESEARCH.md Pattern (smart truncation): ```python def _truncate(self, text: str) -> str: """First 60% + last 40% of context window (D-13).""" limit = self._context_chars if len(text) <= limit: return text head_len = int(limit * 0.6) tail_len = limit - head_len return text[:head_len] + "\n[...truncated...]\n" + text[-tail_len:] ``` **health_check pattern** (analog lines 60–69): ```python # From backend/ai/openai_provider.py lines 60–69 async def health_check(self) -> bool: try: await self._client().chat.completions.create( model=self._model, max_tokens=5, messages=[{"role": "user", "content": "ping"}], ) return True except Exception: return False ``` In `GenericOpenAIProvider` call `self._client.chat.completions.create(...)` (no `()`). --- ### `backend/ai/openai_provider.py` (MODIFY — singleton + context_chars) **Analog:** self (current file) **Changes from current pattern:** Current `__init__` (lines 9–15): ```python # CURRENT — creates client per-call (anti-pattern to replace) def __init__(self, api_key: str, model: str = "gpt-4o", base_url=None): self._api_key = api_key self._model = model self._base_url = base_url def _client(self) -> AsyncOpenAI: return AsyncOpenAI(api_key=self._api_key or "placeholder", base_url=self._base_url) ``` Replace with (D-07 singleton pattern): ```python # TARGET — singleton stored in __init__ def __init__(self, api_key: str, model: str, base_url: str | None, context_chars: int): self._api_key = api_key or "not-needed" # SDK 2.34+ rejects empty string self._model = model self._base_url = base_url self._context_chars = context_chars self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url) ``` Remove `MAX_AI_CHARS = 8_000` constant (line 5). Replace `document_text[:MAX_AI_CHARS]` with `self._truncate(document_text)`. Add `_truncate()` method (same as GenericOpenAIProvider above). Remove `_client()` method entirely. Update all `self._client()` call-sites to `self._client` (no `()`). --- ### `backend/ai/anthropic_provider.py` (MODIFY — singleton + output_config) **Analog:** self (current file) + `backend/ai/openai_provider.py` **Current anti-pattern** (lines 9–14): ```python # CURRENT — creates client per-call (anti-pattern to replace) def __init__(self, api_key: str, model: str = "claude-sonnet-4-6"): self._api_key = api_key self._model = model def _client(self): return anthropic.AsyncAnthropic(api_key=self._api_key) ``` **Target pattern** (D-07 + D-03 output_config): ```python # TARGET — singleton + context_chars + output_config def __init__(self, api_key: str, model: str, context_chars: int): self._api_key = api_key self._model = model self._context_chars = context_chars self._client = anthropic.AsyncAnthropic(api_key=self._api_key) ``` Remove `MAX_AI_CHARS = 8_000` (line 5). Replace `document_text[:MAX_AI_CHARS]` with `self._truncate(document_text)`. Add `_truncate()` method. Remove `_client()` method. Replace `client = self._client()` call-sites with direct `self._client`. **output_config pattern for classify** (from RESEARCH.md D-03): ```python # Add to messages.create() call in classify(): output_config={ "format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA} }, ``` `_CLASSIFICATION_SCHEMA` defined as module-level constant. `response.content[0].text` read only when `response.stop_reason == "end_turn"`; otherwise fall back to `parse_classification("")`. --- ### `backend/ai/ollama_provider.py` / `lmstudio_provider.py` (MODIFY — context_chars) **Analog:** `backend/ai/ollama_provider.py` (entire file — 10 lines) **Current pattern** (lines 1–11): ```python from ai.openai_provider import OpenAIProvider class OllamaProvider(OpenAIProvider): def __init__(self, base_url: str = "http://host.docker.internal:11434", model: str = "llama3.2"): super().__init__( api_key="ollama", model=model, base_url=base_url.rstrip("/") + "/v1", ) ``` These one-liner subclasses become aliases in the provider registry (both route to `GenericOpenAIProvider`). The files themselves may be reduced to empty stubs or removed if `get_provider()` registry handles instantiation directly. If kept, update `super().__init__()` to pass `context_chars=8_000` as a default. --- ### `backend/ai/__init__.py` (REFACTOR — registry pattern) **Analog:** self (current file — lines 1–35) **Current anti-pattern** (lines 8–35): ```python # CURRENT — flat if/elif chain def get_provider(settings: dict) -> AIProvider: active = settings.get("active_provider", "lmstudio") providers = settings.get("providers", {}) cfg = providers.get(active, {}) if active == "anthropic": return AnthropicProvider(...) elif active == "openai": ... else: raise ValueError(f"Unknown AI provider: {active}") ``` **Target registry pattern** (from RESEARCH.md get_provider() Registry Pattern): ```python # TARGET — O(1) dict lookup, typed ProviderConfig input from ai.provider_config import ProviderConfig _REGISTRY: dict[str, type] = { "openai": OpenAIProvider, "anthropic": AnthropicProvider, "gemini": GenericOpenAIProvider, # ... all providers } def get_provider(config: ProviderConfig) -> AIProvider: cls = _REGISTRY.get(config.provider_id) if cls is None: raise ValueError(f"Unknown AI provider: {config.provider_id!r}") # AnthropicProvider does not take base_url — handle in factory if config.provider_id == "anthropic": return cls(api_key=config.api_key or "not-needed", model=config.model, context_chars=config.context_chars) return cls(api_key=config.api_key or "not-needed", model=config.model, base_url=config.base_url, context_chars=config.context_chars) ``` --- ### `backend/db/models.py` — ADD `SystemSettings` model **Analog:** `backend/db/models.py` — `CloudConnection` model (lines 299–318) **Closest existing model pattern** (lines 299–318): ```python # From backend/db/models.py lines 299–318 class CloudConnection(Base): __tablename__ = "cloud_connections" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) provider: Mapped[str] = mapped_column(String, nullable=False) credentials_enc: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False, default="ACTIVE") connected_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (Index("ix_cloud_connections_user", "user_id"),) ``` **Target SystemSettings model** — same `Mapped[]` typed column style, same `server_default=func.now()` for timestamps, same `UniqueConstraint` in `__table_args__`. No FK (global table, not per-user). Columns: `id` (UUID PK), `provider_id` (String UNIQUE NOT NULL), `api_key_enc` (Text nullable), `base_url` (Text nullable), `model_name` (Text NOT NULL default ""), `context_chars` (Integer NOT NULL default 8000), `is_active` (Boolean NOT NULL default False), `created_at` (TIMESTAMP), `updated_at` (TIMESTAMP). **Import additions needed** (from top of models.py, lines 25–38): ```python # Already imported — no new imports needed: # Boolean, String, Text, TIMESTAMP, UniqueConstraint, Integer # UUID, Mapped, mapped_column, func ``` --- ### `backend/migrations/versions/0005_system_settings.py` (NEW — migration) **Analog:** `backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py` (entire file — 86 lines) **Header pattern** (analog lines 1–36): ```python """Phase 4 schema additions: ...""" from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "0004" down_revision = "0003" branch_labels = None depends_on = None ``` New migration uses: `revision = "0005"`, `down_revision = "0004"`. **`op.create_table()` pattern** (from analog + `backend/migrations/versions/0001_initial_schema.py` lines 38–55): ```python # From 0001 lines 38–55 — op.create_table with postgresql.UUID op.create_table("users", sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("handle", sa.String(), nullable=False), ... sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("email", name="uq_users_email"), ) ``` New migration uses `sa.dialects.postgresql.UUID(as_uuid=True)` for the `id` column, `sa.text("gen_random_uuid()")` as `server_default` for `id`, and `sa.UniqueConstraint("provider_id", name="uq_system_settings_provider_id")` in the column list. **downgrade pattern** (analog lines 77–86): ```python def downgrade() -> None: op.drop_table("system_settings") ``` --- ### `backend/services/ai_config.py` (NEW — service, CRUD + encrypt) **Analog:** `backend/storage/cloud_utils.py` (entire file — 181 lines) **Module docstring pattern** (analog lines 1–19): ```python """ Cloud storage shared utilities for DocuVault. Security design: HKDF credential encryption (D-18, CLOUD-02): _derive_fernet_key() creates a FRESH HKDF instance on every call. The cryptography library raises AlreadyFinalized if .derive() is called twice on the same instance. ... """ from __future__ import annotations import base64 from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF ``` **HKDF key derivation pattern** (analog lines 114–141 — COPY VERBATIM, change salt + info): ```python # From backend/storage/cloud_utils.py lines 114–141 def _derive_fernet_key(master_key: bytes, user_id: str) -> Fernet: # FRESH HKDF instance on every call — AlreadyFinalized pitfall hkdf = HKDF( algorithm=hashes.SHA256(), length=32, salt=user_id.encode("utf-8"), info=b"cloud-credentials", # <-- change to b"ai-provider-settings" in new file ) raw_key: bytes = hkdf.derive(master_key) fernet_key = base64.urlsafe_b64encode(raw_key) return Fernet(fernet_key) ``` In `ai_config.py`, function is renamed `_derive_ai_settings_key(master_key, provider_id)`. The salt is `provider_id.encode("utf-8")`. The info is `b"ai-provider-settings"` — different from `b"cloud-credentials"` (domain separation). Same AlreadyFinalized comment must be retained. **encrypt/decrypt pattern** (analog lines 144–181): ```python # From backend/storage/cloud_utils.py lines 144–161 def encrypt_credentials(master_key: bytes, user_id: str, credentials: dict) -> str: f = _derive_fernet_key(master_key, user_id) plaintext = json.dumps(credentials).encode("utf-8") return f.encrypt(plaintext).decode("utf-8") def decrypt_credentials(master_key: bytes, user_id: str, credentials_enc: str) -> dict: f = _derive_fernet_key(master_key, user_id) plaintext = f.decrypt(credentials_enc.encode("utf-8")) return json.loads(plaintext) ``` In `ai_config.py`: `encrypt_api_key(master_key, provider_id, api_key: str) -> str` (encrypt a plain string, not a dict — use `.encode()` / `.decode()` directly without `json.dumps`). `decrypt_api_key(master_key, provider_id, api_key_enc: str) -> str`. **Additional function `load_provider_config(session)`** — no analog; use SQLAlchemy `select()` + `where(SystemSettings.is_active == True)` to load the active row, decrypt the key, construct `ProviderConfig`. Pattern for `session.execute(select(...).where(...))` from `backend/api/admin.py` lines 163–167: ```python # From backend/api/admin.py lines 163–167 result = await session.execute( select(User).order_by(User.created_at.desc()) ) users = result.scalars().all() ``` --- ### `backend/services/classifier.py` (REFACTOR — ProviderConfig injection) **Analog:** self (current file — 128 lines) **Current inline dict construction** (lines 57–64 — the anti-pattern to replace): ```python # CURRENT — lines 57–64 in backend/services/classifier.py _ai_provider = ai_provider or app_settings.default_ai_provider _ai_model = ai_model or app_settings.default_ai_model system_prompt = app_settings.system_prompt or _DEFAULT_SYSTEM_PROMPT _settings = { "active_provider": _ai_provider, "providers": {_ai_provider: {"model": _ai_model}}, } provider = get_provider(_settings) ``` **Target pattern** (replace with `load_provider_config()` call): ```python # TARGET — ProviderConfig loaded from DB (D-06) from services.ai_config import load_provider_config config = await load_provider_config(session) # Per-user override: if user has ai_provider set, build a ProviderConfig override provider = get_provider(config) ``` Remove `MAX_AI_CHARS = 8_000` (line 28). Remove `text[:MAX_AI_CHARS]` (line 85) — truncation now lives in the provider's `_truncate()`. Remove the `ai_provider: str | None` and `ai_model: str | None` kwargs from both `classify_document()` and `suggest_topics_for_document()` signatures (or retain them as optional overrides for per-user assignment — see RESEARCH.md Runtime State Inventory note about per-user `ai_provider` column). --- ### `backend/tasks/document_tasks.py` (REFACTOR — Celery retry) **Analog:** self (current file — 173 lines) **Current task decorator** (lines 22–25): ```python # CURRENT — no bind, no retry @celery_app.task(name="tasks.document_tasks.extract_and_classify") def extract_and_classify(document_id: str) -> dict: return asyncio.run(_run(document_id)) ``` **Target decorator** (D-09 — bind=True + max_retries): ```python # TARGET @celery_app.task( name="tasks.document_tasks.extract_and_classify", bind=True, max_retries=3, ) def extract_and_classify(self, document_id: str) -> dict: try: return asyncio.run(_run(document_id)) except _ClassificationError as exc: countdowns = [30, 90, 270] countdown = countdowns[min(self.request.retries, 2)] raise self.retry(exc=exc, countdown=countdown) ``` `_ClassificationError` is a new module-level sentinel exception class (defined above the task). The existing `except Exception as e: doc.status = "classification_failed"` block in `_run()` (lines 109–124) is replaced: instead of catching and returning a dict, `_run()` raises `_ClassificationError(str(e))` for classification failures specifically (not for extract/storage failures which still `return {...}`). On `MaxRetriesExceededError`, write `classification_failed` to DB via a new `_mark_classification_failed(document_id)` async helper. **Existing non-retryable return pattern to preserve** (lines 47–106 — storage/extract failures): ```python # From backend/tasks/document_tasks.py lines 47–50 — keep unchanged try: doc_uuid = _uuid.UUID(document_id) except ValueError: return {"document_id": document_id, "status": "invalid_id"} ``` All `return {... "status": "extract_failed" ...}` patterns in the retrieval and extraction steps are preserved — they are not retried. --- ### `frontend/src/components/admin/AdminAiConfigTab.vue` (MODIFY — add global config section) **Analog:** self (current file — 136 lines) + `frontend/src/components/admin/AdminUsersTab.vue` **Current imports/setup pattern** (lines 76–94): ```javascript // From frontend/src/components/admin/AdminAiConfigTab.vue lines 76–94 import { ref, reactive, onMounted } from 'vue' import * as api from '../../api/client.js' const users = ref([]) const loading = ref(false) const loadError = ref(null) const savingId = ref(null) const savedId = ref(null) const configs = reactive({}) ``` **Save function pattern** (lines 96–114): ```javascript // From frontend/src/components/admin/AdminAiConfigTab.vue lines 96–114 async function saveConfig(userId) { savingId.value = userId savedId.value = null try { await api.adminUpdateAiConfig(userId, configs[userId].provider || null, configs[userId].model || null) savedId.value = userId setTimeout(() => { if (savedId.value === userId) savedId.value = null }, 1500) } catch (e) { loadError.value = e.message } finally { savingId.value = null } } ``` New global config section adds a second `ref()` block (`systemConfig`, `savingSystem`, `savedSystem`) and a `saveSystemConfig()` function following the same try/catch/finally shape. New API functions `api.getAiConfig()` and `api.saveAiConfig(body)` are called instead of `adminUpdateAiConfig`. **Loading state template pattern** (lines 2–9): ```html