mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
66992496f5
* fix(api): 404 bank-scoped reads for a bank that does not exist (#4175) GET /stats and GET /memories/list answered 200 with zeroed counters and an empty page for a bank nobody ever created — byte-identical to a healthy, empty bank. A monitor built on either kept passing after the bank it watched was renamed, deleted or recreated under another id, and a typo in bank_id was never surfaced. The same held for every other bank-scoped aggregate/list read: /graph, /stats/memories-timeseries, /entities, /entities/graph, /mental-models, /knowledge-base/{tree,export,search}, /directives, /documents, /tags, /operations, /observations/scopes, /config and /webhooks. (Sub-resource GETs already 404 on the missing child.) Each of those engine reads now calls _require_bank_exists after its own authentication and read authorization, so the check neither widens what a request may see nor creates the bank; the profile row is cached per process, so an existing bank costs no extra query. The 404 is declared in the OpenAPI spec on those operations, so generated clients have a documented missing-bank case. The Rust client's build script drops schema-less error responses first: progenitor models at most one error type per operation and the typed 422 is the one worth keeping. * fix(api): derive the 404 endpoint list from the routing table, not by hand Two follow-ups to the same fix. The regression test enumerated the 17 bank-scoped reads by hand, so a bank-scoped collection GET added later would be covered the day someone remembered to extend the file — exactly the sibling-parity trap the reviewed change is about. It now walks the app's own routes and asserts the contract over every one of them, with two commented exemptions (/document-transfer and /profile, both withdrawn endpoints that answer 410 Gone for every bank). The scan immediately found both, which the hand-written list had missed. The rebase onto main also landed the /profile removal underneath the earlier commit, leaving a declared 404 on an endpoint that can now only ever return 410. Dropped it, and regenerated the spec and clients. * fix(tests): create the bank in tests that read a bank they never created CI found three suites that reached an engine read with no bank row, which the new 404 turns from an empty result into an error. All three are test setup gaps, not behaviour the fix gets wrong — a real deployment always has the row, because every write path (retain included) creates it before anything else exists. - test_memories_extension: a store owns the facts, never the bank row itself, so the three seam reads now create the bank the way a retain would. - test_schema_isolation: the bank row goes into each tenant schema alongside the memory_units row it inserts directly — "created" is per schema, which is part of what the test is about. - test_knowledge_search_text_search_disabled: this engine is stubbed down to the one method under test and has no real pool, so the existence read is stubbed alongside _authenticate_tenant. Also carries the docs-skill copy of the OpenAPI spec, which verify-generated-files caught: it mirrors hindsight-docs/static/openapi.json and was left behind when the /profile 404 was dropped.
457 lines
18 KiB
Python
457 lines
18 KiB
Python
"""
|
|
Tests for the bank stats endpoint and the memories-timeseries endpoint.
|
|
|
|
Covers the new fields exposed by GET /v1/default/banks/{bank_id}/stats
|
|
(operations_by_status) and the new endpoint
|
|
GET /v1/default/banks/{bank_id}/stats/memories-timeseries.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from hindsight_api import RequestContext
|
|
from hindsight_api.api import create_app
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def api_client(memory):
|
|
app = create_app(memory, initialize_memory=False)
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield client
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_bank_id(memory):
|
|
bank_id = f"stats_test_{datetime.now().timestamp()}"
|
|
# Create the bank. Bank-scoped reads 404 for a bank nobody created (#4175),
|
|
# so a stats test that only invents an id would exercise that path instead of
|
|
# the empty-bank one it is about.
|
|
await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
|
return bank_id
|
|
|
|
|
|
async def _insert_memory(memory, bank_id: str, text: str, *, failed: bool = False) -> str:
|
|
"""Insert a single experience memory, optionally marked as consolidation-failed."""
|
|
mem_id = uuid.uuid4()
|
|
async with memory._pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO memory_units (id, bank_id, text, fact_type, created_at, consolidation_failed_at)
|
|
VALUES ($1, $2, $3, 'experience', now(), CASE WHEN $4 THEN now() ELSE NULL END)
|
|
""",
|
|
mem_id,
|
|
bank_id,
|
|
text,
|
|
failed,
|
|
)
|
|
return str(mem_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.memory_backend_incompatible
|
|
async def test_bank_stats_exposes_memory_write_watermark(api_client, memory, test_bank_id):
|
|
"""/stats must carry the bank's newest memory write time.
|
|
|
|
It rides along on the consolidation aggregate for free, and is what the
|
|
mental-models card and the knowledge tree compare each `last_refreshed_at`
|
|
against — the alternative being a scan of the bank's memories per model.
|
|
"""
|
|
try:
|
|
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert response.status_code == 200
|
|
# Nulls are dropped from responses, so an empty bank carries no watermark.
|
|
assert response.json().get("last_memory_write_at") is None
|
|
|
|
mem_id = await _insert_memory(memory, test_bank_id, "A memory lands in the bank.")
|
|
# The 60s TTL would otherwise serve the pre-insert (empty) payload.
|
|
await memory._bank_stats_cache.clear()
|
|
|
|
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert response.status_code == 200
|
|
async with memory._pool.acquire() as conn:
|
|
written_at = await conn.fetchval("SELECT updated_at FROM memory_units WHERE id = $1::uuid", mem_id)
|
|
assert datetime.fromisoformat(response.json()["last_memory_write_at"]) == written_at
|
|
finally:
|
|
await memory._bank_stats_cache.clear()
|
|
async with memory._pool.acquire() as conn:
|
|
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bank_stats_exposes_operations_by_status(api_client, test_bank_id):
|
|
"""/stats should return operations_by_status with all finished operations."""
|
|
try:
|
|
# Kick off a retain so at least one completed operation exists.
|
|
response = await api_client.post(
|
|
f"/v1/default/banks/{test_bank_id}/memories",
|
|
json={"items": [{"content": "Alice is a software engineer.", "context": "team"}]},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert response.status_code == 200
|
|
stats = response.json()
|
|
|
|
assert "operations_by_status" in stats
|
|
assert isinstance(stats["operations_by_status"], dict)
|
|
# A synchronous retain finishes as "completed".
|
|
assert stats["operations_by_status"].get("completed", 0) >= 1
|
|
# pending/failed counters should still be present as scalar mirrors.
|
|
assert stats["pending_operations"] == stats["operations_by_status"].get("pending", 0)
|
|
assert stats["failed_operations"] == stats["operations_by_status"].get("failed", 0)
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"period,expected_count,expected_trunc",
|
|
[
|
|
("1h", 60, "minute"),
|
|
("12h", 12, "hour"),
|
|
("1d", 24, "hour"),
|
|
("7d", 7, "day"),
|
|
("30d", 30, "day"),
|
|
("90d", 90, "day"),
|
|
],
|
|
)
|
|
async def test_memories_timeseries_periods(api_client, test_bank_id, period, expected_count, expected_trunc):
|
|
"""Every period must return the full expected bucket count and trunc."""
|
|
try:
|
|
response = await api_client.post(
|
|
f"/v1/default/banks/{test_bank_id}/memories",
|
|
json={"items": [{"content": "Bob works on infrastructure.", "context": "team"}]},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
|
|
params={"period": period},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
assert body["bank_id"] == test_bank_id
|
|
assert body["period"] == period
|
|
assert body["trunc"] == expected_trunc
|
|
assert len(body["buckets"]) == expected_count
|
|
|
|
for bucket in body["buckets"]:
|
|
assert "time" in bucket
|
|
# Bucket `time` must serialize as a tz-aware ISO (ending in `+00:00` or `Z`).
|
|
# A naive ISO (`2026-04-18T00:00:00`) would be parsed as local time by
|
|
# `new Date()` per ECMA-262, shifting the chart by the browser's timezone.
|
|
assert bucket["time"].endswith("+00:00") or bucket["time"].endswith("Z"), (
|
|
f"bucket time must include UTC offset, got {bucket['time']!r}"
|
|
)
|
|
assert bucket["world"] >= 0
|
|
assert bucket["experience"] >= 0
|
|
assert bucket["observation"] >= 0
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memories_timeseries_invalid_period_falls_back(api_client, test_bank_id):
|
|
"""An unknown period must fall back to the 7d default."""
|
|
try:
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
|
|
params={"period": "nonsense"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["period"] == "7d"
|
|
assert body["trunc"] == "day"
|
|
assert len(body["buckets"]) == 7
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(api_client, test_bank_id):
|
|
"""A bank with no memories must still return the full zero-filled bucket set."""
|
|
try:
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
|
|
params={"period": "7d"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body["buckets"]) == 7
|
|
for bucket in body["buckets"]:
|
|
assert bucket["world"] == 0
|
|
assert bucket["experience"] == 0
|
|
assert bucket["observation"] == 0
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memories_timeseries_reflects_retained_memories(api_client, test_bank_id):
|
|
"""Freshly-retained memories must show up in today's bucket counts."""
|
|
try:
|
|
response = await api_client.post(
|
|
f"/v1/default/banks/{test_bank_id}/memories",
|
|
json={
|
|
"items": [
|
|
{"content": "Alice is a software engineer.", "context": "team"},
|
|
{"content": "Bob works on infrastructure.", "context": "team"},
|
|
]
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/stats/memories-timeseries",
|
|
params={"period": "7d"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
totals = sum(b["world"] + b["experience"] + b["observation"] for b in body["buckets"])
|
|
assert totals >= 2, "expected at least two memories across all buckets"
|
|
|
|
# Those memories should land in the most-recent bucket.
|
|
latest = body["buckets"][-1]
|
|
assert latest["world"] + latest["experience"] + latest["observation"] >= 2
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.memory_backend_incompatible
|
|
async def test_bank_stats_reports_failed_consolidation(api_client, memory, test_bank_id):
|
|
"""/stats must surface the count of memories with consolidation_failed_at set."""
|
|
try:
|
|
await _insert_memory(memory, test_bank_id, "Alice failed 1.", failed=True)
|
|
await _insert_memory(memory, test_bank_id, "Alice failed 2.", failed=True)
|
|
await _insert_memory(memory, test_bank_id, "Alice pending.", failed=False)
|
|
|
|
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert response.status_code == 200
|
|
stats = response.json()
|
|
|
|
assert stats["failed_consolidation"] == 2
|
|
# Pending is the work the consolidator will still do, so the two failed
|
|
# memories are counted only as failed — the buckets are disjoint.
|
|
assert stats["pending_consolidation"] == 1
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.memory_backend_incompatible
|
|
async def test_pending_consolidation_matches_pending_memory_list(api_client, memory, test_bank_id):
|
|
"""pending_consolidation must agree with ?consolidation_state=pending.
|
|
|
|
Both are meant to answer "what is left to consolidate"; when the stats gauge
|
|
counted permanently failed memories too, it sat above the list total by
|
|
exactly failed_consolidation and never reached zero.
|
|
"""
|
|
try:
|
|
await _insert_memory(memory, test_bank_id, "Still queued 1.", failed=False)
|
|
await _insert_memory(memory, test_bank_id, "Still queued 2.", failed=False)
|
|
await _insert_memory(memory, test_bank_id, "Given up on.", failed=True)
|
|
|
|
stats_response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert stats_response.status_code == 200
|
|
stats = stats_response.json()
|
|
|
|
list_response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/memories/list",
|
|
params={"consolidation_state": "pending", "limit": 1},
|
|
)
|
|
assert list_response.status_code == 200
|
|
|
|
assert stats["pending_consolidation"] == list_response.json()["total"] == 2
|
|
assert stats["failed_consolidation"] == 1
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.memory_backend_incompatible
|
|
async def test_list_memories_filter_by_consolidation_state_failed(api_client, memory, test_bank_id):
|
|
"""?consolidation_state=failed returns only memories with consolidation_failed_at set."""
|
|
try:
|
|
failed_id = await _insert_memory(memory, test_bank_id, "Broken item.", failed=True)
|
|
await _insert_memory(memory, test_bank_id, "Healthy item.", failed=False)
|
|
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/memories/list",
|
|
params={"consolidation_state": "failed"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
ids = [item["id"] for item in body["items"]]
|
|
assert failed_id in ids
|
|
assert body["total"] == 1
|
|
assert body["items"][0]["consolidation_failed_at"] is not None
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_memories_filter_by_consolidation_state_rejects_unknown(api_client, test_bank_id):
|
|
"""An invalid consolidation_state value must return a 400 (not 500)."""
|
|
try:
|
|
response = await api_client.get(
|
|
f"/v1/default/banks/{test_bank_id}/memories/list",
|
|
params={"consolidation_state": "bogus"},
|
|
)
|
|
assert response.status_code == 400
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bank_stats_link_counts_have_no_join(api_client, test_bank_id):
|
|
"""link_counts must be populated; the deprecated breakdown fields must be empty.
|
|
|
|
Confirms the simplified single-table aggregation still produces the totals
|
|
the UI reads (`links_by_link_type`) without the historical
|
|
memory_links⇒memory_units join that powered the 2D `links_breakdown` no
|
|
consumer reads.
|
|
"""
|
|
try:
|
|
response = await api_client.post(
|
|
f"/v1/default/banks/{test_bank_id}/memories",
|
|
json={"items": [{"content": "Carol leads platform engineering.", "context": "team"}]},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert response.status_code == 200
|
|
stats = response.json()
|
|
|
|
# link totals must still come back so the UI overview cards render.
|
|
assert isinstance(stats["links_by_link_type"], dict)
|
|
assert stats["total_links"] >= 0
|
|
|
|
# Deprecated breakdown fields stay in the response shape but are empty.
|
|
assert stats["links_breakdown"] == {}
|
|
assert stats["links_by_fact_type"] == {}
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.memory_backend_incompatible
|
|
async def test_get_bank_freshness_returns_only_consolidation_fields(memory, test_bank_id):
|
|
"""get_bank_freshness must return just the freshness keys, no link aggregation."""
|
|
from hindsight_api.extensions import RequestContext
|
|
|
|
try:
|
|
await _insert_memory(memory, test_bank_id, "Headed for consolidation.", failed=False)
|
|
await _insert_memory(memory, test_bank_id, "Also pending.", failed=True)
|
|
|
|
freshness = await memory.get_bank_freshness(
|
|
test_bank_id,
|
|
request_context=RequestContext(internal=True),
|
|
)
|
|
|
|
assert set(freshness.keys()) == {
|
|
"last_consolidated_at",
|
|
"last_memory_write_at",
|
|
"pending_consolidation",
|
|
"failed_consolidation",
|
|
}
|
|
# Same disjoint buckets as /stats: the failed memory is not also pending.
|
|
assert freshness["pending_consolidation"] == 1
|
|
assert freshness["failed_consolidation"] == 1
|
|
finally:
|
|
await memory._bank_stats_cache.clear()
|
|
async with memory._pool.acquire() as conn:
|
|
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_uses_freshness_not_bank_stats(memory, test_bank_id):
|
|
"""reflect() must call the cheap freshness query, not get_bank_stats.
|
|
|
|
Counts calls to `_compute_bank_stats` (the heavy loader) during a reflect
|
|
invocation; it must stay at zero — reflect should route through
|
|
`get_bank_freshness` instead.
|
|
"""
|
|
from hindsight_api.extensions import RequestContext
|
|
|
|
try:
|
|
# Seed a single memory so reflect has something to inspect.
|
|
await _insert_memory(memory, test_bank_id, "Reflect seed.", failed=False)
|
|
|
|
compute_calls = 0
|
|
original_compute = memory._compute_bank_stats
|
|
|
|
async def counting_compute(bank_id: str):
|
|
nonlocal compute_calls
|
|
compute_calls += 1
|
|
return await original_compute(bank_id)
|
|
|
|
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
|
|
try:
|
|
await memory._bank_stats_cache.clear()
|
|
try:
|
|
await memory.reflect(
|
|
test_bank_id,
|
|
"What do you know about this bank?",
|
|
request_context=RequestContext(internal=True),
|
|
)
|
|
except Exception:
|
|
# reflect may fail without a configured LLM in this test env;
|
|
# we only care that it did not invoke the heavy stats loader
|
|
# before failing.
|
|
pass
|
|
assert compute_calls == 0
|
|
finally:
|
|
memory._compute_bank_stats = original_compute # type: ignore[method-assign]
|
|
finally:
|
|
await memory._bank_stats_cache.clear()
|
|
async with memory._pool.acquire() as conn:
|
|
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bank_stats_served_from_cache_on_repeat_call(api_client, memory, test_bank_id):
|
|
"""A second /stats call within the TTL must not re-run the aggregations.
|
|
|
|
The cache layer wraps the DB-heavy `_compute_bank_stats` body; counting
|
|
its invocations is the cleanest way to prove the wiring works without
|
|
relying on timing.
|
|
"""
|
|
try:
|
|
response = await api_client.post(
|
|
f"/v1/default/banks/{test_bank_id}/memories",
|
|
json={"items": [{"content": "Bob is a project manager.", "context": "team"}]},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
original = memory._compute_bank_stats
|
|
call_count = 0
|
|
|
|
async def counting_compute(bank_id: str):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return await original(bank_id)
|
|
|
|
# Make sure no stale entry exists from prior test ordering.
|
|
await memory._bank_stats_cache.clear()
|
|
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
|
|
try:
|
|
first = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
second = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
assert first.json() == second.json()
|
|
assert call_count == 1
|
|
finally:
|
|
memory._compute_bank_stats = original # type: ignore[method-assign]
|
|
await memory._bank_stats_cache.clear()
|
|
finally:
|
|
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|