TODO.md L4: with a dead model endpoint the sync discovered it only mid-import, after slow clones — and a tooltip on the button is not a readable error. - app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny pre-sync probe (one short embedding + one 1-token-scale completion) that fails naming the unavailable model (embed first, then the summary model); the sync sanitizer still masks credentials. - app/api/sync.py: the probe is step 1 of _run_sync — before source resolution and before any clone_or_pull; a model failure is just another 'failed' state (no new endpoint, A10/A12 untouched). - frontend/assets/header.js: applySyncFailure now also opens the module-owned error modal (every page carrying #sync-btn, zero page-markup changes): lazily built backdrop + role=alertdialog panel, error text via textContent, close via button / Esc / backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn fallback — the run's disabled button drops focus to <body>). - frontend/assets/styles.css: the modal on the phase-08 error palette (z-index above the header, .is-open open/close, reduced-motion stilling, 44px close target). - Tests: probe unit tests (both up / embed down / summary down / custom model names), sync integration (fail-fast before any clone, probe-before-effective_sources ordering, credential masking, healthy regression), the phase-41 source pins, and the story E2E (two module apps on distinct ports — dead endpoint on a closed loopback port vs session mock: ≤10 s fail-fast + modal contract, all three dismissal paths with focus out to #sync-btn, button title/.is-error + Sources banner untouched, healthy phase-32 lifecycle regression to 'Synced HH:MM'). E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py 3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py 3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright clean.
47 lines
1.9 KiB
Python
47 lines
1.9 KiB
Python
"""Shared test fakes (no network, deterministic)."""
|
|
from __future__ import annotations
|
|
|
|
from app.config import Settings
|
|
from app.rag.llm import LLMError
|
|
|
|
|
|
class FakeEmbedder:
|
|
"""Duck-typed stand-in for :class:`app.rag.llm.LLMClient` (see the
|
|
``Embedder`` protocol in :mod:`app.rag.importer`).
|
|
|
|
Returns deterministic vectors of *dim* dimensions; records every call
|
|
so tests can assert batching behaviour. ``chat`` is the deterministic
|
|
``lite``-model stand-in (phase 30): it returns
|
|
``"Summary of <first token of the user content>"`` and raises
|
|
:class:`LLMError` when the content contains the sentinel word
|
|
``SUMMARY-BLOWUP`` (drives the importer's fail-soft summary path).
|
|
"""
|
|
|
|
def __init__(self, dim: int = 768) -> None:
|
|
self.dim = dim
|
|
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
|
|
self.embed_batches = 0
|
|
self.calls: list[list[str]] = []
|
|
self.chat_calls: list[list[dict[str, str]]] = []
|
|
|
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
self.calls.append(list(texts))
|
|
self.embed_batches += 1
|
|
return [[0.01 * (i % 97) for i in range(self.dim)] for _ in texts]
|
|
|
|
async def embed_one(self, text: str) -> list[float]:
|
|
"""The retrieval/probe convenience path — delegates to :meth:`embed`
|
|
(satisfies the phase-41 pre-sync model probe)."""
|
|
(vec,) = await self.embed([text])
|
|
return vec
|
|
|
|
async def chat(
|
|
self, messages: list[dict[str, str]], model: str | None = None
|
|
) -> str:
|
|
self.chat_calls.append(list(messages))
|
|
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
|
if "SUMMARY-BLOWUP" in user:
|
|
raise LLMError("simulated lite-model failure (SUMMARY-BLOWUP sentinel)")
|
|
first = user.split()
|
|
return "Summary of " + (first[0] if first else "<empty>")
|