fleet-memory/hindsight-api/tests/test_entity_labels.py
Nicolò Boschi 9b96becc5c
feat: entity labels — optional, free_values, multi_value, UI polish (#450)
* feat: entity labels

* feat: entity labels — optional, free_values, multi_value, UI polish

Completes the entity labels system:

**Schema & extraction**
- Dynamic Pydantic Labels model per fact: each group becomes a typed
  field (Literal | None, list[Literal], str | None, or list[str])
- `optional: bool` flag per group — non-optional enum fields appear in
  JSON schema required array so structured-output providers enforce them
- `free_values: bool` flag per group — accepts any LLM-generated string
  instead of a predefined enum; example values shown as hints in prompt
- New `is_label_entity()` helper for labels-only mode filtering that
  handles both enum lookup and free_values key-prefix matching
- Sentinel rejection: "None"/"null"/"n/a" strings dropped in post-processing

**BM25 / dense retrieval**
- `text_signals` column on memory_units: entity names + date tokens for
  enriched BM25 indexing without polluting stored fact text
- Dense embedding includes occurred_end when it differs from occurred_start
- Alembic migration z1u2v3w4x5y6 (merge revision fixing two heads)

**UI (bank-config-view)**
- Shadcn Switch replaces custom Toggle for both entity-labels and observations
- Shadcn Checkbox for multi/optional/free_values per group
- Input heights bumped to h-8 throughout the editor
- "Label Groups" → "Entity Labels", "Free-form entities" → "Entities"
- Free-text groups show "Example hints" banner in values section

**Tests (45 unit + 3 LLM integration)**
- build_labels_model: single, multi, mixed, free_values optional/required/multi
- is_label_entity: enum match, free_values prefix match, no false positives
- Post-processing: null/absent/string-None/free_values/sentinels/multi-value
- Schema: labels in required, structured object, no labels when unconfigured
- LLM integration: single-value enum, multi-value enum, free_values retain

**Docs**
- retain.md: new Entity Labels section covering groups, flags, examples
- configuration.md: retain_free_form_entities env var + entity_labels note

* fix(tests): update hierarchical fields count for entity_labels additions

entity_labels and retain_free_form_entities are hierarchical fields,
bumping the expected count from 11 to 13.

* fix(migration): rename text_signals revision to avoid collision with main

Main branch claimed z1u2v3w4x5y6 for observation_scopes. Rename our
text_signals migration to a2b3c4d5e6f7, chaining after z1u2v3w4x5y6.

* refactor(entity-labels): simplify free_values — always str|None, no multi

- free_values groups always produce str | None (multi_value and optional
  flags are ignored for free text groups — always optional, never multi)
- Prompt section for free_values groups shows only key + description,
  no values list (users put examples in the description instead)
- UI: section title "Entities", toggle "Free Form Entities", replace
  per-group checkboxes with a type dropdown (Enum / Free text); only
  show multi checkbox and values list when type is Enum
- Update tests to reflect new behaviour

* refactor(entity-labels): replace free_values/multi_value booleans with type field

- LabelGroup now uses type: "value" | "multi-values" | "text" instead of
  free_values/multi_value boolean pair
- Backward-compat migration converts legacy dicts automatically
- Rename retain_free_form_entities → entities_allow_free_form throughout
- Update UI dropdown to show Single value / Multi-values / Free text
- Remove separate multi checkbox (captured by type selection)
- Update docs examples and configuration.md
- Update all tests to use new field names

* fix(migration): backfill observation_scopes column for DBs with swapped z1u2v3w4x5y6

Local DBs that had z1u2v3w4x5y6 applied when it referred to the old
text_signals migration (before it was renamed to a2b3c4d5e6f7) won't have
observation_scopes in their memory_units table. This migration adds the
column with IF NOT EXISTS so it's a no-op on clean installs.

* feat(entity-labels): add tag field to auto-populate memory unit tags from labels

When a LabelGroup has tag=True, extracted key:value entities for that group
are automatically written to the memory unit's tags array. This lets entity
labels double as tags, enabling immediate filtering via the existing
tags/tags_match API params with no extra infrastructure.

- Add tag: bool = False to LabelGroup
- _inject_label_tags() helper called in both sync and batch extraction paths
- UI: add Tag checkbox per label group row
- Docs: document the new tag field
- Tests: 4 new unit tests covering all tag injection paths

* style: ruff format migration file

* fix(migration): fix multiple alembic heads after rebase — point text_signals after nullable_event_date

* fix(clients): update timestamp field to use Timestamp wrapper type after timestamp=unset feature

* style: ruff format agent.py

* fix(docs): update Go quickstart example to use NullableTimestamp for timestamp field
2026-03-02 13:05:25 +01:00

1120 lines
43 KiB
Python

"""
Unit tests for entity labels models and helpers.
Tests label parsing, enum building, prompt generation, lookup building,
entity post-processing, and embedding augmentation.
Also includes LLM integration tests (require DB + LLM) that call retain
and verify label entities are extracted and stored correctly.
"""
import uuid
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.retain.entity_labels import (
EntityLabelsConfig,
LabelGroup,
LabelValue,
build_labels_lookup,
parse_entity_labels,
)
# ─── parse_entity_labels ───────────────────────────────────────────────────────
def test_parse_entity_labels_none():
result = parse_entity_labels(None)
assert result is None
def test_parse_entity_labels_empty_list():
result = parse_entity_labels([])
assert result is None
def test_parse_entity_labels_list_format():
"""Legacy list format: just a list of attribute dicts (using new type field)."""
raw = [
{
"key": "pedagogy",
"description": "Teaching strategy",
"type": "multi-values",
"values": [
{"value": "scaffolding", "description": "Break down tasks"},
{"value": "active_engagement", "description": "Group work"},
],
}
]
result = parse_entity_labels(raw)
assert result is not None
assert isinstance(result, EntityLabelsConfig)
assert len(result.attributes) == 1
attr = result.attributes[0]
assert attr.key == "pedagogy"
assert attr.type == "multi-values"
assert len(attr.values) == 2
assert attr.values[0].value == "scaffolding"
def test_parse_entity_labels_dict_format():
"""New dict format (free_form_entities is now a separate config field, not in EntityLabelsConfig)."""
raw = {
"attributes": [
{
"key": "interest",
"description": "User interest area",
"values": [{"value": "active", "description": "Active hobbies"}],
}
],
}
result = parse_entity_labels(raw)
assert result is not None
assert len(result.attributes) == 1
assert result.attributes[0].key == "interest"
def test_parse_entity_labels_dict_format_defaults():
"""Dict format parses attributes correctly."""
raw = {
"attributes": [
{"key": "topic", "values": [{"value": "math", "description": "Mathematics"}]}
]
}
result = parse_entity_labels(raw)
assert result is not None
assert len(result.attributes) == 1
# ─── build_labels_model ────────────────────────────────────────────────────────
# free_values schema variants
def test_build_labels_model_single_value():
"""Single-value group → Literal | None field (anyOf), defaults to None."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="engagement",
values=[LabelValue(value="active"), LabelValue(value="passive")],
)
]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
props = schema["properties"]
assert "engagement" in props
# Single-value: Pydantic emits anyOf[{enum: [...]}, {type: null}]
any_of = props["engagement"]["anyOf"]
enum_values = next(branch["enum"] for branch in any_of if "enum" in branch)
assert set(enum_values) == {"active", "passive"}
# Defaults to None when omitted
instance = Model()
assert instance.engagement is None # type: ignore[attr-defined]
def test_build_labels_model_multi_value():
"""Multi-value group → list[Literal] field, defaults to empty list."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="pedagogy",
type="multi-values",
values=[LabelValue(value="scaffolding"), LabelValue(value="active_engagement")],
)
]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
props = schema["properties"]
assert "pedagogy" in props
assert props["pedagogy"]["type"] == "array"
assert set(props["pedagogy"]["items"]["enum"]) == {"scaffolding", "active_engagement"}
instance = Model()
assert instance.pedagogy == [] # type: ignore[attr-defined]
def test_build_labels_model_mixed():
"""Mixed single + multi-value groups both present."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(key="engagement", values=[LabelValue(value="active")]),
LabelGroup(key="pedagogy", type="multi-values", values=[LabelValue(value="scaffolding")]),
]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
assert "engagement" in schema["properties"]
assert "pedagogy" in schema["properties"]
def test_build_labels_model_none_when_no_values():
"""Returns None when no groups have values."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="empty", values=[])])
assert build_labels_model(labels_cfg) is None
def test_build_labels_model_free_values_optional():
"""type='text', optional=True → str | None field."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[LabelGroup(key="topic", type="text", optional=True, values=[])]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
topic = schema["properties"]["topic"]
any_of_types = {branch.get("type") for branch in topic["anyOf"]}
assert "string" in any_of_types and "null" in any_of_types
assert Model().topic is None # type: ignore[attr-defined]
def test_build_labels_model_free_values_always_optional():
"""type='text' with optional=False is still treated as str | None — always optional."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[LabelGroup(key="topic", type="text", optional=False, values=[])]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
# free_values groups are always optional (str | None), never in required
assert "topic" not in schema.get("required", [])
anyOf = schema["properties"]["topic"].get("anyOf", [])
assert any(b.get("type") == "string" for b in anyOf)
def test_build_labels_model_free_values_multi_still_optional():
"""type='text' is always str | None — multi-values only applies to enum types."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[LabelGroup(key="tags", type="text", values=[])]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
# free_values groups are always str | None regardless of multi_value
assert "tags" not in schema.get("required", [])
anyOf = schema["properties"]["tags"].get("anyOf", [])
assert any(b.get("type") == "string" for b in anyOf)
def test_build_labels_model_free_values_no_values_still_creates_field():
"""type='text' group with no values still creates a field (description holds examples)."""
from hindsight_api.engine.retain.entity_labels import build_labels_model
labels_cfg = EntityLabelsConfig(
attributes=[LabelGroup(key="mood", type="text", values=[])]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
assert "mood" in Model.model_json_schema()["properties"]
# ─── is_label_entity ──────────────────────────────────────────────────────────
def test_is_label_entity_enum_match():
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels
cfg = parse_entity_labels([{"key": "engagement", "values": [{"value": "active"}]}])
lookup = build_labels_lookup(cfg)
assert is_label_entity("engagement:active", cfg, lookup) is True
def test_is_label_entity_enum_no_match():
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels
cfg = parse_entity_labels([{"key": "engagement", "values": [{"value": "active"}]}])
lookup = build_labels_lookup(cfg)
assert is_label_entity("engagement:unknown", cfg, lookup) is False
assert is_label_entity("Alice", cfg, lookup) is False
def test_is_label_entity_free_values_prefix_match():
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels
cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
lookup = build_labels_lookup(cfg)
assert is_label_entity("topic:algebra", cfg, lookup) is True
assert is_label_entity("topic:anything at all", cfg, lookup) is True
def test_is_label_entity_free_values_no_match_other_key():
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels
cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
lookup = build_labels_lookup(cfg)
assert is_label_entity("Alice", cfg, lookup) is False
assert is_label_entity("engagement:active", cfg, lookup) is False
# ─── build_labels_lookup ───────────────────────────────────────────────────────
def test_build_labels_lookup():
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="Pedagogy",
values=[
LabelValue(value="Scaffolding"),
LabelValue(value="Active_Engagement"),
],
)
]
)
lookup = build_labels_lookup(labels_cfg)
assert "pedagogy:scaffolding" in lookup
assert "pedagogy:active_engagement" in lookup
# Should be lowercase
assert all(v == v.lower() for v in lookup)
def test_build_labels_lookup_raw_list():
"""build_labels_lookup accepts raw list format for backwards compatibility."""
raw = [
{
"key": "interest",
"values": [{"value": "active", "description": "Active interest"}],
}
]
lookup = build_labels_lookup(raw)
assert "interest:active" in lookup
def test_build_labels_lookup_none():
lookup = build_labels_lookup(None)
assert lookup == set()
# ─── _build_labels_prompt_section ─────────────────────────────────────────────
def test_build_labels_prompt_section_none():
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
result = _build_labels_prompt_section(None)
assert result == ""
def test_build_labels_prompt_section_empty_config():
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
result = _build_labels_prompt_section(EntityLabelsConfig(attributes=[]))
assert result == ""
def test_build_labels_prompt_section_generates_key_values():
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="pedagogy",
description="Teaching strategy",
type="multi-values",
values=[
LabelValue(value="scaffolding", description="Break down tasks"),
LabelValue(value="active_engagement", description="Group work"),
],
)
]
)
result = _build_labels_prompt_section(labels_cfg)
# Structured format: values listed as "value" bullets under the key name
assert "scaffolding" in result
assert "active_engagement" in result
assert "pedagogy" in result
assert "Teaching strategy" in result
assert "multi" in result # prompt mentions multi-value nature
def test_build_labels_prompt_section_free_form_true():
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="topic",
values=[LabelValue(value="math")],
)
],
)
result = _build_labels_prompt_section(labels_cfg, free_form_entities=True)
# When free_form_entities=True: prompt says to also fill 'entities' field
assert "labels" in result
assert "entities" in result
def test_build_labels_prompt_section_free_form_false():
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="topic",
values=[LabelValue(value="math")],
)
],
)
result = _build_labels_prompt_section(labels_cfg, free_form_entities=False)
assert "labels-only mode" in result
# ─── augment_texts_with_entities ──────────────────────────────────────────────
def test_augment_texts_with_entities():
"""Entity names appear in embedding input but fact_text is unchanged."""
from datetime import UTC, datetime
from hindsight_api.engine.retain.embedding_processing import augment_texts_with_dates
from hindsight_api.engine.retain.types import ExtractedFact
event_date = datetime(2024, 6, 1, tzinfo=UTC)
fact = ExtractedFact(
fact_text="User attended workshop",
fact_type="world",
entities=["pedagogy:scaffolding", "user"],
mentioned_at=event_date,
)
def fmt_date(dt):
return "June 2024"
augmented = augment_texts_with_dates([fact], fmt_date)
assert len(augmented) == 1
# Entity names should appear in augmented text
assert "pedagogy:scaffolding" in augmented[0]
assert "user" in augmented[0]
# Original fact text should be present
assert "User attended workshop" in augmented[0]
# ─── _inject_label_tags ───────────────────────────────────────────────────────
def test_inject_label_tags_adds_tagged_entities():
"""tag=True group: extracted label entities are added to fact.tags."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
from hindsight_api.engine.retain.types import ExtractedFact
config = MagicMock()
config.entity_labels = [
{"key": "pedagogy", "type": "value", "tag": True, "values": [{"value": "scaffolding"}]},
{"key": "engagement", "type": "value", "tag": False, "values": [{"value": "active"}]},
]
fact = ExtractedFact(
fact_text="Teacher used scaffolding",
fact_type="world",
entities=["pedagogy:scaffolding", "engagement:active"],
tags=["session-1"],
)
_inject_label_tags([fact], config)
# pedagogy group has tag=True → added to tags
assert "pedagogy:scaffolding" in fact.tags
# engagement group has tag=False → NOT added
assert "engagement:active" not in fact.tags
# original tag preserved
assert "session-1" in fact.tags
def test_inject_label_tags_no_duplicate():
"""No duplicate if label entity already in tags."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
from hindsight_api.engine.retain.types import ExtractedFact
config = MagicMock()
config.entity_labels = [
{"key": "pedagogy", "type": "value", "tag": True, "values": [{"value": "scaffolding"}]},
]
fact = ExtractedFact(
fact_text="...",
fact_type="world",
entities=["pedagogy:scaffolding"],
tags=["pedagogy:scaffolding"],
)
_inject_label_tags([fact], config)
assert fact.tags.count("pedagogy:scaffolding") == 1
def test_inject_label_tags_no_tag_groups_is_noop():
"""When no groups have tag=True, tags are unchanged."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
from hindsight_api.engine.retain.types import ExtractedFact
config = MagicMock()
config.entity_labels = [
{"key": "pedagogy", "type": "value", "tag": False, "values": [{"value": "scaffolding"}]},
]
fact = ExtractedFact(fact_text="...", fact_type="world", entities=["pedagogy:scaffolding"])
_inject_label_tags([fact], config)
assert fact.tags == []
def test_inject_label_tags_no_labels_config_is_noop():
"""When entity_labels is None, tags are unchanged."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
from hindsight_api.engine.retain.types import ExtractedFact
config = MagicMock()
config.entity_labels = None
fact = ExtractedFact(fact_text="...", fact_type="world", entities=["pedagogy:scaffolding"])
_inject_label_tags([fact], config)
assert fact.tags == []
# ─── entity label post-processing ─────────────────────────────────────────────
def test_label_entity_post_processing():
"""Structured labels dict is parsed into key:value entity strings; invalid values filtered."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
from hindsight_api.engine.retain.fact_extraction import Entity
labels_cfg = parse_entity_labels(
[
{
"key": "pedagogy",
"values": [
{"value": "scaffolding", "description": ""},
{"value": "active_engagement", "description": ""},
],
}
]
)
assert labels_cfg is not None
labels_lookup = build_labels_lookup(labels_cfg)
# Simulated LLM response — structured dict, not a flat list
labels_data = {"pedagogy": "scaffolding"} # single-value field
validated_entities: list[Entity] = []
if isinstance(labels_data, dict) and labels_lookup:
existing_texts_lower: set[str] = set()
for group in labels_cfg.attributes:
value = labels_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
label_str = f"{group.key}:{v}"
if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
entity_texts = {e.text for e in validated_entities}
assert "pedagogy:scaffolding" in entity_texts
def test_label_entity_post_processing_invalid_value_ignored():
"""Values not in the lookup are silently dropped."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
from hindsight_api.engine.retain.fact_extraction import Entity
labels_cfg = parse_entity_labels(
[{"key": "pedagogy", "values": [{"value": "scaffolding", "description": ""}]}]
)
labels_lookup = build_labels_lookup(labels_cfg)
labels_data = {"pedagogy": "unknown_value"}
validated_entities: list[Entity] = []
existing_texts_lower: set[str] = set()
for group in labels_cfg.attributes:
value = labels_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
label_str = f"{group.key}:{v}"
if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
assert validated_entities == []
def test_label_entity_post_processing_multi_value():
"""Multi-value list field produces one entity per value."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
from hindsight_api.engine.retain.fact_extraction import Entity
labels_cfg = parse_entity_labels(
[
{
"key": "pedagogy",
"multi_value": True,
"values": [
{"value": "scaffolding", "description": ""},
{"value": "active_engagement", "description": ""},
],
}
]
)
labels_lookup = build_labels_lookup(labels_cfg)
# Multi-value: LLM returns a list
labels_data = {"pedagogy": ["scaffolding", "active_engagement"]}
validated_entities: list[Entity] = []
existing_texts_lower: set[str] = set()
for group in labels_cfg.attributes:
value = labels_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
label_str = f"{group.key}:{v}"
if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
entity_texts = {e.text for e in validated_entities}
assert "pedagogy:scaffolding" in entity_texts
assert "pedagogy:active_engagement" in entity_texts
def _run_label_post_processing(labels_cfg, labels_data: dict) -> set[str]:
"""Helper: mirrors the production label post-processing logic, returns entity text set."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup
from hindsight_api.engine.retain.fact_extraction import Entity
labels_lookup = build_labels_lookup(labels_cfg)
validated_entities: list[Entity] = []
existing_texts_lower: set[str] = set()
effective_data = labels_data or {}
if isinstance(effective_data, dict):
for group in labels_cfg.attributes:
value = effective_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
continue
label_str = f"{group.key}:{v.strip()}"
if group.type == "text":
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
elif label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
return {e.text for e in validated_entities}
def test_free_values_label_accepts_any_string():
"""type='text' group: any non-empty string produces a key:value entity."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
entity_texts = _run_label_post_processing(labels_cfg, {"topic": "quadratic equations"})
assert "topic:quadratic equations" in entity_texts
def test_free_values_label_rejects_none_sentinel():
"""type='text' group: string 'None' / 'null' / 'n/a' are rejected."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
for sentinel in ("None", "null", "n/a", "NULL", "NONE"):
result = _run_label_post_processing(labels_cfg, {"topic": sentinel})
assert result == set(), f"Sentinel '{sentinel}' should not produce an entity, got: {result}"
def test_free_values_label_is_single_value():
"""type='text' groups are always single-value (str | None)."""
from hindsight_api.engine.retain.entity_labels import build_labels_model, parse_entity_labels
labels_cfg = parse_entity_labels(
[{"key": "topic", "type": "text", "values": []}]
)
Model = build_labels_model(labels_cfg)
assert Model is not None
schema = Model.model_json_schema()
# Must be str | None, not list
assert schema["properties"]["topic"].get("type") != "array"
anyOf = schema["properties"]["topic"].get("anyOf", [])
assert any(b.get("type") == "string" for b in anyOf)
def test_free_values_label_not_in_lookup():
"""type='text' group values do NOT appear in the lookup set (no fixed vocabulary)."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
labels_cfg = parse_entity_labels(
[{"key": "topic", "type": "text", "values": [{"value": "algebra"}]}]
)
lookup = build_labels_lookup(labels_cfg)
assert "topic:algebra" not in lookup # example hints not added to lookup
assert len(lookup) == 0
def test_optional_label_null_produces_no_entity():
"""JSON null (Python None) for an optional label → no entity created."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels(
[{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
)
# LLM returned null — content didn't match any value
entity_texts = _run_label_post_processing(labels_cfg, {"engagement": None})
assert entity_texts == set(), f"Expected no entities for null optional label, got: {entity_texts}"
def test_optional_label_absent_key_produces_no_entity():
"""Missing key in labels dict for an optional label → no entity created."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels(
[{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
)
# LLM omitted the key entirely
entity_texts = _run_label_post_processing(labels_cfg, {})
assert entity_texts == set(), f"Expected no entities for absent optional label, got: {entity_texts}"
def test_optional_label_string_none_produces_no_entity():
"""String 'None' from LLM for an optional label → no entity created (not in lookup)."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels(
[{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
)
# LLM returned the string "None" instead of JSON null — must not be stored
entity_texts = _run_label_post_processing(labels_cfg, {"engagement": "None"})
assert entity_texts == set(), (
f"String 'None' must not produce engagement:None entity, got: {entity_texts}"
)
def test_optional_label_null_does_not_affect_other_labels():
"""Null for one optional label doesn't suppress other valid labels on the same fact."""
from hindsight_api.engine.retain.entity_labels import parse_entity_labels
labels_cfg = parse_entity_labels(
[
{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]},
{"key": "topic", "optional": True, "values": [{"value": "math"}, {"value": "science"}]},
]
)
# engagement is null, but topic is set
entity_texts = _run_label_post_processing(labels_cfg, {"engagement": None, "topic": "math"})
assert "topic:math" in entity_texts, f"Expected topic:math entity, got: {entity_texts}"
assert not any("engagement" in t for t in entity_texts), (
f"engagement should not appear, got: {entity_texts}"
)
def test_free_form_entities_false_clears_entities():
"""When retain_free_form_entities=False, non-label entities are removed."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
from hindsight_api.engine.retain.fact_extraction import Entity
labels_cfg = parse_entity_labels(
{
"attributes": [
{
"key": "pedagogy",
"values": [{"value": "scaffolding", "description": ""}],
}
],
}
)
labels_lookup = build_labels_lookup(labels_cfg)
free_form_entities = False # standalone config field
# Mix of label and free-form entities
validated_entities = [
Entity(text="pedagogy:scaffolding"),
Entity(text="Alice"),
Entity(text="Google"),
]
# Apply free_form filtering
if not free_form_entities and labels_lookup:
validated_entities = [e for e in validated_entities if e.text.lower() in labels_lookup]
entity_texts = {e.text for e in validated_entities}
assert "pedagogy:scaffolding" in entity_texts
assert "Alice" not in entity_texts
assert "Google" not in entity_texts
def test_free_form_entities_true_keeps_all():
"""When retain_free_form_entities=True (default), all entities are kept."""
from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
from hindsight_api.engine.retain.fact_extraction import Entity
labels_cfg = parse_entity_labels(
{
"attributes": [
{
"key": "pedagogy",
"values": [{"value": "scaffolding", "description": ""}],
}
],
}
)
labels_lookup = build_labels_lookup(labels_cfg)
free_form_entities = True # default value
validated_entities = [
Entity(text="pedagogy:scaffolding"),
Entity(text="Alice"),
]
# With free_form_entities=True, should NOT filter
if not free_form_entities and labels_lookup:
validated_entities = [e for e in validated_entities if e.text.lower() in labels_lookup]
entity_texts = {e.text for e in validated_entities}
assert "pedagogy:scaffolding" in entity_texts
assert "Alice" in entity_texts
# ─── _build_extraction_prompt_and_schema with labels ──────────────────────────
def test_extraction_schema_includes_labels_model():
"""When entity_labels configured, response schema has a structured Labels field."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
config = MagicMock()
config.entity_labels = [
{
"key": "engagement",
"values": [{"value": "active"}, {"value": "passive"}],
},
{
"key": "pedagogy",
"type": "multi-values",
"values": [{"value": "scaffolding"}, {"value": "active_engagement"}],
},
]
config.entities_allow_free_form = True
config.retain_extraction_mode = "concise"
config.retain_extract_causal_links = False
config.retain_mission = None
config.retain_custom_instructions = None
prompt, schema = _build_extraction_prompt_and_schema(config)
# Schema should be a dynamic response model
json_schema = schema.model_json_schema()
assert "facts" in json_schema["properties"]
# Drill into the fact item schema
fact_schema = json_schema["$defs"]["LabelsFact"]
assert "labels" in fact_schema["properties"]
assert "labels" in fact_schema["required"]
# Labels should be a nested object (not a flat array)
labels_ref = fact_schema["properties"]["labels"]
labels_def_key = labels_ref["$ref"].split("/")[-1]
labels_def = json_schema["$defs"][labels_def_key]
assert "engagement" in labels_def["properties"]
assert "pedagogy" in labels_def["properties"]
# engagement: single-value → anyOf[{enum: [...]}, {type: null}]
any_of = labels_def["properties"]["engagement"]["anyOf"]
engagement_enums = next(b["enum"] for b in any_of if "enum" in b)
assert set(engagement_enums) == {"active", "passive"}
# pedagogy: multi-value → array of enum
assert labels_def["properties"]["pedagogy"]["type"] == "array"
assert set(labels_def["properties"]["pedagogy"]["items"]["enum"]) == {"scaffolding", "active_engagement"}
# Prompt should reference the labels object
assert "labels" in prompt
assert "engagement" in prompt
assert "pedagogy" in prompt
def test_extraction_schema_labels_in_required():
"""labels field is in the required array so OpenAI structured outputs enforce it."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
config = MagicMock()
config.entity_labels = [{"key": "topic", "values": [{"value": "math"}]}]
config.entities_allow_free_form = True
config.retain_extraction_mode = "concise"
config.retain_extract_causal_links = False
config.retain_mission = None
config.retain_custom_instructions = None
_, schema = _build_extraction_prompt_and_schema(config)
fact_schema = schema.model_json_schema()["$defs"]["LabelsFact"]
assert "labels" in fact_schema["required"]
def test_extraction_schema_no_labels_when_unconfigured():
"""Without entity_labels, schema falls back to a base FactExtraction class (no dynamic model)."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import (
_build_extraction_prompt_and_schema,
)
config = MagicMock()
config.entity_labels = None
config.entities_allow_free_form = True
config.retain_extraction_mode = "concise"
config.retain_extract_causal_links = False
config.retain_mission = None
config.retain_custom_instructions = None
_, schema = _build_extraction_prompt_and_schema(config)
# No labels field in schema — it's a plain base response model
json_schema = schema.model_json_schema()
# Verify 'labels' is NOT a required or present field in any fact definition
fact_defs = {k: v for k, v in json_schema.get("$defs", {}).items() if "facts" not in k.lower()}
for name, defn in fact_defs.items():
assert "labels" not in defn.get("properties", {}), f"Found 'labels' in {name}"
# ─── LLM integration tests (require DB + LLM) ─────────────────────────────────
@pytest.mark.asyncio
async def test_retain_extracts_single_value_label(memory, request_context):
"""
End-to-end: retain content with entity_labels configured (single-value).
Verify that the LLM assigns the label and it ends up as a key:value entity on the memory unit.
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-labels-single-{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Configure entity_labels on the bank
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={
"entity_labels": [
{
"key": "engagement",
"description": "Student engagement level during the session",
"values": [
{"value": "active", "description": "Student is actively participating"},
{"value": "passive", "description": "Student is listening but not participating"},
],
}
],
"entities_allow_free_form": False, # labels-only mode
},
context=request_context,
)
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=(
"During today's tutoring session, Maria asked many questions, "
"participated in every exercise, and solved the problems independently. "
"She was very engaged throughout."
),
request_context=request_context,
)
assert len(unit_ids) > 0, "Should have extracted at least one fact"
# Query entity names for the retained units
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in rows}
assert "engagement:active" in entity_names, (
f"Expected 'engagement:active' label entity. Got: {entity_names}"
)
# In labels-only mode, free-form entities like 'Maria' should be absent
assert not any("maria" in n for n in entity_names), (
f"Free-form entity 'Maria' should not appear in labels-only mode. Got: {entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_retain_extracts_multi_value_label(memory, request_context):
"""
End-to-end: retain content with a multi_value entity_labels group.
Verify that multiple label values can be assigned to a single fact.
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-labels-multi-{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={
"entity_labels": [
{
"key": "pedagogy",
"description": "Teaching strategies observed in the session",
"type": "multi-values",
"values": [
{"value": "scaffolding", "description": "Teacher breaks tasks into smaller steps"},
{"value": "direct_instruction", "description": "Teacher explains concepts directly"},
{"value": "socratic_questioning", "description": "Teacher guides via questions"},
],
}
],
"entities_allow_free_form": False,
},
context=request_context,
)
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=(
"The teacher broke the algebra problem into small steps and guided the student "
"through each one with questions like 'What do you notice about this equation?' "
"and 'What would happen if you moved this term to the other side?'. "
"The lesson was clearly structured with scaffolding and socratic questioning."
),
request_context=request_context,
)
assert len(unit_ids) > 0
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in rows}
# At least one pedagogy label should be assigned
pedagogy_labels = {n for n in entity_names if n.startswith("pedagogy:")}
assert len(pedagogy_labels) > 0, (
f"Expected at least one pedagogy:* label entity. Got: {entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_retain_extracts_free_values_label(memory, request_context):
"""
End-to-end: retain content with a free_values entity_labels group.
Verify that the LLM produces a key:value entity with an open-ended value
(not constrained to a predefined enum list).
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-labels-free-{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={
"entity_labels": [
{
"key": "topic",
"description": "The specific subject being discussed in this session. Examples: algebra, geometry, quadratic equations.",
"type": "text",
"optional": True,
"values": [],
}
],
"entities_allow_free_form": False,
},
context=request_context,
)
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=(
"The student and tutor spent the session working through quadratic equations. "
"They factored several expressions and practised the quadratic formula."
),
request_context=request_context,
)
assert len(unit_ids) > 0
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in rows}
# A topic:* entity must exist — value is free-form so we only check the prefix
topic_entities = {n for n in entity_names if n.startswith("topic:")}
assert len(topic_entities) > 0, (
f"Expected at least one topic:* free-value entity. Got: {entity_names}"
)
# The value must not be the literal string "none" or "null"
assert not any(n in ("topic:none", "topic:null", "topic:n/a") for n in topic_entities), (
f"topic entity should not be a null sentinel. Got: {topic_entities}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)