* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
193 lines
7.2 KiB
Python
193 lines
7.2 KiB
Python
"""
|
|
Tests for per-bank HNSW index lifecycle and UNION ALL retrieval.
|
|
|
|
Covers:
|
|
- _hnsw_index_name deterministic naming
|
|
- Per-bank HNSW indexes created on bank creation (retain_async / ensure_bank_exists)
|
|
- Per-bank HNSW indexes dropped on bank deletion
|
|
- retrieve_semantic_bm25_combined groups results correctly by fact_type and source
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from hindsight_api.engine.retain.bank_utils import _HNSW_FACT_TYPES, _hnsw_index_name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests — no DB required
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHnswIndexName:
|
|
def test_deterministic(self):
|
|
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
assert _hnsw_index_name("world", uid) == _hnsw_index_name("world", uid)
|
|
|
|
def test_strips_dashes(self):
|
|
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
name = _hnsw_index_name("world", uid)
|
|
# uid16 should be hex chars only
|
|
assert "-" not in name
|
|
|
|
def test_uses_first_16_hex_chars(self):
|
|
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
uid16 = uid.replace("-", "")[:16] # "550e8400e29b41d4"
|
|
assert name_ends_with(name=_hnsw_index_name("world", uid), suffix=uid16)
|
|
|
|
def test_suffix_per_fact_type(self):
|
|
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
names = {ft: _hnsw_index_name(ft, uid) for ft in _HNSW_FACT_TYPES}
|
|
# All three names must be distinct
|
|
assert len(set(names.values())) == 3
|
|
|
|
def test_all_fact_types_covered(self):
|
|
assert set(_HNSW_FACT_TYPES) == {"world", "experience", "observation"}
|
|
|
|
def test_fits_pg_identifier_limit(self):
|
|
# PostgreSQL max identifier length is 63 chars
|
|
uid = "f" * 32 # simulated UUID without dashes
|
|
for ft in _HNSW_FACT_TYPES:
|
|
assert len(_hnsw_index_name(ft, uid)) <= 63
|
|
|
|
|
|
def name_ends_with(name: str, suffix: str) -> bool:
|
|
return name.endswith(suffix)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests — require DB (memory fixture)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _get_bank_hnsw_indexes(pool, bank_id: str) -> list[str]:
|
|
"""Return index names for memory_units that match the per-bank pattern."""
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT indexname
|
|
FROM pg_indexes
|
|
WHERE tablename = 'memory_units'
|
|
AND indexname LIKE 'idx_mu_emb_%'
|
|
AND indexdef LIKE $1
|
|
ORDER BY indexname
|
|
""",
|
|
f"%bank_id = '{bank_id}'%",
|
|
)
|
|
return [row["indexname"] for row in rows]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retain_creates_per_bank_hnsw_indexes(memory, request_context):
|
|
"""retain_async on a new bank must create 3 per-(bank, fact_type) HNSW indexes."""
|
|
bank_id = f"test_hnsw_create_{uuid.uuid4().hex[:8]}"
|
|
try:
|
|
await memory.retain_async(
|
|
bank_id=bank_id,
|
|
content="Alice is a software engineer.",
|
|
request_context=request_context,
|
|
)
|
|
indexes = await _get_bank_hnsw_indexes(memory._pool, bank_id)
|
|
assert len(indexes) == 3, f"Expected 3 per-bank HNSW indexes, got: {indexes}"
|
|
for ft_short in _HNSW_FACT_TYPES.values():
|
|
assert any(ft_short in idx for idx in indexes), (
|
|
f"Missing index for fact_type short '{ft_short}' in {indexes}"
|
|
)
|
|
finally:
|
|
await memory.delete_bank(bank_id, request_context=request_context)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_bank_drops_hnsw_indexes(memory, request_context):
|
|
"""delete_bank must drop all per-bank HNSW indexes."""
|
|
bank_id = f"test_hnsw_drop_{uuid.uuid4().hex[:8]}"
|
|
|
|
await memory.retain_async(
|
|
bank_id=bank_id,
|
|
content="Bob is a data scientist.",
|
|
request_context=request_context,
|
|
)
|
|
# Verify indexes exist before deletion
|
|
indexes_before = await _get_bank_hnsw_indexes(memory._pool, bank_id)
|
|
assert len(indexes_before) == 3
|
|
|
|
await memory.delete_bank(bank_id, request_context=request_context)
|
|
|
|
indexes_after = await _get_bank_hnsw_indexes(memory._pool, bank_id)
|
|
assert indexes_after == [], f"Indexes should be dropped after bank deletion, got: {indexes_after}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retain_idempotent_bank_creation(memory, request_context):
|
|
"""Retaining into the same bank twice must not error and still have exactly 3 indexes."""
|
|
bank_id = f"test_hnsw_idem_{uuid.uuid4().hex[:8]}"
|
|
try:
|
|
await memory.retain_async(
|
|
bank_id=bank_id,
|
|
content="Carol is a product manager.",
|
|
request_context=request_context,
|
|
)
|
|
await memory.retain_async(
|
|
bank_id=bank_id,
|
|
content="Carol joined the company in 2022.",
|
|
request_context=request_context,
|
|
)
|
|
indexes = await _get_bank_hnsw_indexes(memory._pool, bank_id)
|
|
assert len(indexes) == 3
|
|
finally:
|
|
await memory.delete_bank(bank_id, request_context=request_context)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retrieve_semantic_bm25_grouped_by_fact_type(memory, request_context):
|
|
"""
|
|
retrieve_semantic_bm25_combined must return a dict keyed by fact_type with
|
|
(semantic_list, bm25_list) tuples. All returned facts must belong to their
|
|
declared fact_type.
|
|
"""
|
|
from hindsight_api.engine.search.retrieval import retrieve_semantic_bm25_combined
|
|
|
|
bank_id = f"test_retrieval_{uuid.uuid4().hex[:8]}"
|
|
try:
|
|
await memory.retain_async(
|
|
bank_id=bank_id,
|
|
content=(
|
|
"Alice is a software engineer at TechCorp. "
|
|
"She visited Paris in 2023 for a conference."
|
|
),
|
|
context="background",
|
|
event_date=datetime(2023, 6, 1, tzinfo=timezone.utc),
|
|
request_context=request_context,
|
|
)
|
|
|
|
query_emb = memory.embeddings.encode(["software engineer Alice"])
|
|
query_emb_str = str(query_emb[0])
|
|
|
|
fact_types = ["world", "experience"]
|
|
async with memory._pool.acquire() as conn:
|
|
results = await retrieve_semantic_bm25_combined(
|
|
conn=conn,
|
|
query_emb_str=query_emb_str,
|
|
query_text="software engineer Alice",
|
|
bank_id=bank_id,
|
|
fact_types=fact_types,
|
|
limit=5,
|
|
)
|
|
|
|
# Must return an entry for every requested fact_type
|
|
assert set(results.keys()) == set(fact_types)
|
|
|
|
for ft, (sem, bm25) in results.items():
|
|
# Semantic and BM25 lists must be lists
|
|
assert isinstance(sem, list)
|
|
assert isinstance(bm25, list)
|
|
# All semantic results must declare the correct fact_type
|
|
for r in sem:
|
|
assert r.fact_type == ft, f"Semantic result has wrong fact_type: {r.fact_type}"
|
|
# All BM25 results must declare the correct fact_type
|
|
for r in bm25:
|
|
assert r.fact_type == ft, f"BM25 result has wrong fact_type: {r.fact_type}"
|
|
|
|
finally:
|
|
await memory.delete_bank(bank_id, request_context=request_context)
|