* 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
194 lines
6.3 KiB
Python
194 lines
6.3 KiB
Python
"""
|
|
Entity labels models and helpers for retain pipeline.
|
|
|
|
Defines a controlled vocabulary of key:value classification labels
|
|
(e.g., 'pedagogy:scaffolding', 'interest:active') that are extracted
|
|
at retain time and stored as entities.
|
|
"""
|
|
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, create_model
|
|
|
|
|
|
class LabelValue(BaseModel):
|
|
"""A single allowed value for a label group."""
|
|
|
|
value: str
|
|
description: str = ""
|
|
|
|
|
|
class LabelGroup(BaseModel):
|
|
"""A label group (dimension) with its type and allowed values."""
|
|
|
|
key: str
|
|
description: str = ""
|
|
type: Literal["value", "multi-values", "text"] = "value"
|
|
optional: bool = True
|
|
tag: bool = False
|
|
values: list[LabelValue] = []
|
|
|
|
|
|
class EntityLabelsConfig(BaseModel):
|
|
"""Entity labels configuration for a bank (controlled vocabulary)."""
|
|
|
|
attributes: list[LabelGroup] = []
|
|
|
|
|
|
def parse_entity_labels(raw: dict | list | None) -> EntityLabelsConfig | None:
|
|
"""
|
|
Parse raw entity labels config into EntityLabelsConfig.
|
|
|
|
Accepts:
|
|
- None → returns None
|
|
- list → list of attribute dicts (each may use legacy free_values/multi_value or new type field)
|
|
- dict → {attributes: [...]}
|
|
|
|
Legacy migration (backward-compat):
|
|
- free_values=True → type="text"
|
|
- multi_value=True → type="multi-values"
|
|
- neither / free_values=False → type="value"
|
|
|
|
Args:
|
|
raw: Raw entity labels config from bank config
|
|
|
|
Returns:
|
|
EntityLabelsConfig or None if raw is None/empty
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
|
|
if isinstance(raw, list):
|
|
if not raw:
|
|
return None
|
|
attributes = [LabelGroup.model_validate(_migrate_label_group(a)) for a in raw]
|
|
return EntityLabelsConfig(attributes=attributes)
|
|
|
|
if isinstance(raw, dict):
|
|
attrs_raw = raw.get("attributes", [])
|
|
if not attrs_raw:
|
|
return None
|
|
attributes = [LabelGroup.model_validate(_migrate_label_group(a)) for a in attrs_raw]
|
|
return EntityLabelsConfig(attributes=attributes)
|
|
|
|
return None
|
|
|
|
|
|
def _migrate_label_group(raw: dict) -> dict:
|
|
"""Migrate legacy free_values/multi_value fields to the new type field."""
|
|
if not isinstance(raw, dict) or "type" in raw:
|
|
return raw
|
|
patched = dict(raw)
|
|
if patched.get("free_values"):
|
|
patched["type"] = "text"
|
|
elif patched.get("multi_value"):
|
|
patched["type"] = "multi-values"
|
|
else:
|
|
patched["type"] = "value"
|
|
# Remove legacy keys so Pydantic doesn't error on unknown fields
|
|
patched.pop("free_values", None)
|
|
patched.pop("multi_value", None)
|
|
return patched
|
|
|
|
|
|
def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None:
|
|
"""
|
|
Build a dynamic Pydantic model for structured label extraction.
|
|
|
|
Each LabelGroup becomes a typed field based on its type:
|
|
- type="text" → str | None (always optional)
|
|
- type="value", optional=True → Literal["v1","v2"] | None
|
|
- type="value", optional=False → Literal["v1","v2"] (required)
|
|
- type="multi-values" → list[Literal["v1","v2"]]
|
|
|
|
Args:
|
|
labels_cfg: Parsed EntityLabelsConfig
|
|
|
|
Returns:
|
|
Dynamic Pydantic model class, or None if no groups defined
|
|
"""
|
|
fields: dict = {}
|
|
for group in labels_cfg.attributes:
|
|
if not group.key:
|
|
continue
|
|
description = group.description or group.key
|
|
|
|
if group.type == "text":
|
|
# Free-form: any string value accepted, always optional
|
|
fields[group.key] = (str | None, Field(default=None, description=description))
|
|
else:
|
|
# Enum-constrained: must have defined values
|
|
if not group.values:
|
|
continue
|
|
values = tuple(v.value for v in group.values if v.value)
|
|
if not values:
|
|
continue
|
|
# Literal[("v1", "v2")] is equivalent to Literal["v1", "v2"] in Python 3.11+
|
|
literal_type = Literal[values] # type: ignore[valid-type]
|
|
if group.type == "multi-values":
|
|
fields[group.key] = (
|
|
list[literal_type], # type: ignore[valid-type]
|
|
Field(default_factory=list, description=description),
|
|
)
|
|
elif group.optional:
|
|
fields[group.key] = (
|
|
literal_type | None, # type: ignore[valid-type]
|
|
Field(default=None, description=description),
|
|
)
|
|
else:
|
|
fields[group.key] = (
|
|
literal_type, # type: ignore[valid-type]
|
|
Field(description=description),
|
|
)
|
|
|
|
if not fields:
|
|
return None
|
|
|
|
return create_model("Labels", **fields)
|
|
|
|
|
|
def is_label_entity(text: str, labels_cfg: EntityLabelsConfig, labels_lookup: set[str]) -> bool:
|
|
"""
|
|
Return True if entity text belongs to any configured label group.
|
|
|
|
For enum groups: checks the pre-built lookup set.
|
|
For text groups: checks that the text starts with a known key prefix.
|
|
"""
|
|
if text.lower() in labels_lookup:
|
|
return True
|
|
for group in labels_cfg.attributes:
|
|
if group.type == "text" and group.key and text.lower().startswith(f"{group.key.lower()}:"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def build_labels_lookup(labels_cfg: EntityLabelsConfig | list | None) -> set[str]:
|
|
"""
|
|
Build a set of valid 'key:value' label strings (lowercase) for fast lookup.
|
|
|
|
Accepts either EntityLabelsConfig or raw list/None for backwards compatibility.
|
|
|
|
Args:
|
|
labels_cfg: EntityLabelsConfig, raw list of attribute dicts, or None
|
|
|
|
Returns:
|
|
Set of lowercase 'key:value' strings
|
|
"""
|
|
if labels_cfg is None:
|
|
return set()
|
|
|
|
# Accept raw list/dict for backwards compatibility
|
|
if not isinstance(labels_cfg, EntityLabelsConfig):
|
|
parsed = parse_entity_labels(labels_cfg)
|
|
if parsed is None:
|
|
return set()
|
|
labels_cfg = parsed
|
|
|
|
valid = set()
|
|
for group in labels_cfg.attributes:
|
|
if group.type == "text":
|
|
continue # No fixed vocabulary — all values accepted in post-processing
|
|
for v in group.values:
|
|
if group.key and v.value:
|
|
valid.add(f"{group.key}:{v.value}".lower())
|
|
return valid
|