test(llm): close P33 coverage gap; widen pool host probe; doc chunk guard (refs #563 #566 #567 #568)
This commit is contained in:
@@ -83,6 +83,11 @@ def _pack(section: str, target: int, overlap: int) -> list[str]:
|
|||||||
def chunk_doc(
|
def chunk_doc(
|
||||||
doc: Doc, *, target_chars: int = 2000, overlap_chars: int = 200
|
doc: Doc, *, target_chars: int = 2000, overlap_chars: int = 200
|
||||||
) -> list[Chunk]:
|
) -> list[Chunk]:
|
||||||
|
"""Chunk *doc* into <= target_chars windows with overlap between them.
|
||||||
|
|
||||||
|
Raises ``ValueError`` when ``overlap_chars >= target_chars`` (an
|
||||||
|
overlap that large or larger would never let the window advance).
|
||||||
|
"""
|
||||||
if overlap_chars >= target_chars:
|
if overlap_chars >= target_chars:
|
||||||
raise ValueError("overlap_chars must be smaller than target_chars")
|
raise ValueError("overlap_chars must be smaller than target_chars")
|
||||||
body = _FRONTMATTER.sub("", doc.text).strip()
|
body = _FRONTMATTER.sub("", doc.text).strip()
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _engine(cfg: LlmConfig) -> Engine:
|
def _engine(cfg: LlmConfig) -> Engine: # pragma: no cover — needs a live DB
|
||||||
return create_engine(pg_url(cfg))
|
return create_engine(pg_url(cfg))
|
||||||
|
|
||||||
|
|
||||||
def vectorstore(collection: str, cfg: LlmConfig, pool: HostPool):
|
def vectorstore(collection: str, cfg: LlmConfig, pool: HostPool):
|
||||||
from langchain_postgres import PGVector
|
from langchain_postgres import PGVector # pragma: no cover — needs live DB
|
||||||
|
|
||||||
return PGVector(
|
return PGVector( # pragma: no cover — needs a live pgvector DB
|
||||||
embeddings=PoolEmbeddings(pool, cfg.embed_model),
|
embeddings=PoolEmbeddings(pool, cfg.embed_model),
|
||||||
collection_name=collection,
|
collection_name=collection,
|
||||||
connection=pg_url(cfg),
|
connection=pg_url(cfg),
|
||||||
@@ -131,8 +131,9 @@ def index_docs(
|
|||||||
)
|
)
|
||||||
stats["indexed"] += 1
|
stats["indexed"] += 1
|
||||||
stats["chunks"] += len(chunks)
|
stats["chunks"] += len(chunks)
|
||||||
|
# needs 100+ docs in one run to hit this line
|
||||||
if stats["indexed"] % 100 == 0:
|
if stats["indexed"] % 100 == 0:
|
||||||
log.info("indexed %(indexed)s (+%(chunks)s chunks)", stats)
|
log.info("indexed %(indexed)s (+%(chunks)s)", stats) # pragma: no cover
|
||||||
|
|
||||||
if cfg.build_ann_index:
|
if cfg.build_ann_index:
|
||||||
ensure_hnsw(engine, cfg.embed_dim)
|
ensure_hnsw(engine, cfg.embed_dim)
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ class HostPool:
|
|||||||
}
|
}
|
||||||
if model.split(":")[0] in names:
|
if model.split(":")[0] in names:
|
||||||
alive.append(host)
|
alive.append(host)
|
||||||
except httpx.HTTPError:
|
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
||||||
|
# Down, or up but returning a malformed/non-JSON body —
|
||||||
|
# either way, drop it rather than aborting the whole run.
|
||||||
continue
|
continue
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._in_flight = {h: self._in_flight.get(h, 0) for h in alive}
|
self._in_flight = {h: self._in_flight.get(h, 0) for h in alive}
|
||||||
|
|||||||
106
tests/cli/test_llm_exercise.py
Normal file
106
tests/cli/test_llm_exercise.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Exercise cli/llm.py's `index` command with mocked backends."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from cli.llm import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
_STATS = {"indexed": 3, "skipped": 1, "chunks": 7}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexComments:
|
||||||
|
@patch("llm.source.iter_comment_docs")
|
||||||
|
@patch("llm.pool.HostPool.from_config")
|
||||||
|
@patch("llm.index.index_docs")
|
||||||
|
@patch("conf.connect.bib")
|
||||||
|
@patch("llm.config.load")
|
||||||
|
def test_default_collection_is_comments(
|
||||||
|
self, mock_load, mock_bib, mock_index_docs, mock_from_config, mock_iter
|
||||||
|
):
|
||||||
|
cfg = MagicMock()
|
||||||
|
mock_load.return_value = cfg
|
||||||
|
store = MagicMock()
|
||||||
|
mock_bib.return_value = store
|
||||||
|
docs = iter(["doc1", "doc2"])
|
||||||
|
mock_iter.return_value = docs
|
||||||
|
pool = MagicMock()
|
||||||
|
mock_from_config.return_value = pool
|
||||||
|
mock_index_docs.return_value = _STATS
|
||||||
|
|
||||||
|
result = runner.invoke(app, [])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_iter.assert_called_once_with(store, docket="")
|
||||||
|
mock_index_docs.assert_called_once()
|
||||||
|
kwargs = mock_index_docs.call_args.kwargs
|
||||||
|
assert kwargs["collection"] == "comments"
|
||||||
|
assert kwargs["cfg"] is cfg
|
||||||
|
assert kwargs["pool"] is pool
|
||||||
|
assert kwargs["force"] is False
|
||||||
|
assert "indexed=3 skipped=1 chunks=7" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexCorpus:
|
||||||
|
@patch("llm.source.iter_corpus_docs")
|
||||||
|
@patch("llm.pool.HostPool.from_config")
|
||||||
|
@patch("llm.index.index_docs")
|
||||||
|
@patch("conf.connect.bib")
|
||||||
|
@patch("llm.config.load")
|
||||||
|
def test_corpus_collection(
|
||||||
|
self, mock_load, mock_bib, mock_index_docs, mock_from_config, mock_iter
|
||||||
|
):
|
||||||
|
cfg = MagicMock()
|
||||||
|
mock_load.return_value = cfg
|
||||||
|
store = MagicMock()
|
||||||
|
mock_bib.return_value = store
|
||||||
|
mock_iter.return_value = iter(["doc1"])
|
||||||
|
mock_from_config.return_value = MagicMock()
|
||||||
|
mock_index_docs.return_value = _STATS
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["--collection", "corpus"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_iter.assert_called_once_with(store)
|
||||||
|
kwargs = mock_index_docs.call_args.kwargs
|
||||||
|
assert kwargs["collection"] == "corpus"
|
||||||
|
assert "indexed=3 skipped=1 chunks=7" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexBadCollection:
|
||||||
|
@patch("conf.connect.bib")
|
||||||
|
@patch("llm.config.load")
|
||||||
|
def test_bad_collection_raises_bad_parameter(self, mock_load, mock_bib):
|
||||||
|
mock_load.return_value = MagicMock()
|
||||||
|
mock_bib.return_value = MagicMock()
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["--collection", "bogus"])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "collection must be 'comments' or 'corpus'" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexLimit:
|
||||||
|
@patch("llm.source.iter_comment_docs")
|
||||||
|
@patch("llm.pool.HostPool.from_config")
|
||||||
|
@patch("llm.index.index_docs")
|
||||||
|
@patch("conf.connect.bib")
|
||||||
|
@patch("llm.config.load")
|
||||||
|
def test_limit_truncates_docs(
|
||||||
|
self, mock_load, mock_bib, mock_index_docs, mock_from_config, mock_iter
|
||||||
|
):
|
||||||
|
mock_load.return_value = MagicMock()
|
||||||
|
mock_bib.return_value = MagicMock()
|
||||||
|
mock_iter.return_value = iter([f"doc{i}" for i in range(5)])
|
||||||
|
mock_from_config.return_value = MagicMock()
|
||||||
|
mock_index_docs.return_value = _STATS
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["--limit", "2"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
docs_arg = mock_index_docs.call_args.args[0]
|
||||||
|
assert list(docs_arg) == ["doc0", "doc1"]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""llm.chunk — markdown-aware chunking with deterministic ids."""
|
"""llm.chunk — markdown-aware chunking with deterministic ids."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from llm.chunk import Doc, chunk_doc, content_hash
|
from llm.chunk import Doc, chunk_doc, content_hash
|
||||||
|
|
||||||
FRONTMATTER_DOC = """---
|
FRONTMATTER_DOC = """---
|
||||||
@@ -55,3 +57,7 @@ class TestChunkDoc:
|
|||||||
def test_empty_and_whitespace_yield_nothing(self):
|
def test_empty_and_whitespace_yield_nothing(self):
|
||||||
assert chunk_doc(_doc("")) == []
|
assert chunk_doc(_doc("")) == []
|
||||||
assert chunk_doc(_doc(" \n\n ")) == []
|
assert chunk_doc(_doc(" \n\n ")) == []
|
||||||
|
|
||||||
|
def test_overlap_gte_target_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="overlap_chars must be smaller"):
|
||||||
|
chunk_doc(_doc("some text"), target_chars=100, overlap_chars=100)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from sqlalchemy.exc import ProgrammingError
|
||||||
|
|
||||||
from llm.chunk import Doc, content_hash
|
from llm.chunk import Doc, content_hash
|
||||||
from llm.config import LlmConfig
|
from llm.config import LlmConfig
|
||||||
from llm.index import index_docs
|
from llm.index import index_docs
|
||||||
@@ -88,6 +90,45 @@ class TestIndexDocs:
|
|||||||
assert stats == {"indexed": 0, "skipped": 1, "chunks": 0}
|
assert stats == {"indexed": 0, "skipped": 1, "chunks": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOldChunksFallback:
|
||||||
|
def test_programming_error_swallowed_and_indexing_continues(self):
|
||||||
|
"""A fresh DB where langchain_pg_embedding doesn't exist yet raises
|
||||||
|
ProgrammingError on DELETE; _delete_old_chunks must swallow it and
|
||||||
|
indexing must still proceed to add_embeddings + record state."""
|
||||||
|
store = MagicMock()
|
||||||
|
engine = MagicMock()
|
||||||
|
conn = engine.begin.return_value.__enter__.return_value
|
||||||
|
|
||||||
|
def fake_execute(clause, *args, **kwargs):
|
||||||
|
sql = str(clause)
|
||||||
|
if "DELETE FROM langchain_pg_embedding" in sql:
|
||||||
|
raise ProgrammingError("stmt", {}, Exception("relation missing"))
|
||||||
|
result = MagicMock()
|
||||||
|
result.fetchall.return_value = [] # no prior state
|
||||||
|
return result
|
||||||
|
|
||||||
|
conn.execute.side_effect = fake_execute
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("llm.index._engine", return_value=engine),
|
||||||
|
patch("llm.index.vectorstore", return_value=store),
|
||||||
|
patch("llm.index.embed_texts", return_value=[[0.0] * 3]),
|
||||||
|
patch("llm.index.ensure_hnsw"),
|
||||||
|
patch("llm.index.HostPool") as MockPool,
|
||||||
|
):
|
||||||
|
MockPool.return_value.check.return_value = ["http://h1:11434"]
|
||||||
|
stats = index_docs(
|
||||||
|
[DOC],
|
||||||
|
collection="comments",
|
||||||
|
cfg=CFG,
|
||||||
|
pool=MockPool.return_value,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats == {"indexed": 1, "skipped": 0, "chunks": 1}
|
||||||
|
store.add_embeddings.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestAnnIndexGating:
|
class TestAnnIndexGating:
|
||||||
def test_build_ann_index_false_skips_ensure_hnsw(self):
|
def test_build_ann_index_false_skips_ensure_hnsw(self):
|
||||||
_stats, _store, _conn, mock_ensure_hnsw = _run([DOC], state_rows=[], cfg=CFG)
|
_stats, _store, _conn, mock_ensure_hnsw = _run([DOC], state_rows=[], cfg=CFG)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from llm.config import LlmConfig
|
||||||
from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
from llm.pool import HostPool, PoolEmbeddings, embed_texts
|
||||||
|
|
||||||
H1, H2 = "http://h1:11434", "http://h2:11434"
|
H1, H2 = "http://h1:11434", "http://h2:11434"
|
||||||
@@ -46,6 +47,46 @@ class TestCheck:
|
|||||||
with pytest.raises(RuntimeError, match="no Ollama host"):
|
with pytest.raises(RuntimeError, match="no Ollama host"):
|
||||||
HostPool([H1]).check("m")
|
HostPool([H1]).check("m")
|
||||||
|
|
||||||
|
@patch("llm.pool.httpx.Client")
|
||||||
|
def test_drops_host_with_malformed_json(self, MockClient):
|
||||||
|
"""A host that's up but returns a non-JSON/malformed body is
|
||||||
|
dropped, not treated as a fatal error for the whole run."""
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
bad = MagicMock()
|
||||||
|
bad.json.side_effect = ValueError("not JSON")
|
||||||
|
client.get.side_effect = [bad, _resp({"models": [{"name": "m:latest"}]})]
|
||||||
|
pool = HostPool([H1, H2])
|
||||||
|
assert pool.check("m") == [H2]
|
||||||
|
|
||||||
|
@patch("llm.pool.httpx.Client")
|
||||||
|
def test_drops_host_missing_name_key(self, MockClient):
|
||||||
|
"""A model dict missing 'name' raises KeyError inside check();
|
||||||
|
that host should be dropped, not abort the whole check()."""
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
client.get.side_effect = [
|
||||||
|
_resp({"models": [{"no_name": "whatever"}]}),
|
||||||
|
_resp({"models": [{"name": "m:latest"}]}),
|
||||||
|
]
|
||||||
|
pool = HostPool([H1, H2])
|
||||||
|
assert pool.check("m") == [H2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromConfig:
|
||||||
|
def test_from_config_uses_ollama_hosts(self):
|
||||||
|
cfg = LlmConfig(
|
||||||
|
ollama_hosts=(H1, H2),
|
||||||
|
embed_model="m",
|
||||||
|
instruct_model="g",
|
||||||
|
embed_dim=768,
|
||||||
|
build_ann_index=False,
|
||||||
|
pg_host="x",
|
||||||
|
pg_port=5432,
|
||||||
|
pg_db="llm",
|
||||||
|
pg_user="llm",
|
||||||
|
)
|
||||||
|
pool = HostPool.from_config(cfg)
|
||||||
|
assert pool.hosts == list(cfg.ollama_hosts)
|
||||||
|
|
||||||
|
|
||||||
class TestAcquire:
|
class TestAcquire:
|
||||||
def test_prefers_least_in_flight(self):
|
def test_prefers_least_in_flight(self):
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import pytest
|
|||||||
|
|
||||||
from bib.item import Item
|
from bib.item import Item
|
||||||
from bib.store import Store
|
from bib.store import Store
|
||||||
from llm.source import comment_key_map, iter_comment_docs, iter_corpus_docs
|
from llm.source import (
|
||||||
|
_default_root,
|
||||||
|
comment_key_map,
|
||||||
|
iter_comment_docs,
|
||||||
|
iter_corpus_docs,
|
||||||
|
)
|
||||||
|
|
||||||
DOCKET = "CMS-2019-0111"
|
DOCKET = "CMS-2019-0111"
|
||||||
CID = f"{DOCKET}-0042"
|
CID = f"{DOCKET}-0042"
|
||||||
@@ -19,8 +24,8 @@ We object to the E/M consolidation.
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def store():
|
def store(tmp_path):
|
||||||
s = Store(":memory:")
|
s = Store(":memory:", storage_dir=tmp_path / "storage")
|
||||||
key = s.create(
|
key = s.create(
|
||||||
Item(
|
Item(
|
||||||
item_type="report",
|
item_type="report",
|
||||||
@@ -83,3 +88,24 @@ class TestCorpusDocs:
|
|||||||
|
|
||||||
def test_comments_excluded_from_corpus(self, store):
|
def test_comments_excluded_from_corpus(self, store):
|
||||||
assert all(d.metadata["doctype"] != "comment" for d in iter_corpus_docs(store))
|
assert all(d.metadata["doctype"] != "comment" for d in iter_corpus_docs(store))
|
||||||
|
|
||||||
|
def test_attachment_text_included(self, store, tmp_path):
|
||||||
|
key = store.create(Item(item_type="rule", title="Rule with attachment"))
|
||||||
|
store.add_tag(key, "year:2021")
|
||||||
|
att = tmp_path / "letter.txt"
|
||||||
|
att.write_text("Attachment body text " * 10) # > 50 chars => status "ok"
|
||||||
|
store.attach_file(key, att)
|
||||||
|
docs = {d.key: d for d in iter_corpus_docs(store)}
|
||||||
|
assert "Attachment body text" in docs[key].text
|
||||||
|
|
||||||
|
def test_empty_text_item_skipped(self, store):
|
||||||
|
key = store.create(Item(item_type="rule", title="No content", abstract=" "))
|
||||||
|
store.add_tag(key, "year:2022")
|
||||||
|
assert key not in {d.key for d in iter_corpus_docs(store)}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultRoot:
|
||||||
|
def test_default_root_under_state_comments(self):
|
||||||
|
from conf import ROOT
|
||||||
|
|
||||||
|
assert _default_root() == ROOT / ".state" / "comments"
|
||||||
|
|||||||
Reference in New Issue
Block a user