diff --git a/backend/DB_STRUCTURE.md b/backend/DB_STRUCTURE.md index 78b0e2e..4c5ea6b 100644 --- a/backend/DB_STRUCTURE.md +++ b/backend/DB_STRUCTURE.md @@ -19,9 +19,13 @@ ### PersonFact - `id` - UUID primary key - `person_id` - links to `Person` +- `fact_category` - normalized category for the fact (for example `visual_descriptor`, `affiliation`, `hobby`) - `fact_text` - remembered fact about the person -- `fact_category` - `"visual_descriptor", "affiliation", "hobby"` -- `source_episode_id` - link to the `Episode` where the fact was learned or most recently updated +- `source` - optional link to the `Episode` where the fact was learned +- `embedding` - optional retrieval embedding for RAG-style fact lookup +- `confidence` - optional confidence score for the fact +- `created_at` - timestamp when the fact was created +- `updated_at` - timestamp when the fact was last updated ### Episodes - `id` - UUID primary key diff --git a/backend/alembic/versions/20260309_0001_initial_memory_schema.py b/backend/alembic/versions/20260309_0001_initial_memory_schema.py new file mode 100644 index 0000000..0b2104f --- /dev/null +++ b/backend/alembic/versions/20260309_0001_initial_memory_schema.py @@ -0,0 +1,145 @@ +"""Initial persistent memory schema.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from pgvector.sqlalchemy import Vector +from sqlalchemy.dialects import postgresql + +from app.core.config import get_settings + +settings = get_settings() + +# revision identifiers, used by Alembic. +revision = "20260309_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + + op.create_table( + "users", + sa.Column("first_name", sa.String(length=100), nullable=False), + sa.Column("last_name", sa.String(length=100), nullable=False), + sa.Column("display_name", sa.String(length=200), nullable=True), + sa.Column("username", sa.String(length=100), nullable=False), + sa.Column("oauth_provider", sa.String(length=100), nullable=True), + sa.Column("oauth_subject", sa.String(length=255), nullable=True), + sa.Column( + "preferences", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("oauth_provider", "oauth_subject", name="uq_users_oauth_identity"), + ) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + + op.create_table( + "people", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("first_name", sa.String(length=100), nullable=False), + sa.Column("last_name", sa.String(length=100), nullable=False), + sa.Column("display_name", sa.String(length=200), nullable=True), + sa.Column("face_embedding", Vector(dim=settings.embedding_dimension), nullable=True), + sa.Column("voice_embedding", Vector(dim=settings.embedding_dimension), nullable=True), + sa.Column("face_embedding_model", sa.String(length=100), nullable=True), + sa.Column("voice_embedding_model", sa.String(length=100), nullable=True), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_people_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_people")), + ) + op.create_index(op.f("ix_people_user_id"), "people", ["user_id"], unique=False) + op.create_index(op.f("ix_people_last_seen_at"), "people", ["last_seen_at"], unique=False) + + op.create_table( + "episodes", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("person_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("dialogue_summary", sa.Text(), nullable=False), + sa.Column("summary_version", sa.String(length=100), nullable=True), + sa.Column("importance_score", sa.Numeric(precision=4, scale=3), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["person_id"], ["people.id"], name=op.f("fk_episodes_person_id_people"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_episodes_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_episodes")), + ) + op.create_index(op.f("ix_episodes_person_id"), "episodes", ["person_id"], unique=False) + op.create_index(op.f("ix_episodes_person_id_start_time"), "episodes", ["person_id", "start_time"], unique=False) + op.create_index(op.f("ix_episodes_user_id"), "episodes", ["user_id"], unique=False) + op.create_index(op.f("ix_episodes_user_id_start_time"), "episodes", ["user_id", "start_time"], unique=False) + + op.create_table( + "person_facts", + sa.Column("person_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_episode_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("fact_text", sa.Text(), nullable=False), + sa.Column("source", sa.String(length=255), nullable=True), + sa.Column("confidence", sa.Numeric(precision=4, scale=3), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["person_id"], ["people.id"], name=op.f("fk_person_facts_person_id_people"), ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["source_episode_id"], + ["episodes.id"], + name=op.f("fk_person_facts_source_episode_id_episodes"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_person_facts")), + ) + op.create_index(op.f("ix_person_facts_person_id"), "person_facts", ["person_id"], unique=False) + op.create_index(op.f("ix_person_facts_source_episode_id"), "person_facts", ["source_episode_id"], unique=False) + + op.create_table( + "user_facts", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("fact_text", sa.Text(), nullable=False), + sa.Column("source", sa.String(length=255), nullable=True), + sa.Column("confidence", sa.Numeric(precision=4, scale=3), nullable=True), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_user_facts_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_facts")), + ) + op.create_index(op.f("ix_user_facts_user_id"), "user_facts", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_user_facts_user_id"), table_name="user_facts") + op.drop_table("user_facts") + + op.drop_index(op.f("ix_person_facts_source_episode_id"), table_name="person_facts") + op.drop_index(op.f("ix_person_facts_person_id"), table_name="person_facts") + op.drop_table("person_facts") + + op.drop_index(op.f("ix_episodes_user_id_start_time"), table_name="episodes") + op.drop_index(op.f("ix_episodes_user_id"), table_name="episodes") + op.drop_index(op.f("ix_episodes_person_id_start_time"), table_name="episodes") + op.drop_index(op.f("ix_episodes_person_id"), table_name="episodes") + op.drop_table("episodes") + + op.drop_index(op.f("ix_people_last_seen_at"), table_name="people") + op.drop_index(op.f("ix_people_user_id"), table_name="people") + op.drop_table("people") + + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_table("users") + + op.execute("DROP EXTENSION IF EXISTS vector") diff --git a/backend/alembic/versions/20260406_0002_align_person_facts_schema.py b/backend/alembic/versions/20260406_0002_align_person_facts_schema.py new file mode 100644 index 0000000..7f54cfc --- /dev/null +++ b/backend/alembic/versions/20260406_0002_align_person_facts_schema.py @@ -0,0 +1,107 @@ +"""Align person_facts with retrieval metadata schema.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from pgvector.sqlalchemy import Vector +from sqlalchemy.dialects import postgresql + +from app.core.config import get_settings + +settings = get_settings() + +# revision identifiers, used by Alembic. +revision = "20260406_0002" +down_revision = "20260309_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "person_facts", + sa.Column("fact_category", sa.String(length=50), nullable=False, server_default="general"), + ) + op.add_column( + "person_facts", + sa.Column("embedding", Vector(dim=settings.retrieval_embedding_dimension), nullable=True), + ) + op.add_column( + "person_facts", + sa.Column("source_tmp", postgresql.UUID(as_uuid=True), nullable=True), + ) + + op.execute("UPDATE person_facts SET source_tmp = source_episode_id") + + op.drop_constraint( + op.f("fk_person_facts_source_episode_id_episodes"), + "person_facts", + type_="foreignkey", + ) + op.drop_index(op.f("ix_person_facts_source_episode_id"), table_name="person_facts") + op.drop_column("person_facts", "source_episode_id") + op.drop_column("person_facts", "source") + + op.alter_column( + "person_facts", + "source_tmp", + existing_type=postgresql.UUID(as_uuid=True), + nullable=True, + new_column_name="source", + ) + op.create_foreign_key( + op.f("fk_person_facts_source_episodes"), + "person_facts", + "episodes", + ["source"], + ["id"], + ondelete="SET NULL", + ) + op.create_index(op.f("ix_person_facts_source"), "person_facts", ["source"], unique=False) + op.alter_column("person_facts", "fact_category", server_default=None) + + +def downgrade() -> None: + op.add_column( + "person_facts", + sa.Column("source_label_tmp", sa.String(length=255), nullable=True), + ) + op.add_column( + "person_facts", + sa.Column("source_episode_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + + op.execute("UPDATE person_facts SET source_episode_id = source") + + op.create_foreign_key( + op.f("fk_person_facts_source_episode_id_episodes"), + "person_facts", + "episodes", + ["source_episode_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index( + op.f("ix_person_facts_source_episode_id"), + "person_facts", + ["source_episode_id"], + unique=False, + ) + + op.drop_constraint( + op.f("fk_person_facts_source_episodes"), + "person_facts", + type_="foreignkey", + ) + op.drop_index(op.f("ix_person_facts_source"), table_name="person_facts") + op.drop_column("person_facts", "source") + op.alter_column( + "person_facts", + "source_label_tmp", + existing_type=sa.String(length=255), + nullable=True, + new_column_name="source", + ) + op.drop_column("person_facts", "embedding") + op.drop_column("person_facts", "fact_category") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 17e0d13..9ee46b8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -28,6 +28,13 @@ class Settings(BaseSettings): # Embeddings # # ------------------------------------------------------------------ # embedding_dimension: int = 512 + retrieval_embedding_dimension: int = 1024 + retrieval_bi_encoder_model: str = "BAAI/bge-m3" + retrieval_reranker_model: str = "BAAI/bge-reranker-v2-m3" + retrieval_bi_encoder_top_k: int = 100 + retrieval_bi_encoder_min_score: float = 0.0 + retrieval_reranker_top_k: int = 20 + retrieval_reranker_min_score: float = 0.0 db_echo: bool = False # ------------------------------------------------------------------ # diff --git a/backend/app/crud/memory_store.py b/backend/app/crud/memory_store.py index 8f6d3c2..c2db975 100644 --- a/backend/app/crud/memory_store.py +++ b/backend/app/crud/memory_store.py @@ -1,20 +1,20 @@ from __future__ import annotations -from collections import defaultdict from datetime import datetime, timezone import logging from decimal import Decimal from typing import Optional from uuid import UUID -from sqlalchemy import create_engine, func, select +from sqlalchemy import create_engine, delete, func, or_, select from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, aliased, sessionmaker from ..core.config import get_settings from ..core.database import Base from ..models.episode import Episode -from ..models.memory import Edge, EpisodeParticipant, Summary +from ..models.memory import Alias, Edge, EpisodeParticipant, Pref, Summary from ..models.person import Person, PersonFact from ..models.user import User, UserFact from ..schema.memory import ( @@ -25,6 +25,8 @@ FactOut, PersonIn, PersonOut, + PrefIn, + PrefOut, ProfileContext, SummaryIn, SummaryOut, @@ -33,7 +35,7 @@ logger = logging.getLogger("app.crud.memory_store") settings = get_settings() -# Priority order for fact categories used in disambiguation +# Priority order for categorized facts used to build resolver hints. FACT_CATEGORY_PRIORITY = ("visual_descriptor", "affiliation", "hobby") @@ -68,15 +70,8 @@ def _split_name(name: str) -> tuple[str, str, str]: class MemoryStore: """Typed, deterministic memory access layer over the project schema.""" - def __init__(self, db_url: str | None = None, *, owner_user_id: UUID) -> None: - """Create a store bound to a database URL and owner user. - - Args: - db_url: Override for the configured database URL. - owner_user_id: UUID of the user that owns all memory records - created and queried through this store. The caller is - responsible for ensuring this user exists in the database. - """ + def __init__(self, db_url: str | None = None, owner_user_id: UUID | None = None) -> None: + """Create a store bound to an optional database URL and owner user.""" self._db_url = db_url or settings.database_url self._owner_user_id = owner_user_id @@ -85,8 +80,9 @@ def __init__(self, db_url: str | None = None, *, owner_user_id: UUID) -> None: logger.info("MemoryStore created (db_url=%s, owner=%s)", self._db_url, owner_user_id) @property - def owner_user_id(self) -> UUID: - """The UUID of the owner user scoping all operations.""" + def owner_user_id(self) -> UUID | None: + """Return the active owner UUID, or ``None`` until one is resolved/created.""" + return self._owner_user_id def initialize(self, create_schema: bool = True) -> None: @@ -120,27 +116,19 @@ def Session(self) -> sessionmaker[Session]: raise RuntimeError("MemoryStore not initialized - call .initialize() first") return self._Session - # ── Person CRUD ────────────────────────────────────────── - def upsert_person( self, name: str, + aliases: Optional[list[str]] = None, face_key: Optional[str] = None, voice_key: Optional[str] = None, persona90: Optional[list[float]] = None, ) -> PersonOut: - """Create or update an interlocutor for the current owner. - - The lookup is case-insensitive on the person's display name within the - active owner scope. When a matching person already exists, the supplied - keys and persona vector are updated. - - Returns: - The normalized stored person record. - """ + """Create or update an interlocutor for the current owner.""" data = PersonIn( name=name, + aliases=aliases or [], face_key=face_key, voice_key=voice_key, persona90=persona90 or [], @@ -148,84 +136,110 @@ def upsert_person( persona_supplied = persona90 is not None with self.Session() as session: - with session.begin(): - person = session.scalar( - select(Person).where( - Person.user_id == self._owner_user_id, - func.lower(func.coalesce(Person.display_name, "")) == data.name.lower(), + try: + with session.begin(): + owner = self._get_or_create_owner(session) + person = session.scalar( + select(Person).where( + Person.user_id == owner.id, + func.lower(func.coalesce(Person.display_name, "")) == data.name.lower(), + ) ) - ) - - if person is None: - first_name, last_name, display_name = _split_name(data.name) - person = Person( - user_id=self._owner_user_id, - first_name=first_name, - last_name=last_name, - display_name=display_name, - face_key=data.face_key, - voice_key=data.voice_key, - persona90=data.persona90, - ) - session.add(person) - session.flush() - logger.debug("Inserted person id=%s name=%s", person.id, data.name) - else: - if data.face_key is not None: - person.face_key = data.face_key - if data.voice_key is not None: - person.voice_key = data.voice_key - if persona_supplied: - person.persona90 = data.persona90 - first_name, last_name, display_name = _split_name(data.name) - person.first_name = first_name - person.last_name = last_name - person.display_name = display_name - person.updated_at = datetime.now(timezone.utc) - session.flush() - logger.debug("Updated person id=%s name=%s", person.id, data.name) - return self._load_person(session, person.id) + if person is None: + first_name, last_name, display_name = _split_name(data.name) + person = Person( + user_id=owner.id, + first_name=first_name, + last_name=last_name, + display_name=display_name, + face_key=data.face_key, + voice_key=data.voice_key, + persona90=data.persona90, + ) + session.add(person) + session.flush() + logger.debug("Inserted person id=%s name=%s", person.id, data.name) + else: + if data.face_key is not None: + person.face_key = data.face_key + if data.voice_key is not None: + person.voice_key = data.voice_key + if persona_supplied: + person.persona90 = data.persona90 + first_name, last_name, display_name = _split_name(data.name) + person.first_name = first_name + person.last_name = last_name + person.display_name = display_name + person.updated_at = datetime.now(timezone.utc) + session.flush() + logger.debug("Updated person id=%s name=%s", person.id, data.name) + + session.execute(delete(Alias).where(Alias.person_id == person.id)) + for alias in data.aliases: + session.add(Alias(person_id=person.id, alias=alias)) + + return self._load_person(session, person.id) + except IntegrityError as exc: + raise ValueError(f"Unable to upsert person '{name}': {exc.orig}") from exc def list_people(self) -> list[Person]: - """Return all people for the current owner. - - Returns: - Person ORM instances. - An empty list when no people exist for the owner. - """ + """Return all people for the current owner.""" with self.Session() as session: - people = list( - session.scalars( - select(Person).where(Person.user_id == self._owner_user_id) - ).all() - ) - logger.debug("list_people: %d people for owner=%s", len(people), self._owner_user_id) + with session.begin(): + owner = self._get_or_create_owner(session) + people = list( + session.scalars( + select(Person).where(Person.user_id == owner.id).order_by(Person.created_at.asc()) + ).all() + ) return people def resolve_person_by_name(self, text: str) -> Optional[PersonOut]: - """Resolve a person by display name for the current owner. + """Resolve a person by display name only for the current owner.""" - Case-insensitive match on display_name. Used by the ingestion pipeline - for exact-name lookups. For query-based resolution with ambiguity - handling, use PersonResolver instead. + cleaned = text.strip() + if not cleaned: + return None - Returns: - The matching person record, or ``None`` when no match exists. - """ + with self.Session() as session: + with session.begin(): + owner = self._get_or_create_owner(session) + person = session.scalar( + select(Person).where( + Person.user_id == owner.id, + func.lower(func.coalesce(Person.display_name, "")) == cleaned.lower(), + ) + ) + + if person is None: + return None + + return self._load_person(session, person.id) + + def resolve_person_by_name_or_alias(self, text: str) -> Optional[PersonOut]: + """Resolve a person by display name or alias for the current owner.""" cleaned = text.strip() if not cleaned: return None with self.Session() as session: - person = session.scalar( - select(Person).where( - Person.user_id == self._owner_user_id, - func.lower(func.coalesce(Person.display_name, "")) == cleaned.lower(), + with session.begin(): + owner = self._get_or_create_owner(session) + person = session.scalar( + select(Person) + .outerjoin(Alias, Alias.person_id == Person.id) + .where( + Person.user_id == owner.id, + or_( + func.lower(func.coalesce(Person.display_name, "")) == cleaned.lower(), + func.lower(Alias.alias) == cleaned.lower(), + ), + ) + .limit(1) ) - ) if person is None: logger.debug("resolve_person: no match for '%s'", cleaned) @@ -234,8 +248,6 @@ def resolve_person_by_name(self, text: str) -> Optional[PersonOut]: logger.debug("resolve_person: '%s' -> id=%s", cleaned, person.id) return self._load_person(session, person.id) - # ── Episode / Fact / Summary / Edge writes ─────────────── - def write_episode( self, time_start: datetime, @@ -244,11 +256,7 @@ def write_episode( summary: str = "", participants: Optional[list[UUID]] = None, ) -> UUID: - """Insert a conversation episode and its participant links. - - Returns: - The UUID of the created episode row. - """ + """Insert a conversation episode and its participant links.""" data = EpisodeIn( time_start=time_start, @@ -260,13 +268,14 @@ def write_episode( with self.Session() as session: with session.begin(): + owner = self._get_or_create_owner(session) ordered_participants = list(dict.fromkeys(data.participant_ids)) if not ordered_participants: raise ValueError("write_episode requires at least one participant") - self._assert_people_exist(session, ordered_participants, self._owner_user_id) + self._assert_people_exist(session, ordered_participants, owner.id) episode = Episode( - user_id=self._owner_user_id, + user_id=owner.id, person_id=ordered_participants[0], start_time=data.time_start, end_time=data.time_end, @@ -276,8 +285,8 @@ def write_episode( session.add(episode) session.flush() - for person_id in ordered_participants: - session.add(EpisodeParticipant(episode_id=episode.id, person_id=person_id)) + for participant_id in ordered_participants: + session.add(EpisodeParticipant(episode_id=episode.id, person_id=participant_id)) episode_id = episode.id @@ -289,38 +298,48 @@ def write_fact( person_id: UUID, fact_text: str, confidence: float = 1.0, - fact_category: Optional[str] = None, episode_id: Optional[UUID] = None, valid_from: Optional[datetime] = None, valid_to: Optional[datetime] = None, + *, + fact_category: str = "general", + source: Optional[UUID] = None, + embedding: Optional[list[float]] = None, ) -> UUID: """Persist a structured fact about a person. - Returns: - The UUID of the created fact row. + ``source`` is the canonical episode UUID field. ``episode_id`` remains + accepted as a backward-compatible alias. """ + if source is not None and episode_id is not None and source != episode_id: + raise ValueError("write_fact received conflicting source and episode_id values") + + source_id = source or episode_id data = FactIn( person_id=person_id, + fact_category=fact_category, fact_text=fact_text, confidence=confidence, - fact_category=fact_category, - episode_id=episode_id, + source=source_id, + embedding=embedding, valid_from=valid_from, valid_to=valid_to, ) with self.Session() as session: with session.begin(): - self._assert_person_exists(session, data.person_id, self._owner_user_id) - self._assert_episode_exists(session, data.episode_id, self._owner_user_id) + owner = self._get_or_create_owner(session) + self._assert_person_exists(session, data.person_id, owner.id) + self._assert_episode_exists(session, data.source, owner.id) row = PersonFact( person_id=data.person_id, - fact_text=data.fact_text, fact_category=data.fact_category, + source=data.source, + fact_text=data.fact_text, + embedding=data.embedding, confidence=_decimal(data.confidence), - source_episode_id=data.episode_id, valid_from=data.valid_from, valid_to=data.valid_to, ) @@ -331,6 +350,41 @@ def write_fact( logger.debug("Wrote fact id=%s for person=%s", fact_id, data.person_id) return fact_id + def write_pref( + self, + person_id: UUID, + pref_text: str, + confidence: float = 1.0, + episode_id: Optional[UUID] = None, + ) -> UUID: + """Persist a preference remembered about a person.""" + + data = PrefIn( + person_id=person_id, + pref_text=pref_text, + confidence=confidence, + episode_id=episode_id, + ) + + with self.Session() as session: + with session.begin(): + owner = self._get_or_create_owner(session) + self._assert_person_exists(session, data.person_id, owner.id) + self._assert_episode_exists(session, data.episode_id, owner.id) + + row = Pref( + person_id=data.person_id, + pref_text=data.pref_text, + confidence=_decimal(data.confidence), + episode_id=data.episode_id, + ) + session.add(row) + session.flush() + pref_id = row.id + + logger.debug("Wrote pref id=%s for person=%s", pref_id, data.person_id) + return pref_id + def write_summary( self, person_id: UUID, @@ -339,11 +393,7 @@ def write_summary( episode_time_end: Optional[datetime] = None, episode_id: Optional[UUID] = None, ) -> UUID: - """Persist a summary slice for a person. - - Returns: - The UUID of the created summary row. - """ + """Persist a summary slice for a person.""" data = SummaryIn( person_id=person_id, @@ -355,8 +405,9 @@ def write_summary( with self.Session() as session: with session.begin(): - self._assert_person_exists(session, data.person_id, self._owner_user_id) - self._assert_episode_exists(session, data.episode_id, self._owner_user_id) + owner = self._get_or_create_owner(session) + self._assert_person_exists(session, data.person_id, owner.id) + self._assert_episode_exists(session, data.episode_id, owner.id) row = Summary( person_id=data.person_id, @@ -380,13 +431,7 @@ def write_edge( confidence: float = 1.0, episode_id: Optional[UUID] = None, ) -> UUID: - """Create or update a directed relationship between two people. - - Edges are unique by ``(src_id, relation, dst_id)``. - - Returns: - The UUID of the inserted or updated edge row. - """ + """Create or update a directed relationship between two people.""" data = EdgeIn( src_id=src_id, @@ -398,9 +443,10 @@ def write_edge( with self.Session() as session: with session.begin(): - self._assert_person_exists(session, data.src_id, self._owner_user_id) - self._assert_person_exists(session, data.dst_id, self._owner_user_id) - self._assert_episode_exists(session, data.episode_id, self._owner_user_id) + owner = self._get_or_create_owner(session) + self._assert_person_exists(session, data.src_id, owner.id) + self._assert_person_exists(session, data.dst_id, owner.id) + self._assert_episode_exists(session, data.episode_id, owner.id) edge = session.scalar( select(Edge).where( @@ -427,94 +473,90 @@ def write_edge( edge_id = edge.id - logger.debug("Wrote edge id=%s: %s -[%s]-> %s", edge_id, data.src_id, data.relation, data.dst_id) + logger.debug( + "Wrote edge id=%s: person %s -[%s]-> person %s", + edge_id, + data.src_id, + data.relation, + data.dst_id, + ) return edge_id - # ── Read methods ───────────────────────────────────────── - def get_user_facts(self) -> list[str]: - """Return all fact texts stored for the current owner user. - - Used by the ingestion pipeline to provide wearer context. - """ + """Return all fact texts stored for the current owner user.""" with self.Session() as session: - rows = session.scalars( - select(UserFact.fact_text) - .where(UserFact.user_id == self._owner_user_id) - .order_by(UserFact.created_at.asc()) - ).all() + with session.begin(): + owner = self._get_or_create_owner(session) + rows = session.scalars( + select(UserFact.fact_text) + .where(UserFact.user_id == owner.id) + .order_by(UserFact.created_at.asc()) + ).all() return list(rows) def get_disambiguation_hints(self, person_id: UUID) -> dict[str, list[str]]: - """Return categorized fact texts for person disambiguation. - - Groups facts by ``fact_category`` in priority order: - visual_descriptor, affiliation, hobby. - - Returns: - Dict keyed by category with lists of fact_text strings. - Categories with no facts are omitted. - """ + """Return categorized fact texts used to disambiguate same-name people.""" with self.Session() as session: - rows = session.execute( - select(PersonFact.fact_category, PersonFact.fact_text) - .where( - PersonFact.person_id == person_id, - PersonFact.fact_category.isnot(None), - ) - .order_by(PersonFact.created_at.desc()) - ).all() + with session.begin(): + owner = self._get_or_create_owner(session) + self._assert_person_exists(session, person_id, owner.id) + rows = session.execute( + select(PersonFact.fact_category, PersonFact.fact_text) + .where(PersonFact.person_id == person_id) + .order_by(PersonFact.created_at.desc()) + ).all() hints: dict[str, list[str]] = {} for category in FACT_CATEGORY_PRIORITY: - texts = [text for cat, text in rows if cat == category] + texts = [text for fact_category, text in rows if fact_category == category] if texts: hints[category] = texts return hints def get_profile_context(self, person_id: UUID) -> ProfileContext: - """Return the profile context bundle for one person. - - The returned structure contains facts, summaries, outgoing edges, - and the stored ``persona90`` vector ordered newest-first. - - Returns: - A ``ProfileContext`` ready for deterministic retrieval use. - """ + """Return the stored profile bundle for one person.""" with self.Session() as session: - person = self._get_person(session, person_id, self._owner_user_id) + with session.begin(): + owner = self._get_or_create_owner(session) + person = self._get_person(session, person_id, owner.id) - facts_rows = session.scalars( - select(PersonFact) - .where(PersonFact.person_id == person_id) - .order_by(PersonFact.created_at.desc()) - ).all() + facts_rows = session.scalars( + select(PersonFact) + .where(PersonFact.person_id == person_id) + .order_by(PersonFact.created_at.desc()) + ).all() - summaries_rows = session.scalars( - select(Summary) - .where(Summary.person_id == person_id) - .order_by(Summary.created_at.desc()) - ).all() + prefs_rows = session.scalars( + select(Pref).where(Pref.person_id == person_id).order_by(Pref.created_at.desc()) + ).all() - dst_person = aliased(Person) - edge_rows = session.execute( - select(Edge, func.coalesce(dst_person.display_name, dst_person.first_name)) - .join(dst_person, dst_person.id == Edge.dst_id) - .where(Edge.src_id == person_id) - .order_by(Edge.created_at.desc()) - ).all() + summaries_rows = session.scalars( + select(Summary) + .where(Summary.person_id == person_id) + .order_by(Summary.created_at.desc()) + ).all() + + dst_person = aliased(Person) + edge_rows = session.execute( + select(Edge, func.coalesce(dst_person.display_name, dst_person.first_name)) + .join(dst_person, dst_person.id == Edge.dst_id) + .where(Edge.src_id == person_id) + .order_by(Edge.created_at.desc()) + ).all() facts = [ FactOut( id=row.id, + fact_category=row.fact_category, fact_text=row.fact_text, confidence=_float(row.confidence), - fact_category=row.fact_category, - episode_id=row.source_episode_id, + source=row.source, + embedding=row.embedding, + episode_id=row.source, valid_from=_iso(row.valid_from), valid_to=_iso(row.valid_to), created_at=_iso(row.created_at) or "", @@ -522,6 +564,17 @@ def get_profile_context(self, person_id: UUID) -> ProfileContext: for row in facts_rows ] + prefs = [ + PrefOut( + id=row.id, + pref_text=row.pref_text, + confidence=_float(row.confidence), + episode_id=row.episode_id, + created_at=_iso(row.created_at) or "", + ) + for row in prefs_rows + ] + summaries = [ SummaryOut( id=row.id, @@ -549,15 +602,17 @@ def get_profile_context(self, person_id: UUID) -> ProfileContext: return ProfileContext( facts=facts, + prefs=prefs, summaries=summaries, edges_from=edges, persona90=person.persona90 or [], ) - # ── Private helpers ────────────────────────────────────── - def _load_person(self, session: Session, person_id: UUID) -> PersonOut: person = self._get_person(session, person_id, person_user_id=None) + aliases = session.scalars( + select(Alias.alias).where(Alias.person_id == person_id).order_by(Alias.alias.asc()) + ).all() return PersonOut( id=person.id, @@ -565,10 +620,33 @@ def _load_person(self, session: Session, person_id: UUID) -> PersonOut: face_key=person.face_key, voice_key=person.voice_key, persona90=person.persona90 or [], + aliases=list(aliases), created_at=_iso(person.created_at) or "", updated_at=_iso(person.updated_at) or "", ) + def _get_or_create_owner(self, session: Session) -> User: + if self._owner_user_id is not None: + owner = session.get(User, self._owner_user_id) + if owner is None: + raise ValueError(f"Owner user id={self._owner_user_id} not found") + return owner + + owner = session.scalar(select(User).order_by(User.created_at.asc()).limit(1)) + if owner is None: + owner = User( + first_name="Memory", + last_name="Owner", + display_name="Memory Store Owner", + username="memory-store-owner", + ) + session.add(owner) + session.flush() + logger.debug("Created default owner user id=%s", owner.id) + + self._owner_user_id = owner.id + return owner + def _get_person(self, session: Session, person_id: UUID, person_user_id: UUID | None) -> Person: person = session.get(Person, person_id) if person is None or (person_user_id is not None and person.user_id != person_user_id): diff --git a/backend/app/models/person.py b/backend/app/models/person.py index a89e4d1..ee38902 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -17,8 +17,10 @@ from pgvector.sqlalchemy import Vector VECTOR_TYPE = Vector(settings.embedding_dimension).with_variant(JSON(), "sqlite") + FACT_VECTOR_TYPE = Vector(settings.retrieval_embedding_dimension).with_variant(JSON(), "sqlite") except ModuleNotFoundError: VECTOR_TYPE = JSON() + FACT_VECTOR_TYPE = JSON() PERSONA_TYPE = ARRAY(Float).with_variant(JSON(), "sqlite") @@ -96,7 +98,15 @@ class PersonFact(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "person_facts" __table_args__ = ( CheckConstraint( - "fact_category IN ('visual_descriptor', 'affiliation', 'hobby')", + "fact_category IN (" + "'general', " + "'visual_descriptor', " + "'affiliation', " + "'hobby', " + "'biographical', " + "'relationship', " + "'other'" + ")", name="ck_person_facts_fact_category", ), ) @@ -106,14 +116,14 @@ class PersonFact(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, index=True, ) - source_episode_id: Mapped[Any | None] = mapped_column( + fact_category: Mapped[str] = mapped_column(String(50), nullable=False, default="general") + source: Mapped[Any | None] = mapped_column( ForeignKey("episodes.id", ondelete="SET NULL"), nullable=True, index=True, ) fact_text: Mapped[str] = mapped_column(Text, nullable=False) - fact_category: Mapped[str | None] = mapped_column(String(50), nullable=True) - source: Mapped[str | None] = mapped_column(String(255), nullable=True) + embedding: Mapped[list[float] | None] = mapped_column(FACT_VECTOR_TYPE, nullable=True) confidence: Mapped[Decimal | None] = mapped_column(Numeric(4, 3), nullable=True) valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/schema/__init__.py b/backend/app/schema/__init__.py index 721bafa..da0d1f3 100644 --- a/backend/app/schema/__init__.py +++ b/backend/app/schema/__init__.py @@ -1,6 +1,14 @@ """Pydantic schemas used by the backend.""" -from .memory import EdgeOut, FactOut, PersonOut, ProfileContext, SummaryOut +from .memory import ( + EdgeOut, + FactOut, + PersonOut, + PrefOut, + ProfileContext, + RetrievedPersonContext, + SummaryOut, +) from .person_resolver import ResolveCandidate, ResolveResult from .user import UserFactRead, UserRead @@ -8,9 +16,11 @@ "EdgeOut", "FactOut", "PersonOut", + "PrefOut", "ProfileContext", "ResolveCandidate", "ResolveResult", + "RetrievedPersonContext", "SummaryOut", "UserFactRead", "UserRead", diff --git a/backend/app/schema/memory.py b/backend/app/schema/memory.py index 85225bc..a4f0441 100644 --- a/backend/app/schema/memory.py +++ b/backend/app/schema/memory.py @@ -4,53 +4,69 @@ Pydantic models for the EgoMem memory store. Every public method on MemoryStore accepts and returns these models. -This gives us: - - runtime input validation (replaces Zod from the TS world) - - auto-generated JSON schemas (useful for FastAPI) - - a single place to see every data shape in the system - -Design notes: - - "In" models = what you pass INTO a method (write inputs) - - "Out" models = what you get BACK from a method (read outputs) - - ProfileContext = the composite shape returned by get_profile_context() - This maps directly to the Level-1 MemChunk content - that gets injected into the dialog model (Eq. 2: p_t). """ from __future__ import annotations from datetime import datetime -from typing import Literal, Optional +from typing import Optional from uuid import UUID from pydantic import BaseModel, Field, field_validator -# Allowed categories for PersonFact.fact_category (mirrors DB CHECK constraint) -PersonFactCategory = Literal["visual_descriptor", "affiliation", "hobby"] +from ..core.config import get_settings +settings = get_settings() + +_FACT_CATEGORIES = { + "general", + "visual_descriptor", + "affiliation", + "hobby", + "biographical", + "relationship", + "other", +} -# ── Shared validators ──────────────────────────────────────── def _check_confidence(v: float) -> float: - """Confidence must be between 0 and 1 inclusive.""" if not 0.0 <= v <= 1.0: raise ValueError(f"confidence must be in [0, 1], got {v}") return v def _check_persona90(v: list[float]) -> list[float]: - """persona90 must be exactly 90 floats (or empty).""" if len(v) != 0 and len(v) != 90: raise ValueError(f"persona90 must have 0 or 90 elements, got {len(v)}") return v -#input schemas +def _check_retrieval_embedding(v: list[float] | None) -> list[float] | None: + if v is None: + return None + if len(v) != settings.retrieval_embedding_dimension: + raise ValueError( + "embedding must have " + f"{settings.retrieval_embedding_dimension} elements, got {len(v)}" + ) + return v + + +def _normalize_fact_category(v: str) -> str: + normalized = v.strip().lower().replace("-", "_").replace(" ", "_") + if normalized == "affilation": + normalized = "affiliation" + if normalized not in _FACT_CATEGORIES: + allowed = ", ".join(sorted(_FACT_CATEGORIES)) + raise ValueError(f"fact_category must be one of: {allowed}") + return normalized + -#validates name length and persona length rule class PersonIn(BaseModel): """Input for upsert_person().""" + name: str = Field(..., min_length=1, max_length=200) + aliases: list[str] = Field(default_factory=list) face_key: Optional[str] = None voice_key: Optional[str] = None persona90: list[float] = Field(default_factory=list) @@ -60,9 +76,18 @@ class PersonIn(BaseModel): def validate_persona90(cls, v: list[float]) -> list[float]: return _check_persona90(v) + @field_validator("aliases") + @classmethod + def validate_aliases(cls, v: list[str]) -> list[str]: + cleaned = [a.strip() for a in v if a.strip()] + if len(cleaned) != len(set(a.lower() for a in cleaned)): + raise ValueError("aliases must be unique (case-insensitive)") + return cleaned + class EpisodeIn(BaseModel): """Input for write_episode().""" + time_start: datetime time_end: datetime transcript: str = "" @@ -79,20 +104,32 @@ def end_after_start(cls, v: datetime, info) -> datetime: class FactIn(BaseModel): - """Input for write_fact().""" + """Input shape for saving one fact.""" + person_id: UUID + fact_category: str = "general" fact_text: str = Field(..., min_length=1) confidence: float = 1.0 - fact_category: Optional[PersonFactCategory] = None - episode_id: Optional[UUID] = None + source: Optional[UUID] = None + embedding: list[float] | None = None valid_from: Optional[datetime] = None valid_to: Optional[datetime] = None + @field_validator("fact_category") + @classmethod + def validate_fact_category(cls, v: str) -> str: + return _normalize_fact_category(v) + @field_validator("confidence") @classmethod def validate_confidence(cls, v: float) -> float: return _check_confidence(v) + @field_validator("embedding") + @classmethod + def validate_embedding(cls, v: list[float] | None) -> list[float] | None: + return _check_retrieval_embedding(v) + @field_validator("valid_to") @classmethod def valid_range(cls, v: Optional[datetime], info) -> Optional[datetime]: @@ -102,8 +139,23 @@ def valid_range(cls, v: Optional[datetime], info) -> Optional[datetime]: return v +class PrefIn(BaseModel): + """Input for write_pref().""" + + person_id: UUID + pref_text: str = Field(..., min_length=1) + confidence: float = 1.0 + episode_id: Optional[UUID] = None + + @field_validator("confidence") + @classmethod + def validate_confidence(cls, v: float) -> float: + return _check_confidence(v) + + class SummaryIn(BaseModel): """Input for write_summary().""" + person_id: UUID summary_text: str = Field(..., min_length=1) episode_time_start: Optional[datetime] = None @@ -118,9 +170,10 @@ def summary_end_after_start(cls, v: Optional[datetime], info) -> Optional[dateti raise ValueError("episode_time_end must be >= episode_time_start") return v -#enforces non-empty relation and forbids self edges + class EdgeIn(BaseModel): """Input for write_edge().""" + src_id: UUID relation: str = Field(..., min_length=1, max_length=100) dst_id: UUID @@ -140,28 +193,49 @@ def no_self_edges(cls, v: UUID, info) -> UUID: return v -#outputs schemas - class PersonOut(BaseModel): id: UUID name: str face_key: Optional[str] voice_key: Optional[str] persona90: list[float] + aliases: list[str] created_at: str updated_at: str class FactOut(BaseModel): + """Output shape for one stored fact.""" + id: UUID + fact_category: str fact_text: str confidence: float - fact_category: Optional[str] = None - episode_id: Optional[UUID] + source: Optional[UUID] + embedding: list[float] | None = None + episode_id: Optional[UUID] = None valid_from: Optional[str] valid_to: Optional[str] created_at: str + @field_validator("fact_category") + @classmethod + def validate_fact_category(cls, v: str) -> str: + return _normalize_fact_category(v) + + @field_validator("embedding") + @classmethod + def validate_embedding(cls, v: list[float] | None) -> list[float] | None: + return _check_retrieval_embedding(v) + + +class PrefOut(BaseModel): + id: UUID + pref_text: str + confidence: float + episode_id: Optional[UUID] + created_at: str + class SummaryOut(BaseModel): id: UUID @@ -183,18 +257,19 @@ class EdgeOut(BaseModel): class ProfileContext(BaseModel): - """ - The composite view returned by get_profile_context(). - - This is the Python equivalent of the Level-1 MemChunk content - from the paper. When the retrieval process identifies a user, - it calls get_profile_context(person_id) and the result gets - serialized into the text channel of the MemChunk (Eq. 2: p_t). + """Output shape returned by ``get_profile_context(person_id)``.""" - Shape matches the issue spec: - { facts, summaries, edges_from, persona90 } - """ facts: list[FactOut] = Field(default_factory=list) + prefs: list[PrefOut] = Field(default_factory=list) summaries: list[SummaryOut] = Field(default_factory=list) edges_from: list[EdgeOut] = Field(default_factory=list) persona90: list[float] = Field(default_factory=list) + + +class RetrievedPersonContext(BaseModel): + """Output shape returned by ``retrieve_person_context(person_id, query)``.""" + + person_id: UUID + facts: list[FactOut] = Field(default_factory=list) + summaries: list[SummaryOut] = Field(default_factory=list) + edges: list[EdgeOut] = Field(default_factory=list) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 4801999..756ca87 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,18 +1,13 @@ -"""Services layer — business logic above the CRUD layer. - -Public surface: - -* :func:`ingest_conversation` — full conversation ingestion pipeline -* :class:`IngestionResult` — typed return value from the pipeline -* :class:`EmbeddingProvider` — protocol for plugging in a RAG embedding backend -""" +"""Service-layer helpers for backend application workflows.""" +from ..schema.ingestion import IngestionResult from .conversation_ingestion import ingest_conversation from .embedding import EmbeddingProvider -from ..schema.ingestion import IngestionResult +from .retrieval_service import retrieve_person_context __all__ = [ - "ingest_conversation", - "IngestionResult", "EmbeddingProvider", + "IngestionResult", + "ingest_conversation", + "retrieve_person_context", ] diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py index 0586c46..7481f3f 100644 --- a/backend/app/services/llm_client.py +++ b/backend/app/services/llm_client.py @@ -11,9 +11,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING - -from openai import AsyncOpenAI +from typing import TYPE_CHECKING, Any from ..core.config import get_settings from ..schema.ingestion import ( @@ -22,7 +20,7 @@ ) if TYPE_CHECKING: - pass + from openai import AsyncOpenAI logger = logging.getLogger("app.services.llm_client") @@ -92,8 +90,15 @@ def __init__( max_retries: int | None = None, ) -> None: settings = get_settings() + try: + from openai import AsyncOpenAI + except ModuleNotFoundError as exc: + raise RuntimeError( + "openai package is required to construct LLMClient. " + "Install backend requirements before using the real ingestion client." + ) from exc self._model = model or settings.openai_model - self._client = AsyncOpenAI( + self._client: Any = AsyncOpenAI( api_key=api_key or settings.openai_api_key, max_retries=max_retries if max_retries is not None else settings.openai_max_retries, ) diff --git a/backend/app/services/retrieval_service.py b/backend/app/services/retrieval_service.py new file mode 100644 index 0000000..35dd0fa --- /dev/null +++ b/backend/app/services/retrieval_service.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from dataclasses import dataclass +import importlib.util +import math +import re +from typing import Any, Protocol, Sequence, cast +from uuid import UUID + +from ..core.config import get_settings +from ..crud.memory_store import MemoryStore +from ..schema.memory import EdgeOut, FactOut, RetrievedPersonContext, SummaryOut + +settings = get_settings() + +_STOP_WORDS = { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "from", + "how", + "i", + "in", + "is", + "it", + "me", + "my", + "of", + "on", + "or", + "that", + "the", + "their", + "them", + "they", + "this", + "to", + "was", + "what", + "when", + "where", + "who", + "with", +} + + +class BiEncoder(Protocol): + """Input: one query string and many candidate texts. Output: one score per text for the first retrieval pass.""" + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + ... + + +class CrossEncoderReranker(Protocol): + """Input: one query string and many candidate texts. Output: one score per text for the final reranking pass.""" + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + ... + + +@dataclass(frozen=True) +class MemoryCandidate: + """One retrievable memory item. Input to ranking is its text; output is the original fact, summary, or edge payload.""" + + kind: str + text: str + payload: FactOut | SummaryOut | EdgeOut + + +class LexicalBiEncoder: + """Fallback scorer. Input: query plus candidate texts. Output: simple lexical similarity scores.""" + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + query_weights = _token_weights(query) + if not query_weights: + return [0.0 for _ in texts] + + return [_weighted_overlap_score(query_weights, _token_weights(text)) for text in texts] + + +class LexicalCrossEncoder: + """Fallback reranker. Input: query plus candidate texts. Output: slightly stricter lexical scores for final ordering.""" + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + normalized_query = query.strip().lower() + query_weights = _token_weights(query) + if not normalized_query and not query_weights: + return [0.0 for _ in texts] + + scores: list[float] = [] + for text in texts: + text_weights = _token_weights(text) + overlap = _weighted_overlap_score(query_weights, text_weights) + phrase_bonus = 0.25 if normalized_query and normalized_query in text.lower() else 0.0 + scores.append(overlap + phrase_bonus) + return scores + + +class BGEM3BiEncoder: + """Model-backed first-pass retriever. Input: query and candidate texts. Output: coarse relevance scores.""" + + def __init__(self, model_name: str = settings.retrieval_bi_encoder_model) -> None: + self._model_name = model_name + self._model: Any | None = None + + def _load_model(self) -> Any: + if self._model is None: + from sentence_transformers import SentenceTransformer + + self._model = SentenceTransformer(self._model_name) + return self._model + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + if not texts: + return [] + + model = self._load_model() + embeddings = model.encode( + [query, *texts], + normalize_embeddings=True, + ) + query_embedding = embeddings[0] + candidate_embeddings = embeddings[1:] + return [ + float(sum(float(a) * float(b) for a, b in zip(query_embedding, candidate))) + for candidate in candidate_embeddings + ] + + +class BGEReranker: + """Model-backed reranker. Input: query and candidate texts. Output: final relevance scores.""" + + def __init__(self, model_name: str = settings.retrieval_reranker_model) -> None: + self._model_name = model_name + self._model: Any | None = None + + def _load_model(self) -> Any: + if self._model is None: + from sentence_transformers import CrossEncoder + + self._model = CrossEncoder(self._model_name) + return self._model + + def score(self, query: str, texts: Sequence[str]) -> list[float]: + if not texts: + return [] + + model = self._load_model() + pairs = [(query, text) for text in texts] + predictions = model.predict(pairs) + return [float(score) for score in predictions] + + +def retrieve_person_context( + person_id: UUID, + query: str, + store: MemoryStore | None = None, + bi_encoder: BiEncoder | None = None, + reranker: CrossEncoderReranker | None = None, + bi_encoder_top_k: int = settings.retrieval_bi_encoder_top_k, + bi_encoder_min_score: float = settings.retrieval_bi_encoder_min_score, + reranker_top_k: int = settings.retrieval_reranker_top_k, + reranker_min_score: float = settings.retrieval_reranker_min_score, +) -> RetrievedPersonContext: + """Input: a person ID, the user's question, and optional store/scorers. + + Output: a ``RetrievedPersonContext`` containing the most relevant facts, + summaries, and outgoing edges for that person. + """ + + owns_store = store is None + memory_store = store or MemoryStore() + if owns_store: + memory_store.initialize() + + try: + profile = memory_store.get_profile_context(person_id) + candidates = _build_candidates(profile.facts, profile.summaries, profile.edges_from) + + if not query.strip(): + top_candidates = candidates[:reranker_top_k] + else: + bi_encoder_impl = bi_encoder or _default_bi_encoder() + reranker_impl = reranker or _default_reranker() + try: + top_candidates = _retrieve_top_candidates( + query=query, + candidates=candidates, + bi_encoder=bi_encoder_impl, + reranker=reranker_impl, + bi_encoder_top_k=bi_encoder_top_k, + bi_encoder_min_score=bi_encoder_min_score, + reranker_top_k=reranker_top_k, + reranker_min_score=reranker_min_score, + ) + except Exception: + if bi_encoder is not None or reranker is not None: + raise + top_candidates = _retrieve_top_candidates( + query=query, + candidates=candidates, + bi_encoder=LexicalBiEncoder(), + reranker=LexicalCrossEncoder(), + bi_encoder_top_k=bi_encoder_top_k, + bi_encoder_min_score=bi_encoder_min_score, + reranker_top_k=reranker_top_k, + reranker_min_score=reranker_min_score, + ) + + facts: list[FactOut] = [] + summaries: list[SummaryOut] = [] + edges: list[EdgeOut] = [] + + for candidate in top_candidates: + if candidate.kind == "fact": + facts.append(cast(FactOut, candidate.payload)) + elif candidate.kind == "summary": + summaries.append(cast(SummaryOut, candidate.payload)) + elif candidate.kind == "edge": + edges.append(cast(EdgeOut, candidate.payload)) + + return RetrievedPersonContext( + person_id=person_id, + facts=facts, + summaries=summaries, + edges=edges, + ) + finally: + if owns_store: + memory_store.close() + + +def _build_candidates( + facts: Sequence[FactOut], + summaries: Sequence[SummaryOut], + edges: Sequence[EdgeOut], +) -> list[MemoryCandidate]: + """Input: separate fact, summary, and edge lists. Output: one combined candidate list used for ranking.""" + candidates: list[MemoryCandidate] = [] + + candidates.extend( + MemoryCandidate( + kind="fact", + text=f"{fact.fact_category} {fact.fact_text}".strip(), + payload=fact, + ) + for fact in facts + ) + candidates.extend( + MemoryCandidate(kind="summary", text=summary.summary_text, payload=summary) + for summary in summaries + ) + candidates.extend( + MemoryCandidate( + kind="edge", + text=f"{edge.relation} {edge.dst_name}".strip(), + payload=edge, + ) + for edge in edges + ) + + return candidates + + +def _retrieve_top_candidates( + query: str, + candidates: Sequence[MemoryCandidate], + bi_encoder: BiEncoder, + reranker: CrossEncoderReranker, + bi_encoder_top_k: int, + bi_encoder_min_score: float, + reranker_top_k: int, + reranker_min_score: float, +) -> list[MemoryCandidate]: + """Input: query plus all candidates and scoring settings. Output: the highest-ranked candidates after both retrieval stages.""" + if not candidates: + return [] + + bi_scores = bi_encoder.score(query, [candidate.text for candidate in candidates]) + coarse_candidates = _take_top( + candidates, + bi_scores, + bi_encoder_top_k, + min_score=bi_encoder_min_score, + ) + if not coarse_candidates: + return [] + + rerank_scores = reranker.score(query, [candidate.text for candidate in coarse_candidates]) + return _take_top( + coarse_candidates, + rerank_scores, + reranker_top_k, + min_score=reranker_min_score, + ) + + +def _take_top( + candidates: Sequence[MemoryCandidate], + scores: Sequence[float], + limit: int, + min_score: float | None = None, +) -> list[MemoryCandidate]: + """Input: candidates with scores. Output: the best candidates up to ``limit``, optionally dropping low-score items.""" + ranked = list(zip(candidates, scores, range(len(candidates)))) + if min_score is not None: + ranked = [row for row in ranked if row[1] > min_score] + ranked.sort(key=lambda row: (row[1], -row[2]), reverse=True) + return [candidate for candidate, _, _ in ranked[:limit]] + + +def _default_bi_encoder() -> BiEncoder: + if importlib.util.find_spec("sentence_transformers") is None: + return LexicalBiEncoder() + return BGEM3BiEncoder() + + +def _default_reranker() -> CrossEncoderReranker: + if importlib.util.find_spec("sentence_transformers") is None: + return LexicalCrossEncoder() + return BGEReranker() + + +def _normalize_token(token: str) -> str: + if len(token) > 4 and token.endswith("ies"): + return token[:-3] + "y" + if len(token) > 4 and token.endswith("ing"): + return token[:-3] + if len(token) > 3 and token.endswith("ed"): + return token[:-2] + if len(token) > 3 and token.endswith("es"): + return token[:-2] + if len(token) > 3 and token.endswith("s") and not token.endswith(("is", "ss")): + return token[:-1] + return token + + +def _token_weights(text: str) -> dict[str, float]: + counts: dict[str, int] = {} + for raw_token in re.findall(r"[a-z0-9]+", text.lower()): + if raw_token in _STOP_WORDS: + continue + token = _normalize_token(raw_token) + counts[token] = counts.get(token, 0) + 1 + + return { + token: 1.0 + math.log(count) + for token, count in counts.items() + } + + +def _weighted_overlap_score( + query_weights: dict[str, float], + text_weights: dict[str, float], +) -> float: + if not query_weights or not text_weights: + return 0.0 + + overlap = set(query_weights) & set(text_weights) + if not overlap: + return 0.0 + + numerator = sum(min(query_weights[token], text_weights[token]) for token in overlap) + denominator = math.sqrt(sum(weight * weight for weight in query_weights.values())) + if denominator == 0.0: + return 0.0 + return numerator / denominator diff --git a/backend/scripts/seed_memory_store.py b/backend/scripts/seed_memory_store.py index 44ddad2..ec66a18 100644 --- a/backend/scripts/seed_memory_store.py +++ b/backend/scripts/seed_memory_store.py @@ -70,7 +70,13 @@ def main() -> None: summary="Conversation about hobbies and weekend plans.", participants=[emily.id, john.id], ) - store.write_fact(emily.id, "likes swimming", confidence=0.9, episode_id=episode_id) + store.write_fact( + emily.id, + "likes swimming", + fact_category="hobby", + confidence=0.9, + source=episode_id, + ) store.write_pref(emily.id, "energy: high", confidence=0.8, episode_id=episode_id) store.write_summary( emily.id, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6f74617..c5f9817 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,14 +2,43 @@ Environment variables are set here at the very top — before any app module is imported — so that pydantic-settings can validate required fields during -collection. These values are test-only stubs; the MemoryStore tests always +collection. These values are test-only stubs; the MemoryStore tests always pass their own ``db_url`` directly, and LLM calls are fully mocked. + +This file also includes a tiny async-test runner so ``async def`` tests can +run without requiring ``pytest-asyncio`` to be installed in every environment. """ +import asyncio +import inspect import os +import pytest + # Provide the required settings fields so pydantic-settings validates cleanly. # Tests always override these at the MemoryStore/LLMClient constructor level, # so these stubs are never actually used to open a connection or make API calls. os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") os.environ.setdefault("OPENAI_API_KEY", "test-key-not-used-in-tests") + + +def pytest_configure(config): + """Register the asyncio marker used by async ingestion tests.""" + + config.addinivalue_line("markers", "asyncio: mark test to run in an asyncio event loop") + + +@pytest.hookimpl(tryfirst=True) +def pytest_pyfunc_call(pyfuncitem): + """Run coroutine tests with ``asyncio.run`` when no async plugin is present.""" + + test_function = pyfuncitem.obj + if not inspect.iscoroutinefunction(test_function): + return None + + funcargs = { + name: pyfuncitem.funcargs[name] + for name in pyfuncitem._fixtureinfo.argnames + } + asyncio.run(test_function(**funcargs)) + return True diff --git a/backend/tests/test_memory_store.py b/backend/tests/test_memory_store.py index 083a1e4..905a86e 100644 --- a/backend/tests/test_memory_store.py +++ b/backend/tests/test_memory_store.py @@ -21,10 +21,13 @@ sys.path.insert(0, str(BACKEND_ROOT)) from app.crud.memory_store import MemoryStore +from app.core.config import get_settings from app.models.memory import Edge, Summary from app.models.person import PersonFact from app.models.user import User +settings = get_settings() + def _make_store() -> MemoryStore: """Create an initialized MemoryStore with a pre-seeded owner user.""" @@ -144,6 +147,7 @@ def test_write_and_read_fact(self, store, emily): profile = store.get_profile_context(emily.id) assert len(profile.facts) == 1 assert profile.facts[0].fact_text == "the user is a student born in 2013" + assert profile.facts[0].fact_category == "general" assert profile.facts[0].confidence == 0.95 def test_temporal_fact(self, store, emily): @@ -157,6 +161,31 @@ def test_temporal_fact(self, store, emily): profile = store.get_profile_context(emily.id) assert profile.facts[0].valid_to is not None + def test_fact_category_source_and_embedding_round_trip(self, store, emily): + now = datetime.now(timezone.utc) + episode_id = store.write_episode( + time_start=now - timedelta(minutes=5), + time_end=now, + transcript="Emily talked about joining a robotics club.", + summary="Discussion about clubs.", + participants=[emily.id], + ) + + embedding = [0.01] * settings.retrieval_embedding_dimension + store.write_fact( + person_id=emily.id, + fact_text="Emily joined the robotics club", + fact_category="affilation", + source=episode_id, + embedding=embedding, + ) + + profile = store.get_profile_context(emily.id) + assert profile.facts[0].fact_category == "affiliation" + assert profile.facts[0].source == episode_id + assert profile.facts[0].embedding == embedding + assert profile.facts[0].episode_id == episode_id + def test_bad_confidence_rejected(self, store, emily): with pytest.raises(ValueError, match="confidence"): store.write_fact( @@ -302,7 +331,15 @@ def test_write_fact_bad_episode_rejected(self, store, emily): store.write_fact( person_id=emily.id, fact_text="test", - episode_id=uuid4(), + source=uuid4(), + ) + + def test_write_fact_bad_embedding_rejected(self, store, emily): + with pytest.raises(ValueError, match="embedding must have"): + store.write_fact( + person_id=emily.id, + fact_text="test", + embedding=[0.1] * 16, ) def test_write_summary_bad_episode_rejected(self, store, emily): diff --git a/backend/tests/test_retrieval_service.py b/backend/tests/test_retrieval_service.py new file mode 100644 index 0000000..7dc63ee --- /dev/null +++ b/backend/tests/test_retrieval_service.py @@ -0,0 +1,148 @@ +from datetime import datetime, timedelta, timezone +from pathlib import Path +import sys + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.crud.memory_store import MemoryStore +from app.services.retrieval_service import retrieve_person_context + + +class StaticScorer: + """Deterministic scorer used to test ranking behavior without ML models.""" + + def __init__(self, scores_by_text: dict[str, float]) -> None: + self._scores_by_text = scores_by_text + + def score(self, query: str, texts: list[str]) -> list[float]: + return [self._scores_by_text.get(text, 0.0) for text in texts] + + +@pytest.fixture +def store(): + s = MemoryStore("sqlite+pysqlite:///:memory:") + s.initialize() + yield s + s.close() + + +@pytest.fixture +def people(store: MemoryStore): + emily = store.upsert_person( + name="Emily Chen", + aliases=["Em"], + persona90=[0.25] * 90, + ) + john = store.upsert_person( + name="John Rivera", + aliases=["Johnny"], + ) + return emily, john + + +def test_retrieve_person_context_returns_ranked_facts_summaries_and_edges(store: MemoryStore, people): + emily, john = people + now = datetime.now(timezone.utc) + episode_id = store.write_episode( + time_start=now - timedelta(minutes=20), + time_end=now - timedelta(minutes=10), + transcript="Emily talked about tennis and her work with John.", + summary="Discussion covered tennis and a work project.", + participants=[emily.id, john.id], + ) + + fact_text = "Emily plays tennis every Saturday" + fact_candidate_text = f"hobby {fact_text}" + summary_text = "Emily planned a tennis session this weekend." + edge_text = "colleague John Rivera" + + store.write_fact( + person_id=emily.id, + fact_text=fact_text, + fact_category="hobby", + source=episode_id, + ) + store.write_fact(person_id=emily.id, fact_text="Emily has a dog named Max", fact_category="biographical") + store.write_pref(person_id=emily.id, pref_text="Favorite drink: oat milk latte", episode_id=episode_id) + store.write_summary(person_id=emily.id, summary_text=summary_text, episode_id=episode_id) + store.write_edge(src_id=emily.id, relation="colleague", dst_id=john.id, confidence=0.9, episode_id=episode_id) + + scorer = StaticScorer( + { + fact_candidate_text: 0.95, + summary_text: 0.90, + edge_text: 0.85, + } + ) + result = retrieve_person_context( + emily.id, + "What do we know about Emily's tennis plans and coworkers?", + store=store, + bi_encoder=scorer, + reranker=scorer, + ) + + assert result.person_id == emily.id + assert [fact.fact_text for fact in result.facts] == [fact_text] + assert [summary.summary_text for summary in result.summaries] == [summary_text] + assert [edge.dst_name for edge in result.edges] == ["John Rivera"] + assert "prefs" not in result.model_dump(mode="json") + + +def test_retrieve_person_context_uses_reranker_for_final_cut(store: MemoryStore, people): + emily, john = people + + fact_text = "Emily is training for a marathon" + fact_candidate_text = f"hobby {fact_text}" + summary_text = "Emily discussed an upcoming marathon training block." + edge_text = "teammate John Rivera" + + store.write_fact(person_id=emily.id, fact_text=fact_text, fact_category="hobby") + store.write_summary(person_id=emily.id, summary_text=summary_text) + store.write_edge(src_id=emily.id, relation="teammate", dst_id=john.id) + + bi_encoder = StaticScorer( + { + fact_candidate_text: 0.99, + summary_text: 0.98, + edge_text: 0.97, + } + ) + reranker = StaticScorer( + { + summary_text: 0.91, + edge_text: 0.89, + fact_candidate_text: 0.10, + } + ) + result = retrieve_person_context( + emily.id, + "Who is Emily training with for the marathon?", + store=store, + bi_encoder=bi_encoder, + reranker=reranker, + bi_encoder_top_k=3, + reranker_top_k=2, + ) + + assert result.facts == [] + assert [summary.summary_text for summary in result.summaries] == [summary_text] + assert [edge.relation for edge in result.edges] == ["teammate"] + + +def test_retrieve_person_context_blank_query_returns_available_memories(store: MemoryStore, people): + emily, john = people + + store.write_fact(person_id=emily.id, fact_text="Emily likes sailing", fact_category="hobby") + store.write_summary(person_id=emily.id, summary_text="Emily discussed weekend sailing plans.") + store.write_edge(src_id=emily.id, relation="friend", dst_id=john.id) + + result = retrieve_person_context(emily.id, " ", store=store) + + assert len(result.facts) == 1 + assert len(result.summaries) == 1 + assert len(result.edges) == 1 diff --git a/frontend/eval/cases.json b/frontend/eval/cases.json new file mode 100644 index 0000000..4ff6a24 --- /dev/null +++ b/frontend/eval/cases.json @@ -0,0 +1,27 @@ +[ + { + "query": "Tell me about John", + "expected_person": "John", + "expected_items": ["John likes tennis"] + }, + { + "query": "What do you remember about Sarah?", + "expected_person": "Sarah", + "expected_items": ["Sarah works in design"] + }, + { + "query": "What does Alex enjoy?", + "expected_person": "Alex", + "expected_items": ["Alex likes hiking"] + }, + { + "query": "Tell me about Mia", + "expected_person": "Mia", + "expected_items": ["Mia has a cat"] + }, + { + "query": "What do you know about Daniel?", + "expected_person": "Daniel", + "expected_items": ["Daniel plays guitar"] + } +] \ No newline at end of file diff --git a/frontend/eval/index.ts b/frontend/eval/index.ts new file mode 100644 index 0000000..ec7e28c --- /dev/null +++ b/frontend/eval/index.ts @@ -0,0 +1,93 @@ +import * as fs from "fs"; +import * as path from "path"; +import { MemoryStore, type MemoryRecord } from "./memoryStore"; + +// This evaluation script loads test cases and memory records from JSON files, runs the retrieval function for each test case, and calculates metrics based on the results. It also logs any failed cases for further analysis. +type EvalCase = { + query: string; + expected_person: string; + expected_items: string[]; +}; + +// Metrics report structure for evaluation results. +type MetricsReport = { + accuracy_person: number; + avg_items_returned: number; + pass_rate: number; +}; + +// Simple in-memory store implementation for evaluation. It matches the query against the person field in the memory records and returns the associated items if a match is found. +function loadJsonFile(fileName: string): T { + const filePath = path.join(process.cwd(), "eval", fileName); + const raw = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as T; +} + +// Main evaluation function that processes test cases, retrieves results from the memory store, and calculates metrics. It also collects failed cases for reporting. +function main(): void { + const testCases = loadJsonFile("cases.json"); + const memories = loadJsonFile("memories.json"); + + const store = new MemoryStore(memories); + + let correctPersonCount = 0; + let totalItemsReturned = 0; + let passedCases = 0; + + const failures: Array<{ + query: string; + expected_person: string; + actual_person: string | null; + expected_items: string[]; + actual_items: string[]; + }> = []; + + for (const testCase of testCases) { + const result = store.retrieve(testCase.query); + + const personCorrect = result.resolvedPerson === testCase.expected_person; + if (personCorrect) { + correctPersonCount += 1; + } + + const matchedItems = testCase.expected_items.filter((item) => + result.items.includes(item) + ); + + totalItemsReturned += result.items.length; + + const itemsCorrect = matchedItems.length === testCase.expected_items.length; + const passed = personCorrect && itemsCorrect; + + if (passed) { + passedCases += 1; + } else { + failures.push({ + query: testCase.query, + expected_person: testCase.expected_person, + actual_person: result.resolvedPerson, + expected_items: testCase.expected_items, + actual_items: result.items + }); + } + } + + // Calculate and log the metrics report based on the evaluation results. + const report: MetricsReport = { + accuracy_person: + testCases.length === 0 ? 0 : correctPersonCount / testCases.length, + avg_items_returned: + testCases.length === 0 ? 0 : totalItemsReturned / testCases.length, + pass_rate: testCases.length === 0 ? 0 : passedCases / testCases.length + }; + + console.log(JSON.stringify(report, null, 2)); + + if (failures.length > 0) { + console.log("\nFailed cases:"); + console.log(JSON.stringify(failures, null, 2)); + process.exitCode = 1; + } +} + +main(); \ No newline at end of file diff --git a/frontend/eval/memories.json b/frontend/eval/memories.json new file mode 100644 index 0000000..37ca976 --- /dev/null +++ b/frontend/eval/memories.json @@ -0,0 +1,7 @@ +[ + { "person": "John", "items": ["John likes tennis"] }, + { "person": "Sarah", "items": ["Sarah works in design"] }, + { "person": "Alex", "items": ["Alex likes hiking"] }, + { "person": "Mia", "items": ["Mia has a cat"] }, + { "person": "Daniel", "items": ["Daniel plays guitar"] } +] \ No newline at end of file diff --git a/frontend/eval/memoryStore.ts b/frontend/eval/memoryStore.ts new file mode 100644 index 0000000..34ce5c8 --- /dev/null +++ b/frontend/eval/memoryStore.ts @@ -0,0 +1,45 @@ +// Minimal in-memory store for evaluation purposes. In a real application, this would likely be replaced with a more robust solution. +// Given a query like "What do you know about John?", it tries to match the name in the query to one of the records and returns the associated items. + +// Name associated with memory and the items related to that person. +export type MemoryRecord = { + person: string; + items: string[]; +}; + +//person resolved from the query. If the person does not exist then it will be null. The items associated with the resolved person. If no person is resolved, this will be an empty array. +export type RetrievalResult = { + resolvedPerson: string | null; + items: string[]; +}; +// Metrics report structure for evaluation results. +export type MetricsReport = { + accuracy_person: number; // Proportion of cases where the correct person was identified. + avg_items_returned: number; // Average number of items returned per query. + pass_rate: number; // Proportion of cases that passed both person and items checks. +}; + +// Simple in-memory store implementation for evaluation. It matches the query against the person field in the memory records and returns the associated items if a match is found. +export class MemoryStore { + constructor(private readonly memories: MemoryRecord[]) {} + + retrieve(query: string): RetrievalResult { + const loweredQuery = query.toLowerCase(); + + const match = this.memories.find((memory) => + loweredQuery.includes(memory.person.toLowerCase()) + ); + + if (!match) { + return { + resolvedPerson: null, + items: [] + }; + } + + return { + resolvedPerson: match.person, + items: match.items + }; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d11446d..a78f4c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,10 @@ { + "scripts": { + "eval": "tsx eval/index.ts" + }, "devDependencies": { - "@types/node": "^25.3.3" + "@types/node": "^25.3.3", + "tsx": "^4.20.5", + "typescript": "^5.9.2" } -} +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..95100cf --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "types": ["node"], + "skipLibCheck": true + }, + "include": ["./**/*.ts"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d123b30..ddfbcd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,28 +2,5 @@ "name": "Persistent-Memory", "lockfileVersion": 3, "requires": true, - "packages": { - "": { - "devDependencies": { - "@types/node": "^25.3.3" - } - }, - "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - } - } + "packages": {} }