mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""Distinct lessons must get distinct ``learning_id``s so re-ingest of one
|
|||
|
|
does not delete the other's chunks.
|
||
|
|
|
||
|
|
The memory distiller emits lessons with a fixed ``Problem: …`` opening shape.
|
||
|
|
Two lessons whose first 100 chars match but whose bodies differ used to collide
|
||
|
|
on ``learning_id = f"lrn-{md5(content[:100])[:12]}"``; ``replace_on_reingest``
|
||
|
|
then routed both to the same source URI, so the second ingest's
|
||
|
|
``replace_chunks`` DELETE wiped the first lesson's chunks — silent data loss.
|
||
|
|
The fix hashes the FULL content (wider hex slice) so distinct bodies get
|
||
|
|
distinct ids and each keeps its chunks.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.services.optimal_brain.indexes.learnings import (
|
||
|
|
LearningsIndexPlugin,
|
||
|
|
RecordLearningParams,
|
||
|
|
)
|
||
|
|
from roboco.services.optimal_brain.text_chunker import Chunk
|
||
|
|
|
||
|
|
|
||
|
|
def _wire(plugin: object, source: str) -> AsyncMock:
|
||
|
|
"""Attach mock store/chunker/embedder; return the store mock."""
|
||
|
|
chunk = Chunk(text="x" * 250, source=source, metadata={})
|
||
|
|
store = AsyncMock()
|
||
|
|
embedder = MagicMock()
|
||
|
|
embedder.aembed_chunks = AsyncMock(return_value=[chunk])
|
||
|
|
object.__setattr__(plugin, "_store", store)
|
||
|
|
object.__setattr__(
|
||
|
|
plugin, "_chunker", MagicMock(chunk_document=MagicMock(return_value=[chunk]))
|
||
|
|
)
|
||
|
|
object.__setattr__(plugin, "_embedder", embedder)
|
||
|
|
object.__setattr__(plugin, "_initialized", True)
|
||
|
|
return store
|
||
|
|
|
||
|
|
|
||
|
|
def _learning_id_from_source(source: str) -> str:
|
||
|
|
return source.rsplit("/", 1)[-1]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_distinct_bodies_with_shared_prefix_get_distinct_ids() -> None:
|
||
|
|
"""Two lessons whose first 100 chars are identical (the fixed
|
||
|
|
``Problem: …`` prefix) but whose bodies differ must produce DIFFERENT
|
||
|
|
``learning_id``s — so both sources are distinct and ``replace_chunks``
|
||
|
|
of the second does NOT delete the first's chunks."""
|
||
|
|
prefix = "Problem: " + "x" * 91 # exactly 100 chars
|
||
|
|
content_a = prefix + "\nApproach: do A. " + "A" * 300
|
||
|
|
content_b = prefix + "\nApproach: do B. " + "B" * 300
|
||
|
|
assert content_a[:100] == content_b[:100]
|
||
|
|
assert content_a != content_b
|
||
|
|
|
||
|
|
plugin = LearningsIndexPlugin()
|
||
|
|
store = _wire(plugin, "roboco://learnings/lrn-x")
|
||
|
|
store.replace_chunks = AsyncMock()
|
||
|
|
|
||
|
|
await plugin.record_learning(
|
||
|
|
RecordLearningParams(content=content_a, category="error_handling")
|
||
|
|
)
|
||
|
|
await plugin.record_learning(
|
||
|
|
RecordLearningParams(content=content_b, category="error_handling")
|
||
|
|
)
|
||
|
|
|
||
|
|
_EXPECTED_INGESTS = 2
|
||
|
|
assert store.replace_chunks.await_count == _EXPECTED_INGESTS
|
||
|
|
sources = [call.args[0] for call in store.replace_chunks.await_args_list]
|
||
|
|
id_a = _learning_id_from_source(sources[0])
|
||
|
|
id_b = _learning_id_from_source(sources[1])
|
||
|
|
assert id_a != id_b, (
|
||
|
|
f"distinct lessons collapsed to the same learning_id {id_a!r} "
|
||
|
|
"— second ingest would delete the first's chunks"
|
||
|
|
)
|
||
|
|
assert sources[0] != sources[1]
|
||
|
|
# The second ingest must NOT delete the first's source: replace_chunks
|
||
|
|
# deletes only by its own source, and the two sources differ.
|
||
|
|
store.delete_by_source.assert_not_awaited()
|