diff --git a/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py new file mode 100644 index 00000000..5ab9bf79 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py @@ -0,0 +1,41 @@ +"""mental_model_id_to_text + +Revision ID: m8h9i0j1k2l3 +Revises: l7g8h9i0j1k2 +Create Date: 2026-01-19 00:00:00.000000 + +This migration changes the mental_models.id column from VARCHAR(64) to TEXT +to support longer model IDs (e.g., entity names that exceed 64 characters). +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "m8h9i0j1k2l3" +down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Change mental_models.id from VARCHAR(64) to TEXT.""" + schema = _get_schema_prefix() + + # Alter the id column type from VARCHAR(64) to TEXT + op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT") + + +def downgrade() -> None: + """Revert mental_models.id from TEXT to VARCHAR(64).""" + schema = _get_schema_prefix() + + # Note: This may fail if any id values exceed 64 characters + op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)") diff --git a/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py new file mode 100644 index 00000000..1f29d8e8 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py @@ -0,0 +1,134 @@ +"""learnings_and_pinned_reflections + +Revision ID: n9i0j1k2l3m4 +Revises: m8h9i0j1k2l3 +Create Date: 2026-01-21 00:00:00.000000 + +This migration: +1. Creates the 'learnings' table for automatic bottom-up consolidation +2. Creates the 'pinned_reflections' table for user-curated living documents +3. Adds consolidation tracking columns to the 'banks' table +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "n9i0j1k2l3m4" +down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Create learnings and pinned_reflections tables.""" + schema = _get_schema_prefix() + + # 1. Create learnings table + op.execute(f""" + CREATE TABLE {schema}learnings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bank_id VARCHAR(64) NOT NULL, + text TEXT NOT NULL, + proof_count INT NOT NULL DEFAULT 1, + history JSONB DEFAULT '[]'::jsonb, + mission_context VARCHAR(64), + pre_mission_change BOOLEAN DEFAULT FALSE, + embedding vector(384), + tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[], + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + + # Add foreign key constraint + op.execute(f""" + ALTER TABLE {schema}learnings + ADD CONSTRAINT fk_learnings_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + + # Indexes for learnings + op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)") + op.execute(f""" + CREATE INDEX idx_learnings_embedding ON {schema}learnings + USING hnsw (embedding vector_cosine_ops) + """) + op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)") + + # Full-text search for learnings + op.execute(f""" + ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS (to_tsvector('english', text)) STORED + """) + op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)") + + # 2. Create pinned_reflections table + op.execute(f""" + CREATE TABLE {schema}pinned_reflections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bank_id VARCHAR(64) NOT NULL, + name VARCHAR(256) NOT NULL, + source_query TEXT NOT NULL, + content TEXT NOT NULL, + embedding vector(384), + tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[], + last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + + # Add foreign key constraint + op.execute(f""" + ALTER TABLE {schema}pinned_reflections + ADD CONSTRAINT fk_pinned_reflections_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + + # Indexes for pinned_reflections + op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)") + op.execute(f""" + CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections + USING hnsw (embedding vector_cosine_ops) + """) + op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)") + + # Full-text search for pinned_reflections + op.execute(f""" + ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED + """) + op.execute(f""" + CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections + USING gin(search_vector) + """) + + # 3. Add consolidation tracking columns to banks table + op.execute(f""" + ALTER TABLE {schema}banks + ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE + """) + op.execute(f""" + ALTER TABLE {schema}banks + ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE + """) + + +def downgrade() -> None: + """Drop learnings and pinned_reflections tables.""" + schema = _get_schema_prefix() + + # Drop tables + op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE") + op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE") + + # Remove columns from banks + op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at") + op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at") diff --git a/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py new file mode 100644 index 00000000..48545a6c --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py @@ -0,0 +1,113 @@ +"""migrate_mental_models_data + +Revision ID: o0j1k2l3m4n5 +Revises: n9i0j1k2l3m4 +Create Date: 2026-01-21 00:00:00.000000 + +This migration: +1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table +2. Migrates existing 'learned' mental models to the new 'learnings' table +3. Deletes non-directive mental models (structural, emergent, pinned, learned) +4. Drops the mental_model_versions table (no longer used) +5. Adds a CHECK constraint that only 'directive' subtype is allowed +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "o0j1k2l3m4n5" +down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Migrate data and clean up old mental models.""" + schema = _get_schema_prefix() + + # 1. Migrate 'pinned' mental models to pinned_reflections + # For pinned models, the first observation's content becomes the pinned reflection content + op.execute(f""" + INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at) + SELECT + bank_id, + name, + description AS source_query, + COALESCE( + observations->'observations'->0->>'content', + description, + '' + ) AS content, + tags, + created_at + FROM {schema}mental_models + WHERE subtype = 'pinned' + ON CONFLICT DO NOTHING + """) + + # 2. Migrate 'learned' mental models to learnings + # Each observation in a learned model becomes a separate learning + op.execute(f""" + INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at) + SELECT + mm.bank_id, + obs->>'content' AS text, + GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count, + mm.tags, + mm.created_at + FROM {schema}mental_models mm, + LATERAL jsonb_array_elements(mm.observations->'observations') AS obs + WHERE mm.subtype = 'learned' + AND obs->>'content' IS NOT NULL + AND obs->>'content' != '' + ON CONFLICT DO NOTHING + """) + + # 3. Delete all non-directive mental models (they've been migrated or are obsolete) + op.execute(f""" + DELETE FROM {schema}mental_models + WHERE subtype != 'directive' + """) + + # 4. Drop the mental_model_versions table (no longer used) + op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE") + + # 5. Drop old constraints and add new one that only allows 'directive' + op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype") + op.execute(f""" + ALTER TABLE {schema}mental_models + ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive') + """) + + +def downgrade() -> None: + """Reverse the migration (data migration is one-way, so this just removes constraints).""" + schema = _get_schema_prefix() + + # Remove the directive-only constraint + op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype") + + # Re-create mental_model_versions table + op.execute(f""" + CREATE TABLE IF NOT EXISTS {schema}mental_model_versions ( + id SERIAL PRIMARY KEY, + bank_id VARCHAR(64) NOT NULL, + model_id VARCHAR(128) NOT NULL, + version INT NOT NULL, + observations JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + op.execute( + f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)" + ) + + # Note: Data migration cannot be reversed - pinned_reflections and learnings data remains diff --git a/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py new file mode 100644 index 00000000..8d9002e4 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py @@ -0,0 +1,194 @@ +"""new_knowledge_architecture + +Revision ID: p1k2l3m4n5o6 +Revises: o0j1k2l3m4n5 +Create Date: 2026-01-21 00:00:00.000000 + +This migration implements the new knowledge architecture: +1. Drops the 'learnings' table (mental models are now in memory_units) +2. Renames 'pinned_reflections' to 'reflections' +3. Drops the 'mental_models' table completely +4. Creates 'directives' table for hard rules +5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history) + +The new architecture: +- Directives: Hard rules in their own table +- Mental Models: Stored in memory_units with fact_type='mental_model' +- Reflections: User-curated documents (renamed from pinned_reflections) +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "p1k2l3m4n5o6" +down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Implement new knowledge architecture.""" + schema = _get_schema_prefix() + + # 1. Drop the learnings table (mental models will be in memory_units) + op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE") + + # 2. Rename pinned_reflections to reflections + op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections") + + # Rename indexes for reflections + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id") + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding") + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags") + op.execute( + f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search" + ) + + # Rename foreign key constraint + op.execute(f""" + ALTER TABLE {schema}reflections + DROP CONSTRAINT IF EXISTS fk_pinned_reflections_bank_id + """) + op.execute(f""" + ALTER TABLE {schema}reflections + ADD CONSTRAINT fk_reflections_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + + # 3. Drop the mental_models table completely + op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE") + + # 4. Create directives table + op.execute(f""" + CREATE TABLE {schema}directives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bank_id VARCHAR(64) NOT NULL, + name VARCHAR(256) NOT NULL, + content TEXT NOT NULL, + priority INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[], + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + + # Add foreign key and indexes for directives + op.execute(f""" + ALTER TABLE {schema}directives + ADD CONSTRAINT fk_directives_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)") + op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)") + op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)") + + # 5. Add mental model support columns to memory_units + # proof_count: Number of memories that support this mental model + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1 + """) + + # source_memory_ids: Array of memory IDs that consolidated into this mental model + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[] + """) + + # history: JSONB array tracking changes to mental models + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb + """) + + # Add index for finding mental models + op.execute(f""" + CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models + ON {schema}memory_units(bank_id, fact_type) + WHERE fact_type = 'mental_model' + """) + + # 6. Update fact_type check constraint to include 'mental_model' + op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check") + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD CONSTRAINT memory_units_fact_type_check + CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model')) + """) + + +def downgrade() -> None: + """Reverse the migration.""" + schema = _get_schema_prefix() + + # Restore original fact_type check constraint (without 'mental_model') + op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check") + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD CONSTRAINT memory_units_fact_type_check + CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation')) + """) + + # Drop mental model columns from memory_units + op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count") + op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids") + op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history") + op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models") + + # Drop directives table + op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE") + + # Rename reflections back to pinned_reflections + op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections") + + # Restore indexes + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id") + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding") + op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags") + op.execute( + f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search" + ) + + # Restore foreign key + op.execute(f""" + ALTER TABLE {schema}pinned_reflections + DROP CONSTRAINT IF EXISTS fk_reflections_bank_id + """) + op.execute(f""" + ALTER TABLE {schema}pinned_reflections + ADD CONSTRAINT fk_pinned_reflections_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + + # Re-create learnings table + op.execute(f""" + CREATE TABLE IF NOT EXISTS {schema}learnings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bank_id VARCHAR(64) NOT NULL, + text TEXT NOT NULL, + proof_count INT NOT NULL DEFAULT 1, + history JSONB DEFAULT '[]'::jsonb, + mission_context VARCHAR(64), + pre_mission_change BOOLEAN DEFAULT FALSE, + embedding vector(384), + tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[], + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + op.execute(f""" + ALTER TABLE {schema}learnings + ADD CONSTRAINT fk_learnings_bank_id + FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE + """) + + # Note: mental_models table recreation is complex and would need separate handling diff --git a/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py new file mode 100644 index 00000000..9d3aa36e --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py @@ -0,0 +1,50 @@ +"""fix_mental_model_fact_type + +Revision ID: q2l3m4n5o6p7 +Revises: p1k2l3m4n5o6 +Create Date: 2026-01-21 13:30:00.000000 + +Fix the fact_type check constraint to include 'mental_model'. +This is a fix for p1k2l3m4n5o6 which should have included this change. +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "q2l3m4n5o6p7" +down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Add 'mental_model' to the fact_type check constraint.""" + schema = _get_schema_prefix() + + # Drop the old constraint and add the new one with mental_model included + op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check") + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD CONSTRAINT memory_units_fact_type_check + CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model')) + """) + + +def downgrade() -> None: + """Remove 'mental_model' from the fact_type check constraint.""" + schema = _get_schema_prefix() + + op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check") + op.execute(f""" + ALTER TABLE {schema}memory_units + ADD CONSTRAINT memory_units_fact_type_check + CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation')) + """) diff --git a/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py new file mode 100644 index 00000000..4c5a2cf6 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py @@ -0,0 +1,47 @@ +"""Add reflect_response JSONB column to reflections + +Revision ID: r3m4n5o6p7q8 +Revises: q2l3m4n5o6p7 +Create Date: 2026-01-21 + +This migration adds a reflect_response JSONB column to store the full +reflect API response payload, including based_on facts and trace data. + +Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6. +""" + +from collections.abc import Sequence + +from alembic import context, op + +revision: str = "r3m4n5o6p7q8" +down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Add reflect_response JSONB column to reflections.""" + schema = _get_schema_prefix() + + # Add reflect_response column to store the full reflect API response + op.execute(f""" + ALTER TABLE {schema}reflections + ADD COLUMN IF NOT EXISTS reflect_response JSONB + """) + + +def downgrade() -> None: + """Remove reflect_response column from reflections.""" + schema = _get_schema_prefix() + + op.execute(f""" + ALTER TABLE {schema}reflections + DROP COLUMN IF EXISTS reflect_response + """) diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index e7c3ab6c..42dde613 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -92,7 +92,7 @@ class RecallRequest(BaseModel): query: str types: list[str] | None = Field( default=None, - description="List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. " + description="List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. " "Note: 'opinion' is accepted but ignored (opinions are excluded from recall).", ) budget: Budget = Budget.MID @@ -570,9 +570,6 @@ class ReflectBasedOn(BaseModel): """Evidence the response is based on: memories and mental models.""" memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response") - mental_models: list[ReflectMentalModel] = Field( - default_factory=list, description="Mental models accessed during reflection" - ) class ReflectTrace(BaseModel): @@ -586,14 +583,6 @@ class ReflectTrace(BaseModel): ) -class CreatedMentalModel(BaseModel): - """A mental model created during reflection.""" - - id: str = Field(description="Mental model ID") - name: str = Field(description="Human-readable name") - description: str = Field(description="What this model tracks") - - class ReflectResponse(BaseModel): """Response model for think endpoint.""" @@ -625,9 +614,6 @@ class ReflectResponse(BaseModel): "tool_calls": [{"tool": "recall", "input": {"query": "AI"}, "duration_ms": 150}], "llm_calls": [{"scope": "agent_1", "duration_ms": 1200}], }, - "mental_models_created": [ - {"id": "mm-new-1", "name": "AI Strategy", "description": "Track AI-related decisions and plans"} - ], } } ) @@ -649,10 +635,6 @@ class ReflectResponse(BaseModel): default=None, description="Execution trace of tool and LLM calls. Only present when include.tool_calls is set.", ) - mental_models_created: list[CreatedMentalModel] = Field( - default_factory=list, - description="Mental models created during this reflection (via the learn tool).", - ) class BanksResponse(BaseModel): @@ -1032,6 +1014,9 @@ class BankStatsResponse(BaseModel): "links_breakdown": {"fact": {"temporal": 100, "semantic": 60, "entity": 40}}, "pending_operations": 2, "failed_operations": 0, + "last_consolidated_at": "2024-01-15T10:30:00Z", + "pending_consolidation": 0, + "total_mental_models": 45, } } ) @@ -1046,6 +1031,10 @@ class BankStatsResponse(BaseModel): links_breakdown: dict[str, dict[str, int]] pending_operations: int failed_operations: int + # Consolidation stats + last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)") + pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into mental models") + total_mental_models: int = Field(default=0, description="Total number of mental models") # Mental Model models @@ -1060,205 +1049,117 @@ class ObservationEvidenceResponse(BaseModel): timestamp: str = Field(description="When the source memory was created (ISO format)") -class MentalModelObservationResponse(BaseModel): - """An observation within a mental model with its supporting evidence.""" - - title: str = Field(description="Short summary title for the observation") - content: str = Field(description="The observation content - detailed explanation") - evidence: list[ObservationEvidenceResponse] = Field( - default_factory=list, description="Supporting evidence with quotes" - ) - created_at: str = Field(description="When this observation was first created (ISO format)") - trend: str = Field(description="Computed trend: stable, strengthening, weakening, new, stale") - evidence_count: int = Field(description="Number of evidence items supporting this observation") - evidence_span: dict = Field(description="Time span of evidence: {from: iso_date, to: iso_date}") +# ========================================================================= +# Directive Models +# ========================================================================= -class MentalModelFreshnessResponse(BaseModel): - """Freshness information for a mental model.""" - - is_up_to_date: bool = Field(description="Whether the model has been refreshed since the last memory was added") - last_refresh_at: str | None = Field(description="When the model was last refreshed (ISO format)") - memories_since_refresh: int = Field(description="Number of memories added since last refresh") - reasons: list[str] = Field( - default_factory=list, - description="Reasons why the model needs refresh (empty if up to date). " - "Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed", - ) - - -class MentalModelResponse(BaseModel): - """Response model for a mental model.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "team-structure", - "bank_id": "test-bank", - "subtype": "structural", - "name": "Team Structure", - "description": "Who's on the team and their roles", - "observations": [ - { - "title": "Prefers async communication", - "content": "The team prefers async communication over synchronous meetings", - "evidence": [ - { - "memory_id": "uuid1", - "quote": "I prefer Slack over meetings", - "relevance": "Shows async preference", - "timestamp": "2024-01-10T08:00:00Z", - } - ], - "created_at": "2024-01-15T10:30:00Z", - "trend": "stable", - "evidence_count": 1, - "evidence_span": {"from": "2024-01-10T08:00:00Z", "to": "2024-01-10T08:00:00Z"}, - } - ], - "version": 1, - "entity_id": None, - "links": [], - "tags": ["project-x"], - "last_updated": "2024-01-15T10:30:00Z", - "last_refresh_at": "2024-01-15T10:30:00Z", - "freshness": { - "is_up_to_date": True, - "last_refresh_at": "2024-01-15T10:30:00Z", - "memories_since_refresh": 0, - "reasons": [], - }, - "created_at": "2024-01-10T08:00:00Z", - } - } - ) +class DirectiveResponse(BaseModel): + """Response model for a directive.""" id: str bank_id: str - subtype: str name: str - description: str - observations: list[MentalModelObservationResponse] = Field( - default_factory=list, description="Structured observations with per-observation fact attribution" - ) - version: int = Field(default=0, description="Version number of the mental model observations") - entity_id: str | None = None - links: list[str] = [] - tags: list[str] = [] - last_updated: str | None = None - last_refresh_at: str | None = Field(default=None, description="When observations were last refreshed (ISO format)") - freshness: MentalModelFreshnessResponse | None = Field( - default=None, description="Freshness info (null for directive subtypes which don't need refresh)" - ) - created_at: str + content: str + priority: int = 0 + is_active: bool = True + tags: list[str] = Field(default_factory=list) + created_at: str | None = None + updated_at: str | None = None -class MentalModelListResponse(BaseModel): - """Response model for listing mental models.""" +class DirectiveListResponse(BaseModel): + """Response model for listing directives.""" - items: list[MentalModelResponse] + items: list[DirectiveResponse] -def _observation_to_response(obs: Observation) -> MentalModelObservationResponse: - """Convert internal Observation model to API response model.""" - return MentalModelObservationResponse( - title=obs.title, - content=obs.content, - evidence=[ - ObservationEvidenceResponse( - memory_id=ev.memory_id, - quote=ev.quote, - relevance=ev.relevance, - timestamp=ev.timestamp.isoformat(), - ) - for ev in obs.evidence - ], - created_at=obs.created_at.isoformat(), - trend=obs.trend.value, - evidence_count=obs.evidence_count, - evidence_span=obs.evidence_span, - ) +class CreateDirectiveRequest(BaseModel): + """Request model for creating a directive.""" + + name: str = Field(description="Human-readable name for the directive") + content: str = Field(description="The directive text to inject into prompts") + priority: int = Field(default=0, description="Higher priority directives are injected first") + is_active: bool = Field(default=True, description="Whether this directive is active") + tags: list[str] = Field(default_factory=list, description="Tags for filtering") -def _prepare_mental_model_response(model: dict[str, Any]) -> MentalModelResponse: - """Convert internal mental model dict to API response model. +class UpdateDirectiveRequest(BaseModel): + """Request model for updating a directive.""" - Handles conversion of Observation models to MentalModelObservationResponse. - """ - observations = model.get("observations", []) - converted_observations = [ - _observation_to_response(obs) if isinstance(obs, Observation) else obs for obs in observations - ] - return MentalModelResponse(**{**model, "observations": converted_observations}) + name: str | None = Field(default=None, description="New name") + content: str | None = Field(default=None, description="New content") + priority: int | None = Field(default=None, description="New priority") + is_active: bool | None = Field(default=None, description="New active status") + tags: list[str] | None = Field(default=None, description="New tags") -class RefreshMentalModelsRequest(BaseModel): - """Request model for refresh mental models endpoint.""" +# ========================================================================= +# Reflections Models +# ========================================================================= - model_config = ConfigDict(json_schema_extra={"example": {"tags": ["project-x"], "subtype": "structural"}}) - tags: list[str] | None = Field(default=None, description="Tags to apply to newly created mental models") - subtype: Literal["structural", "emergent", "pinned", "learned"] | None = Field( +class ReflectionResponse(BaseModel): + """Response model for a reflection.""" + + id: str + bank_id: str + name: str + source_query: str + content: str + tags: list[str] = Field(default_factory=list) + last_refreshed_at: str | None = None + created_at: str | None = None + reflect_response: dict | None = Field( default=None, - description="Only refresh models of this subtype. If not specified, refreshes all subtypes.", + description="Full reflect API response payload including based_on facts and mental_models", ) -class ObservationInput(BaseModel): - """Input model for a single observation.""" +class ReflectionListResponse(BaseModel): + """Response model for listing reflections.""" - title: str = Field(description="Short title/header for the observation") - content: str = Field(description="Content of the observation") + items: list[ReflectionResponse] -class CreateMentalModelRequest(BaseModel): - """Request model for creating a mental model.""" - - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "name": "Product Roadmap", - "description": "Key product priorities and upcoming features", - "tags": ["project-x"], - }, - { - "name": "Meeting Rules", - "description": "Rules about scheduling meetings", - "subtype": "directive", - "observations": [{"title": "Morning meetings", "content": "Never schedule meetings before 10am"}], - }, - ] - } - ) - - name: str = Field(description="Human-readable name for the mental model") - description: str = Field(description="One-liner description for quick scanning") - subtype: str = Field( - default="pinned", - description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)", - ) - observations: list[ObservationInput] | None = Field( - default=None, - description="For directives only: list of user-provided observations. Required when subtype='directive'.", - ) - tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility") - - -class UpdateMentalModelRequest(BaseModel): - """Request model for updating a mental model.""" +class CreateReflectionRequest(BaseModel): + """Request model for creating a reflection.""" model_config = ConfigDict( json_schema_extra={ "example": { - "name": "Updated Name", - "description": "Updated description with new rules", + "name": "Team Communication Preferences", + "source_query": "How does the team prefer to communicate?", + "tags": ["team"], + "max_tokens": 2048, } } ) - name: str | None = Field(default=None, description="New name for the mental model") - description: str | None = Field(default=None, description="New description/rule text") + name: str = Field(description="Human-readable name for the reflection") + source_query: str = Field(description="The query to run to generate content") + tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility") + max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content") + + +class CreateReflectionResponse(BaseModel): + """Response model for reflection creation.""" + + operation_id: str = Field(description="Operation ID to track progress") + + +class UpdateReflectionRequest(BaseModel): + """Request model for updating a reflection.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Team Communication Preferences", + } + } + ) + + name: str | None = Field(default=None, description="New name for the reflection") class OperationResponse(BaseModel): @@ -1287,6 +1188,16 @@ class OperationResponse(BaseModel): error_message: str | None +class ConsolidationResponse(BaseModel): + """Response model for consolidation trigger endpoint.""" + + status: str = Field(description="Status of the consolidation (completed or queued)") + processed: int = Field(description="Number of memories processed") + created: int = Field(description="Number of mental models created") + updated: int = Field(description="Number of mental models updated") + message: str = Field(description="Human-readable summary") + + class OperationsListResponse(BaseModel): """Response model for list operations endpoint.""" @@ -1295,6 +1206,8 @@ class OperationsListResponse(BaseModel): "example": { "bank_id": "user123", "total": 150, + "limit": 20, + "offset": 0, "operations": [ { "id": "550e8400-e29b-41d4-a716-446655440000", @@ -1310,6 +1223,8 @@ class OperationsListResponse(BaseModel): bank_id: str total: int + limit: int + offset: int operations: list[OperationResponse] @@ -1373,6 +1288,34 @@ class AsyncOperationSubmitResponse(BaseModel): status: str +class FeaturesInfo(BaseModel): + """Feature flags indicating which capabilities are enabled.""" + + mental_models: bool = Field(description="Whether mental models (auto-consolidation) are enabled") + mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled") + worker: bool = Field(description="Whether the background worker is enabled") + + +class VersionResponse(BaseModel): + """Response model for the version/info endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "api_version": "1.0.0", + "features": { + "mental_models": False, + "mcp": True, + "worker": True, + }, + } + } + ) + + api_version: str = Field(description="API version string") + features: FeaturesInfo = Field(description="Enabled feature flags") + + def create_app( memory: MemoryEngine, initialize_memory: bool = True, @@ -1586,6 +1529,34 @@ def _register_routes(app: FastAPI): status_code = 200 if health.get("status") == "healthy" else 503 return JSONResponse(content=health, status_code=status_code) + @app.get( + "/version", + response_model=VersionResponse, + summary="Get API version and feature flags", + description="Returns API version information and enabled feature flags. " + "Use this to check which capabilities are available in this deployment.", + tags=["Monitoring"], + operation_id="get_version", + ) + async def version_endpoint() -> VersionResponse: + """ + Get API version and enabled features. + + Returns version info and feature flags that can be used by clients + to determine which capabilities are available. + """ + from hindsight_api.config import get_config + + config = get_config() + return VersionResponse( + api_version="1.0.0", + features=FeaturesInfo( + mental_models=config.enable_mental_models, + mcp=config.mcp_enabled, + worker=config.worker_enabled, + ), + ) + @app.get( "/metrics", summary="Prometheus metrics endpoint", @@ -1821,7 +1792,10 @@ def _register_routes(app: FastAPI): ) response = RecallResponse( - results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response + results=recall_results, + trace=core_result.trace, + entities=entities_response, + chunks=chunks_response, ) handler_duration = time.time() - handler_start @@ -1907,16 +1881,7 @@ def _register_routes(app: FastAPI): occurred_end=fact.occurred_end, ) ) - mental_models = [ - ReflectMentalModel( - id=mm.id, - name=mm.name, - type=mm.type, - subtype=mm.subtype, - ) - for mm in core_result.mental_models - ] - based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models) + based_on_result = ReflectBasedOn(memories=memories) # Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested trace_result: ReflectTrace | None = None @@ -1935,42 +1900,39 @@ def _register_routes(app: FastAPI): llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace] # Build map of directive observations by id directive_observations = {d.id: d.rules for d in core_result.directives_applied} - # Include all mental models (including directives with subtype='directive') - trace_mental_models = [ - ReflectMentalModel( - id=mm.id, - name=mm.name, - type=mm.type, - subtype=mm.subtype, - observations=directive_observations.get(mm.id) if mm.subtype == "directive" else None, - ) - for mm in core_result.mental_models - ] + # Build mental models from tool trace (get_mental_model outputs) + trace_mental_models: list[ReflectMentalModel] = [] + seen_model_ids: set[str] = set() + for tc in core_result.tool_trace: + if tc.tool == "get_mental_model" and tc.output.get("found") and "model" in tc.output: + model = tc.output["model"] + model_id = model.get("id") + if model_id and model_id not in seen_model_ids: + seen_model_ids.add(model_id) + model_subtype = model.get("subtype", "structural") + trace_mental_models.append( + ReflectMentalModel( + id=model_id, + name=model.get("name", ""), + type=model.get("type", "concept"), + subtype=model_subtype, + observations=directive_observations.get(model_id) + if model_subtype == "directive" + else None, + ) + ) trace_result = ReflectTrace( tool_calls=tool_calls, llm_calls=llm_calls, mental_models=trace_mental_models, ) - # Build mental_models_created from tool trace (learn tool outputs) - created_models: list[CreatedMentalModel] = [] - for tc in core_result.tool_trace: - if tc.tool == "learn" and isinstance(tc.output, dict) and tc.output.get("status") == "created": - created_models.append( - CreatedMentalModel( - id=tc.output.get("model_id", ""), - name=tc.input.get("name", ""), - description=tc.input.get("description", ""), - ) - ) - return ReflectResponse( text=core_result.text, based_on=based_on_result, structured_output=core_result.structured_output, usage=core_result.usage, trace=trace_result, - mental_models_created=created_models, ) except OperationValidationError as e: @@ -2096,6 +2058,54 @@ def _register_routes(app: FastAPI): ) total_documents = doc_count_result["count"] if doc_count_result else 0 + # Get consolidation stats + bank_row = await conn.fetchrow( + f""" + SELECT last_consolidated_at + FROM {fq_table("banks")} + WHERE bank_id = $1 + """, + bank_id, + ) + last_consolidated_at = bank_row["last_consolidated_at"] if bank_row else None + + # Count memories pending consolidation (created after last_consolidated_at) + if last_consolidated_at: + pending_consolidation_result = await conn.fetchrow( + f""" + SELECT COUNT(*) as count + FROM {fq_table("memory_units")} + WHERE bank_id = $1 + AND created_at > $2 + AND fact_type IN ('experience', 'world') + """, + bank_id, + last_consolidated_at, + ) + else: + # If never consolidated, count all experience/world memories + pending_consolidation_result = await conn.fetchrow( + f""" + SELECT COUNT(*) as count + FROM {fq_table("memory_units")} + WHERE bank_id = $1 + AND fact_type IN ('experience', 'world') + """, + bank_id, + ) + pending_consolidation = pending_consolidation_result["count"] if pending_consolidation_result else 0 + + # Count total mental models + mental_model_count_result = await conn.fetchrow( + f""" + SELECT COUNT(*) as count + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + total_mental_models = mental_model_count_result["count"] if mental_model_count_result else 0 + # Format results nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats} links_by_type = {row["link_type"]: row["count"] for row in link_stats} @@ -2125,6 +2135,9 @@ def _register_routes(app: FastAPI): links_breakdown=links_breakdown, pending_operations=pending_operations, failed_operations=failed_operations, + last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None), + pending_consolidation=pending_consolidation, + total_mental_models=total_mental_models, ) except (AuthenticationError, HTTPException): @@ -2223,128 +2236,111 @@ def _register_routes(app: FastAPI): entity_id: str, request_context: RequestContext = Depends(get_request_context), ): - """Regenerate observations for an entity. DEPRECATED: Use mental models instead.""" + """Regenerate observations for an entity. DEPRECATED.""" raise HTTPException( status_code=410, - detail="This endpoint is deprecated. Entity observations have been replaced by mental models. " - "Use the /mental-models endpoints instead.", + detail="This endpoint is deprecated. Entity observations are no longer supported.", ) # ========================================================================= - # Mental Models endpoints + # ========================================================================= + # REFLECTIONS ENDPOINTS # ========================================================================= @app.get( - "/v1/default/banks/{bank_id}/mental-models", - response_model=MentalModelListResponse, - summary="List mental models", - description="List all mental models for a bank, optionally filtered by subtype or tags.", - operation_id="list_mental_models", - tags=["Mental Models"], + "/v1/default/banks/{bank_id}/reflections", + response_model=ReflectionListResponse, + summary="List reflections", + description="List user-curated living documents that stay current.", + operation_id="list_reflections", + tags=["Reflections"], ) - async def api_list_mental_models( + async def api_list_reflections( bank_id: str, - subtype: str | None = Query(None, description="Filter by subtype: structural, emergent, or pinned"), - tags_filter: list[str] | None = Query( - None, alias="tags", description="Filter by tags (includes untagged models)" - ), - tags_match: Literal["any", "all", "exact"] = Query( - "any", description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'" - ), + tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"), + tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), request_context: RequestContext = Depends(get_request_context), ): - """List mental models for a bank.""" + """List reflections for a bank.""" try: - models = await app.state.memory.list_mental_models( + reflections = await app.state.memory.list_reflections( bank_id=bank_id, - subtype=subtype, tags=tags_filter, tags_match=tags_match, + limit=limit, + offset=offset, request_context=request_context, ) - - # Add freshness to each model (skip for directives) - # Get data needed for freshness computation (once for all models) - from hindsight_api.engine.reflect.mental_model_reflect import ( - BankProfile, - DirectiveMentalModel, - check_needs_refresh, - ) - - total_memories = await app.state.memory._count_memories_since(bank_id, None) - bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - - # Convert to typed models at the boundary - bank_profile = BankProfile.model_validate(bank_profile_dict) - directives = [DirectiveMentalModel.model_validate(m) for m in models if m.get("subtype") == "directive"] - - for model in models: - if model.get("subtype") != "directive": - last_refresh_at = model.get("last_refresh_at") - memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at) - - # Use check_needs_refresh to get reasons - stored_refresh_state = model.get("refresh_state") - refresh_check = check_needs_refresh( - stored_state=stored_refresh_state, - current_memories_count=total_memories, - bank_profile=bank_profile, - directives=directives, - ) - - model["freshness"] = { - "is_up_to_date": not refresh_check.needs_refresh, - "last_refresh_at": last_refresh_at, - "memories_since_refresh": memories_since, - "reasons": refresh_check.reasons, - } - else: - model["freshness"] = None - - return MentalModelListResponse(items=[_prepare_mental_model_response(m) for m in models]) + return ReflectionListResponse(items=[ReflectionResponse(**r) for r in reflections]) except (AuthenticationError, HTTPException): raise except Exception as e: import traceback error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}") + logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get( + "/v1/default/banks/{bank_id}/reflections/{reflection_id}", + response_model=ReflectionResponse, + summary="Get reflection", + description="Get a specific reflection by ID.", + operation_id="get_reflection", + tags=["Reflections"], + ) + async def api_get_reflection( + bank_id: str, + reflection_id: str, + request_context: RequestContext = Depends(get_request_context), + ): + """Get a reflection by ID.""" + try: + reflection = await app.state.memory.get_reflection( + bank_id=bank_id, + reflection_id=reflection_id, + request_context=request_context, + ) + if reflection is None: + raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found") + return ReflectionResponse(**reflection) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) @app.post( - "/v1/default/banks/{bank_id}/mental-models", - response_model=MentalModelResponse, - summary="Create mental model", - description=( - "Create a mental model. Supports two subtypes:\n" - "- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n" - "- 'directive': User-defined hard rules, observations are provided at creation and never regenerated" - ), - operation_id="create_mental_model", - tags=["Mental Models"], + "/v1/default/banks/{bank_id}/reflections", + response_model=CreateReflectionResponse, + summary="Create reflection", + description="Create a reflection by running reflect with the source query in the background. " + "Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. " + "Use the operations endpoint to check completion status.", + operation_id="create_reflection", + tags=["Reflections"], ) - async def api_create_mental_model( + async def api_create_reflection( bank_id: str, - body: CreateMentalModelRequest, + body: CreateReflectionRequest, request_context: RequestContext = Depends(get_request_context), ): - """Create a mental model (pinned or directive).""" + """Create a reflection (async - returns operation_id).""" try: - # Convert observations to list of dicts if provided - observations_list = None - if body.observations: - observations_list = [{"title": obs.title, "content": obs.content} for obs in body.observations] - - model = await app.state.memory.create_mental_model( + result = await app.state.memory.submit_async_create_reflection( bank_id=bank_id, name=body.name, - description=body.description, - subtype=body.subtype, - observations=observations_list, - tags=body.tags, + source_query=body.source_query, + tags=body.tags if body.tags else None, + max_tokens=body.max_tokens, request_context=request_context, ) - return _prepare_mental_model_response(model) + return CreateReflectionResponse(operation_id=result["operation_id"]) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except (AuthenticationError, HTTPException): @@ -2353,286 +2349,32 @@ def _register_routes(app: FastAPI): import traceback error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/mental-models/{model_id}", - response_model=MentalModelResponse, - summary="Get mental model", - description="Get a specific mental model by ID.", - operation_id="get_mental_model", - tags=["Mental Models"], - ) - async def api_get_mental_model( - bank_id: str, - model_id: str, - request_context: RequestContext = Depends(get_request_context), - ): - """Get a mental model by ID.""" - try: - model = await app.state.memory.get_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - if model is None: - raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found") - - # Compute freshness for non-directive models - if model.get("subtype") != "directive": - from hindsight_api.engine.reflect.mental_model_reflect import ( - BankProfile, - DirectiveMentalModel, - check_needs_refresh, - ) - - last_refresh_at = model.get("last_refresh_at") - total_memories = await app.state.memory._count_memories_since(bank_id, None) - memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at) - bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - directives_dicts = await app.state.memory.list_mental_models( - bank_id, subtype="directive", request_context=request_context - ) - - # Convert to typed models at the boundary - bank_profile = BankProfile.model_validate(bank_profile_dict) - directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts] - - # Use check_needs_refresh to get reasons - stored_refresh_state = model.get("refresh_state") - refresh_check = check_needs_refresh( - stored_state=stored_refresh_state, - current_memories_count=total_memories, - bank_profile=bank_profile, - directives=directives, - ) - - model["freshness"] = { - "is_up_to_date": not refresh_check.needs_refresh, - "last_refresh_at": last_refresh_at, - "memories_since_refresh": memories_since, - "reasons": refresh_check.reasons, - } - else: - # Directives don't need freshness - they're static - model["freshness"] = None - - return _prepare_mental_model_response(model) - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}") + logger.error(f"Error in POST /v1/default/banks/{bank_id}/reflections: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) @app.post( - "/v1/default/banks/{bank_id}/mental-models/refresh", - response_model=AsyncOperationSubmitResponse, - summary="Refresh mental models (async)", - description="Submit a background job to refresh mental models for a bank. " - "By default refreshes all subtypes. Optionally specify 'subtype' to only refresh " - "'structural' (from mission) or 'emergent' (from entities) models. " - "Optionally pass tags to apply to newly created models. " - "Use GET /banks/{bank_id}/operations to check progress.", - operation_id="refresh_mental_models", - tags=["Mental Models"], + "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh", + response_model=ReflectionResponse, + summary="Refresh reflection", + description="Re-run the source query through reflect and update the content.", + operation_id="refresh_reflection", + tags=["Reflections"], ) - async def api_refresh_mental_models( + async def api_refresh_reflection( bank_id: str, - body: RefreshMentalModelsRequest | None = None, + reflection_id: str, request_context: RequestContext = Depends(get_request_context), ): - """Submit a background job to refresh mental models for a bank. - - Requires a mission to be set for the bank first. - Optionally pass tags to apply to newly created mental models. - Optionally specify a subtype to only refresh models of that type. - """ + """Refresh a reflection by re-running its source query.""" try: - result = await app.state.memory.refresh_mental_models( + reflection = await app.state.memory.refresh_reflection( bank_id=bank_id, - tags=body.tags if body else None, - subtype=body.subtype if body else None, + reflection_id=reflection_id, request_context=request_context, ) - return AsyncOperationSubmitResponse(**result) - except ValueError as e: - # Mission not set or other validation error - raise HTTPException(status_code=400, detail=str(e)) - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/refresh: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.delete( - "/v1/default/banks/{bank_id}/mental-models/{model_id}", - response_model=DeleteResponse, - summary="Delete mental model", - description="Delete a mental model.", - operation_id="delete_mental_model", - tags=["Mental Models"], - ) - async def api_delete_mental_model( - bank_id: str, - model_id: str, - request_context: RequestContext = Depends(get_request_context), - ): - """Delete a mental model.""" - try: - deleted = await app.state.memory.delete_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - if not deleted: - raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found") - return DeleteResponse(success=True, deleted_count=1) - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.patch( - "/v1/default/banks/{bank_id}/mental-models/{model_id}", - response_model=MentalModelResponse, - summary="Update mental model", - description="Update a mental model's name and/or description. Useful for editing directives.", - operation_id="update_mental_model", - tags=["Mental Models"], - ) - async def api_update_mental_model( - bank_id: str, - model_id: str, - body: UpdateMentalModelRequest, - request_context: RequestContext = Depends(get_request_context), - ): - """Update a mental model's name and/or description.""" - try: - if body.name is None and body.description is None: - raise HTTPException(status_code=400, detail="At least one of 'name' or 'description' must be provided") - - updated = await app.state.memory.update_mental_model( - bank_id=bank_id, - model_id=model_id, - name=body.name, - description=body.description, - request_context=request_context, - ) - if not updated: - raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found") - return _prepare_mental_model_response(updated) - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh", - response_model=AsyncOperationSubmitResponse, - summary="Refresh mental model content (async)", - description="Submit a background job to refresh content for a specific mental model. " - "This is useful for newly created learned models or to refresh content for any model.", - operation_id="refresh_mental_model", - tags=["Mental Models"], - ) - async def api_refresh_mental_model( - bank_id: str, - model_id: str, - request_context: RequestContext = Depends(get_request_context), - ): - """Refresh content for a specific mental model.""" - try: - result = await app.state.memory.refresh_mental_model_async( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - return AsyncOperationSubmitResponse( - operation_id=result["operation_id"], - status=result.get("status", "queued"), - ) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/refresh: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions", - summary="List mental model version history", - description="List all saved versions of a mental model's observations, ordered by version descending.", - operation_id="list_mental_model_versions", - tags=["Mental Models"], - ) - async def api_list_mental_model_versions( - bank_id: str, - model_id: str, - request_context: RequestContext = Depends(get_request_context), - ): - """List version history for a mental model.""" - try: - versions = await app.state.memory.get_mental_model_versions( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - return {"versions": versions} - except (AuthenticationError, HTTPException): - raise - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}", - summary="Get specific mental model version", - description="Get observations from a specific version of a mental model.", - operation_id="get_mental_model_version", - tags=["Mental Models"], - ) - async def api_get_mental_model_version( - bank_id: str, - model_id: str, - version: int, - request_context: RequestContext = Depends(get_request_context), - ): - """Get a specific version of a mental model.""" - try: - version_data = await app.state.memory.get_mental_model_version( - bank_id=bank_id, - model_id=model_id, - version=version, - request_context=request_context, - ) - if not version_data: - raise HTTPException( - status_code=404, - detail=f"Version {version} not found for mental model '{model_id}'", - ) - return version_data + if reflection is None: + raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found") + return ReflectionResponse(**reflection) except (AuthenticationError, HTTPException): raise except Exception as e: @@ -2640,10 +2382,254 @@ def _register_routes(app: FastAPI): error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" logger.error( - f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}: {error_detail}" + f"Error in POST /v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh: {error_detail}" ) raise HTTPException(status_code=500, detail=str(e)) + @app.patch( + "/v1/default/banks/{bank_id}/reflections/{reflection_id}", + response_model=ReflectionResponse, + summary="Update reflection", + description="Update a reflection's name.", + operation_id="update_reflection", + tags=["Reflections"], + ) + async def api_update_reflection( + bank_id: str, + reflection_id: str, + body: UpdateReflectionRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Update a reflection.""" + try: + reflection = await app.state.memory.update_reflection( + bank_id=bank_id, + reflection_id=reflection_id, + name=body.name, + request_context=request_context, + ) + if reflection is None: + raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found") + return ReflectionResponse(**reflection) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.delete( + "/v1/default/banks/{bank_id}/reflections/{reflection_id}", + summary="Delete reflection", + description="Delete a reflection.", + operation_id="delete_reflection", + tags=["Reflections"], + ) + async def api_delete_reflection( + bank_id: str, + reflection_id: str, + request_context: RequestContext = Depends(get_request_context), + ): + """Delete a reflection.""" + try: + deleted = await app.state.memory.delete_reflection( + bank_id=bank_id, + reflection_id=reflection_id, + request_context=request_context, + ) + if not deleted: + raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found") + return {"status": "deleted"} + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + # ========================================================================= + # DIRECTIVES ENDPOINTS + # ========================================================================= + + @app.get( + "/v1/default/banks/{bank_id}/directives", + response_model=DirectiveListResponse, + summary="List directives", + description="List hard rules that are injected into prompts.", + operation_id="list_directives", + tags=["Directives"], + ) + async def api_list_directives( + bank_id: str, + tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"), + tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"), + active_only: bool = Query(True, description="Only return active directives"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + request_context: RequestContext = Depends(get_request_context), + ): + """List directives for a bank.""" + try: + directives = await app.state.memory.list_directives( + bank_id=bank_id, + tags=tags_filter, + tags_match=tags_match, + active_only=active_only, + limit=limit, + offset=offset, + request_context=request_context, + ) + return DirectiveListResponse(items=[DirectiveResponse(**d) for d in directives]) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get( + "/v1/default/banks/{bank_id}/directives/{directive_id}", + response_model=DirectiveResponse, + summary="Get directive", + description="Get a specific directive by ID.", + operation_id="get_directive", + tags=["Directives"], + ) + async def api_get_directive( + bank_id: str, + directive_id: str, + request_context: RequestContext = Depends(get_request_context), + ): + """Get a directive by ID.""" + try: + directive = await app.state.memory.get_directive( + bank_id=bank_id, + directive_id=directive_id, + request_context=request_context, + ) + if directive is None: + raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found") + return DirectiveResponse(**directive) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post( + "/v1/default/banks/{bank_id}/directives", + response_model=DirectiveResponse, + summary="Create directive", + description="Create a hard rule that will be injected into prompts.", + operation_id="create_directive", + tags=["Directives"], + ) + async def api_create_directive( + bank_id: str, + body: CreateDirectiveRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Create a directive.""" + try: + directive = await app.state.memory.create_directive( + bank_id=bank_id, + name=body.name, + content=body.content, + priority=body.priority, + is_active=body.is_active, + tags=body.tags, + request_context=request_context, + ) + return DirectiveResponse(**directive) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in POST /v1/default/banks/{bank_id}/directives: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.patch( + "/v1/default/banks/{bank_id}/directives/{directive_id}", + response_model=DirectiveResponse, + summary="Update directive", + description="Update a directive's properties.", + operation_id="update_directive", + tags=["Directives"], + ) + async def api_update_directive( + bank_id: str, + directive_id: str, + body: UpdateDirectiveRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Update a directive.""" + try: + directive = await app.state.memory.update_directive( + bank_id=bank_id, + directive_id=directive_id, + name=body.name, + content=body.content, + priority=body.priority, + is_active=body.is_active, + tags=body.tags, + request_context=request_context, + ) + if directive is None: + raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found") + return DirectiveResponse(**directive) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.delete( + "/v1/default/banks/{bank_id}/directives/{directive_id}", + summary="Delete directive", + description="Delete a directive.", + operation_id="delete_directive", + tags=["Directives"], + ) + async def api_delete_directive( + bank_id: str, + directive_id: str, + request_context: RequestContext = Depends(get_request_context), + ): + """Delete a directive.""" + try: + deleted = await app.state.memory.delete_directive( + bank_id=bank_id, + directive_id=directive_id, + request_context=request_context, + ) + if not deleted: + raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found") + return {"status": "deleted"} + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get( "/v1/default/banks/{bank_id}/documents", response_model=ListDocumentsResponse, @@ -2844,17 +2830,27 @@ def _register_routes(app: FastAPI): "/v1/default/banks/{bank_id}/operations", response_model=OperationsListResponse, summary="List async operations", - description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations", + description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.", operation_id="list_operations", tags=["Operations"], ) - async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)): - """List all async operations (pending and failed) for a memory bank.""" + async def api_list_operations( + bank_id: str, + status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"), + limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"), + offset: int = Query(default=0, ge=0, description="Number of operations to skip"), + request_context: RequestContext = Depends(get_request_context), + ): + """List async operations for a memory bank with optional filtering and pagination.""" try: - result = await app.state.memory.list_operations(bank_id, request_context=request_context) + result = await app.state.memory.list_operations( + bank_id, status=status, limit=limit, offset=offset, request_context=request_context + ) return OperationsListResponse( bank_id=bank_id, total=result["total"], + limit=limit, + offset=offset, operations=[OperationResponse(**op) for op in result["operations"]], ) except (AuthenticationError, HTTPException): @@ -3175,6 +3171,63 @@ def _register_routes(app: FastAPI): logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.delete( + "/v1/default/banks/{bank_id}/mental-models", + response_model=DeleteResponse, + summary="Clear all mental models", + description="Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.", + operation_id="clear_mental_models", + tags=["Banks"], + ) + async def api_clear_mental_models(bank_id: str, request_context: RequestContext = Depends(get_request_context)): + """Clear all mental models for a bank.""" + try: + result = await app.state.memory.clear_mental_models(bank_id, request_context=request_context) + return DeleteResponse( + success=True, + message=f"Cleared {result.get('deleted_count', 0)} mental models", + deleted_count=result.get("deleted_count", 0), + ) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post( + "/v1/default/banks/{bank_id}/consolidate", + response_model=ConsolidationResponse, + summary="Trigger consolidation", + description="Run memory consolidation to create/update mental models from recent memories.", + operation_id="trigger_consolidation", + tags=["Banks"], + ) + async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)): + """Trigger consolidation for a bank.""" + try: + result = await app.state.memory.run_consolidation(bank_id, request_context=request_context) + processed = result.get("processed", 0) + created = result.get("created", 0) + updated = result.get("updated", 0) + return ConsolidationResponse( + status="completed", + processed=processed, + created=created, + updated=updated, + message=f"Consolidation completed: {processed} memories processed, {created} mental models created, {updated} updated", + ) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.post( "/v1/default/banks/{bank_id}/memories", response_model=RetainResponse, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index d0992812..44df44f2 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -93,6 +93,11 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS" ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE" ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC" +# Mental models settings +ENV_ENABLE_MENTAL_MODELS = "HINDSIGHT_API_ENABLE_MENTAL_MODELS" +ENV_CONSOLIDATION_SIMILARITY_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_SIMILARITY_THRESHOLD" +ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE" + # Optimization flags ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION" ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER" @@ -171,6 +176,11 @@ DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "ver RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes) +# Mental models defaults +DEFAULT_ENABLE_MENTAL_MODELS = False # Mental models disabled by default (experimental) +DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD = 0.75 # Minimum similarity to consider a learning related +DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization) + # Database migrations DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True @@ -324,6 +334,11 @@ class HindsightConfig: retain_extraction_mode: str retain_observations_async: bool + # Mental models settings + enable_mental_models: bool + consolidation_similarity_threshold: float + consolidation_batch_size: int + # Optimization flags skip_llm_verification: bool lazy_reranker: bool @@ -426,6 +441,15 @@ class HindsightConfig: ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC) ).lower() == "true", + # Mental models settings + enable_mental_models=os.getenv(ENV_ENABLE_MENTAL_MODELS, str(DEFAULT_ENABLE_MENTAL_MODELS)).lower() + == "true", + consolidation_similarity_threshold=float( + os.getenv(ENV_CONSOLIDATION_SIMILARITY_THRESHOLD, str(DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD)) + ), + consolidation_batch_size=int( + os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE)) + ), # Database migrations run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true", # Database connection pool diff --git a/hindsight-api/hindsight_api/engine/consolidation/__init__.py b/hindsight-api/hindsight_api/engine/consolidation/__init__.py new file mode 100644 index 00000000..7d98825c --- /dev/null +++ b/hindsight-api/hindsight_api/engine/consolidation/__init__.py @@ -0,0 +1,5 @@ +"""Consolidation engine for automatic learning creation from memories.""" + +from .consolidator import run_consolidation_job + +__all__ = ["run_consolidation_job"] diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py new file mode 100644 index 00000000..9b7ec6e3 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py @@ -0,0 +1,842 @@ +"""Consolidation engine for automatic mental model creation from memories. + +The consolidation engine runs as a background job after retain operations complete. +It processes new memories and either: +- Creates new mental models from novel facts +- Updates existing mental models when new evidence supports/contradicts/refines them + +Mental models are stored in memory_units with fact_type='mental_model' and include: +- proof_count: Number of supporting memories +- source_memory_ids: Array of memory UUIDs that contribute to this mental model +- history: JSONB tracking changes over time +""" + +import json +import logging +import time +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from ..memory_engine import fq_table +from ..retain import embedding_utils +from .prompts import ( + CONSOLIDATION_SYSTEM_PROMPT, + CONSOLIDATION_USER_PROMPT, +) + +if TYPE_CHECKING: + from asyncpg import Connection + + from ...api.http import RequestContext + from ..memory_engine import MemoryEngine + +logger = logging.getLogger(__name__) + + +class ConsolidationPerfLog: + """Performance logging for consolidation operations.""" + + def __init__(self, bank_id: str): + self.bank_id = bank_id + self.start_time = time.time() + self.lines: list[str] = [] + self.timings: dict[str, float] = {} + + def log(self, message: str) -> None: + """Add a log line.""" + self.lines.append(message) + + def record_timing(self, key: str, duration: float) -> None: + """Record a timing measurement.""" + if key in self.timings: + self.timings[key] += duration + else: + self.timings[key] = duration + + def flush(self) -> None: + """Flush all log lines to the logger.""" + total_time = time.time() - self.start_time + header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}" + footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}" + + log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer + logger.info(log_output) + + +async def run_consolidation_job( + memory_engine: "MemoryEngine", + bank_id: str, + request_context: "RequestContext", +) -> dict[str, Any]: + """ + Run consolidation job for a bank. + + This is called after retain operations to consolidate new memories into mental models. + + Args: + memory_engine: MemoryEngine instance + bank_id: Bank identifier + request_context: Request context for authentication + + Returns: + Dict with consolidation results + """ + from ...config import get_config + + config = get_config() + perf = ConsolidationPerfLog(bank_id) + max_memories_per_batch = config.consolidation_batch_size + + # Check if consolidation is enabled + if not config.enable_mental_models: + logger.debug(f"Consolidation disabled for bank {bank_id}") + return {"status": "disabled", "bank_id": bank_id} + + async with memory_engine._pool.acquire() as conn: + # Get bank profile and last_consolidated_at + t0 = time.time() + bank_row = await conn.fetchrow( + f""" + SELECT bank_id, name, mission, last_consolidated_at + FROM {fq_table("banks")} + WHERE bank_id = $1 + """, + bank_id, + ) + + if not bank_row: + logger.warning(f"Bank {bank_id} not found for consolidation") + return {"status": "bank_not_found", "bank_id": bank_id} + + mission = bank_row["mission"] or "General memory consolidation" + last_consolidated_at = bank_row["last_consolidated_at"] + perf.record_timing("fetch_bank", time.time() - t0) + + # Fetch memories created after last_consolidated_at (exclude mental_model type) + t0 = time.time() + if last_consolidated_at: + memories = await conn.fetch( + f""" + SELECT id, text, fact_type, occurred_start, event_date, tags + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND created_at > $2 + AND fact_type IN ('experience', 'world') + ORDER BY created_at ASC + LIMIT $3 + """, + bank_id, + last_consolidated_at, + max_memories_per_batch, + ) + else: + memories = await conn.fetch( + f""" + SELECT id, text, fact_type, occurred_start, event_date, tags + FROM {fq_table("memory_units")} + WHERE bank_id = $1 + AND fact_type IN ('experience', 'world') + ORDER BY created_at ASC + LIMIT $2 + """, + bank_id, + max_memories_per_batch, + ) + perf.record_timing("fetch_memories", time.time() - t0) + + if not memories: + logger.debug(f"No new memories to consolidate for bank {bank_id}") + # Update timestamp anyway to prevent reprocessing + await _update_last_consolidated_at(conn, bank_id) + return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0} + + logger.info( + f"[CONSOLIDATION] bank={bank_id} memories={len(memories)} " + f"batch_size={max_memories_per_batch} since={last_consolidated_at or 'beginning'}" + ) + perf.log(f"[1] Found {len(memories)} pending memories to consolidate") + + # Process each memory sequentially + # Important: We process ALL pending memories before updating the watermark + # to avoid losing memories when many have the same timestamp + stats = { + "memories_processed": 0, + "mental_models_created": 0, + "mental_models_updated": 0, + "mental_models_merged": 0, + "actions_executed": 0, # Total actions (can be > memories_processed due to multiple actions per fact) + "skipped": 0, + } + + # Track processed memory IDs to avoid reprocessing + processed_ids: set[uuid.UUID] = set() + batch_num = 0 + + while memories: + batch_num += 1 + batch_start = time.time() + + for memory in memories: + if memory["id"] in processed_ids: + continue + + mem_start = time.time() + result = await _process_memory( + conn=conn, + memory_engine=memory_engine, + bank_id=bank_id, + memory=dict(memory), + mission=mission, + request_context=request_context, + perf=perf, + ) + mem_time = time.time() - mem_start + perf.record_timing("process_memory_total", mem_time) + + processed_ids.add(memory["id"]) + stats["memories_processed"] += 1 + + action = result.get("action") + if action == "created": + stats["mental_models_created"] += 1 + stats["actions_executed"] += 1 + elif action == "updated": + stats["mental_models_updated"] += 1 + stats["actions_executed"] += 1 + elif action == "merged": + stats["mental_models_merged"] += 1 + stats["actions_executed"] += 1 + elif action == "multiple": + # Multiple actions from one fact (tag routing) + stats["mental_models_created"] += result.get("created", 0) + stats["mental_models_updated"] += result.get("updated", 0) + stats["mental_models_merged"] += result.get("merged", 0) + stats["actions_executed"] += result.get("total_actions", 0) + elif action == "skipped": + stats["skipped"] += 1 + + batch_time = time.time() - batch_start + perf.log( + f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s " + f"(avg {batch_time / len(memories):.3f}s/memory)" + ) + + # Fetch next batch of memories (excluding already processed) + t0 = time.time() + if last_consolidated_at: + memories = await conn.fetch( + f""" + SELECT id, text, fact_type, occurred_start, event_date, tags + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND created_at > $2 + AND fact_type IN ('experience', 'world') + AND id != ALL($4) + ORDER BY created_at ASC + LIMIT $3 + """, + bank_id, + last_consolidated_at, + max_memories_per_batch, + list(processed_ids), + ) + else: + memories = await conn.fetch( + f""" + SELECT id, text, fact_type, occurred_start, event_date, tags + FROM {fq_table("memory_units")} + WHERE bank_id = $1 + AND fact_type IN ('experience', 'world') + AND id != ALL($3) + ORDER BY created_at ASC + LIMIT $2 + """, + bank_id, + max_memories_per_batch, + list(processed_ids), + ) + perf.record_timing("fetch_memories", time.time() - t0) + + # Update last_consolidated_at only after ALL memories are processed + t0 = time.time() + await _update_last_consolidated_at(conn, bank_id) + perf.record_timing("update_watermark", time.time() - t0) + + # Build summary + perf.log( + f"[3] Results: {stats['memories_processed']} memories → " + f"{stats['actions_executed']} actions " + f"({stats['mental_models_created']} created, " + f"{stats['mental_models_updated']} updated, " + f"{stats['mental_models_merged']} merged, " + f"{stats['skipped']} skipped)" + ) + + # Add timing breakdown + timing_parts = [] + if "recall" in perf.timings: + timing_parts.append(f"recall={perf.timings['recall']:.3f}s") + if "llm" in perf.timings: + timing_parts.append(f"llm={perf.timings['llm']:.3f}s") + if "embedding" in perf.timings: + timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s") + if "db_write" in perf.timings: + timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s") + + if timing_parts: + perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}") + + perf.flush() + + return {"status": "completed", "bank_id": bank_id, **stats} + + +async def _update_last_consolidated_at(conn: "Connection", bank_id: str) -> None: + """Update the bank's last_consolidated_at timestamp.""" + await conn.execute( + f""" + UPDATE {fq_table("banks")} + SET last_consolidated_at = $1 + WHERE bank_id = $2 + """, + datetime.now(timezone.utc), + bank_id, + ) + + +async def _process_memory( + conn: "Connection", + memory_engine: "MemoryEngine", + bank_id: str, + memory: dict[str, Any], + mission: str, + request_context: "RequestContext", + perf: ConsolidationPerfLog | None = None, +) -> dict[str, Any]: + """ + Process a single memory for consolidation using a SINGLE LLM call. + + This function: + 1. Finds related mental models (can be empty) + 2. Uses ONE LLM call to extract durable knowledge AND decide on actions + 3. Executes array of actions (can be multiple creates/updates) + + The LLM handles all cases: + - No related models: returns create action(s) with extracted durable knowledge + - Related models exist: returns update/create actions based on tag routing + - Purely ephemeral fact: returns empty array (skip) + + Returns: + Dict with action summary: created/updated/merged counts + """ + fact_text = memory["text"] + memory_id = memory["id"] + fact_tags = memory.get("tags") or [] + + # Find related mental models using the full recall system (NO tag filtering) + t0 = time.time() + related_mental_models = await _find_related_mental_models( + conn=conn, + memory_engine=memory_engine, + bank_id=bank_id, + query=fact_text, + request_context=request_context, + ) + if perf: + perf.record_timing("recall", time.time() - t0) + + # Single LLM call handles ALL cases (with or without existing models) + t0 = time.time() + actions = await _consolidate_with_llm( + memory_engine=memory_engine, + fact_text=fact_text, + fact_tags=fact_tags, + mental_models=related_mental_models, # Can be empty list + mission=mission, + ) + if perf: + perf.record_timing("llm", time.time() - t0) + + if not actions: + # LLM returned empty array - fact is purely ephemeral, skip + return {"action": "skipped", "reason": "no_durable_knowledge"} + + # Execute all actions and collect results + results = [] + for action in actions: + action_type = action.get("action") + if action_type == "update": + result = await _execute_update_action( + conn=conn, + memory_engine=memory_engine, + bank_id=bank_id, + memory_id=memory_id, + action=action, + mental_models=related_mental_models, + perf=perf, + ) + results.append(result) + elif action_type == "create": + result = await _execute_create_action( + conn=conn, + memory_engine=memory_engine, + bank_id=bank_id, + memory_id=memory_id, + action=action, + event_date=memory.get("event_date"), + occurred_start=memory.get("occurred_start"), + perf=perf, + ) + results.append(result) + + if not results: + # No valid actions executed + return {"action": "skipped", "reason": "no_valid_actions"} + + # Summarize results + created = sum(1 for r in results if r.get("action") == "created") + updated = sum(1 for r in results if r.get("action") == "updated") + merged = sum(1 for r in results if r.get("action") == "merged") + + if len(results) == 1: + return results[0] + + return { + "action": "multiple", + "created": created, + "updated": updated, + "merged": merged, + "total_actions": len(results), + } + + +async def _execute_update_action( + conn: "Connection", + memory_engine: "MemoryEngine", + bank_id: str, + memory_id: uuid.UUID, + action: dict[str, Any], + mental_models: list[dict[str, Any]], + perf: ConsolidationPerfLog | None = None, +) -> dict[str, Any]: + """ + Execute an update action on an existing mental model. + + Updates the mental model text, adds to history, and increments proof_count. + """ + learning_id = action.get("learning_id") + new_text = action.get("text") + reason = action.get("reason", "Updated with new fact") + + if not learning_id or not new_text: + return {"action": "skipped", "reason": "missing_learning_id_or_text"} + + # Find the mental model + model = next((m for m in mental_models if str(m["id"]) == learning_id), None) + if not model: + return {"action": "skipped", "reason": "learning_not_found"} + + # Build history entry + history = list(model.get("history", [])) + history.append( + { + "previous_text": model["text"], + "changed_at": datetime.now(timezone.utc).isoformat(), + "reason": reason, + "source_memory_id": str(memory_id), + } + ) + + # Update source_memory_ids + source_ids = list(model.get("source_memory_ids", [])) + source_ids.append(memory_id) + + # Generate new embedding for updated text + t0 = time.time() + embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text]) + embedding_str = str(embeddings[0]) if embeddings else None + if perf: + perf.record_timing("embedding", time.time() - t0) + + # Update the mental model + t0 = time.time() + await conn.execute( + f""" + UPDATE {fq_table("memory_units")} + SET text = $1, + embedding = $2::vector, + history = $3, + source_memory_ids = $4, + proof_count = $5, + updated_at = now() + WHERE id = $6 + """, + new_text, + embedding_str, + json.dumps(history), + source_ids, + len(source_ids), + uuid.UUID(learning_id), + ) + + # Create links from memory to mental model + await _create_memory_links(conn, memory_id, uuid.UUID(learning_id)) + if perf: + perf.record_timing("db_write", time.time() - t0) + + logger.debug(f"Updated mental model {learning_id} with memory {memory_id}") + + return {"action": "updated", "mental_model_id": learning_id} + + +async def _execute_create_action( + conn: "Connection", + memory_engine: "MemoryEngine", + bank_id: str, + memory_id: uuid.UUID, + action: dict[str, Any], + event_date: datetime | None = None, + occurred_start: datetime | None = None, + perf: ConsolidationPerfLog | None = None, +) -> dict[str, Any]: + """ + Execute a create action for a new mental model. + + Creates a new mental model with the specified text and tags. + The text comes directly from the classify LLM - no second LLM call needed. + """ + text = action.get("text") + tags = action.get("tags", []) + + if not text: + return {"action": "skipped", "reason": "missing_text"} + + # Use text directly from classify - skip the redundant LLM call + result = await _create_mental_model_directly( + conn=conn, + memory_engine=memory_engine, + bank_id=bank_id, + source_memory_id=memory_id, + mental_model_text=text, # Text already processed by classify LLM + tags=tags, + event_date=event_date, + occurred_start=occurred_start, + perf=perf, + ) + + logger.debug(f"Created mental model {result.get('mental_model_id')} from memory {memory_id} (tags: {tags})") + + return result + + +async def _create_memory_links( + conn: "Connection", + memory_id: uuid.UUID, + mental_model_id: uuid.UUID, +) -> None: + """ + Create links between a source memory and its mental model. + + This: + 1. Creates bidirectional semantic links between memory and mental model + 2. Copies existing memory_links from the source memory to the mental model + 3. Copies entity links from the source memory to the mental model + + This enables graph traversal to find related memories via their mental models. + + Note: Uses EXISTS checks to handle the case where source memory was deleted + by a concurrent operation between fetching and link creation. + """ + mu_table = fq_table("memory_units") + ml_table = fq_table("memory_links") + ue_table = fq_table("unit_entities") + + # 1. Bidirectional link between memory and mental model + # Only insert if both units exist (handles concurrent deletion) + await conn.execute( + f""" + INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight) + SELECT $1, $2, 'semantic', 1.0 + WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1) + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2) + ON CONFLICT DO NOTHING + """, + memory_id, + mental_model_id, + ) + await conn.execute( + f""" + INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight) + SELECT $1, $2, 'semantic', 1.0 + WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1) + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2) + ON CONFLICT DO NOTHING + """, + mental_model_id, + memory_id, + ) + + # 2. Copy outgoing memory_links from source memory to mental model + # If source memory links to X, mental model should also link to X + await conn.execute( + f""" + INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight) + SELECT $1, ml.to_unit_id, ml.link_type, ml.entity_id, ml.weight + FROM {ml_table} ml + WHERE ml.from_unit_id = $2 AND ml.to_unit_id != $1 + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1) + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.to_unit_id) + ON CONFLICT DO NOTHING + """, + mental_model_id, + memory_id, + ) + + # 3. Copy incoming memory_links from source memory to mental model + # If X links to source memory, X should also link to mental model + await conn.execute( + f""" + INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight) + SELECT ml.from_unit_id, $1, ml.link_type, ml.entity_id, ml.weight + FROM {ml_table} ml + WHERE ml.to_unit_id = $2 AND ml.from_unit_id != $1 + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1) + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.from_unit_id) + ON CONFLICT DO NOTHING + """, + mental_model_id, + memory_id, + ) + + # 4. Copy entity links from source memory to mental model + await conn.execute( + f""" + INSERT INTO {ue_table} (unit_id, entity_id) + SELECT $1, ue.entity_id + FROM {ue_table} ue + WHERE ue.unit_id = $2 + AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1) + ON CONFLICT DO NOTHING + """, + mental_model_id, + memory_id, + ) + + +async def _find_related_mental_models( + conn: "Connection", + memory_engine: "MemoryEngine", + bank_id: str, + query: str, + request_context: "RequestContext", +) -> list[dict[str, Any]]: + """ + Find mental models related to the given query using the full recall system. + + IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL + potentially related mental models regardless of scope, so the LLM can + decide on tag routing (same scope update vs cross-scope create). + + This leverages: + - Semantic search (embedding similarity) + - BM25 text search (keyword matching) + - Entity-based retrieval (shared entities) + - Graph traversal (connected via entity links) + + Returns: + List of related mental models with their tags for LLM tag routing + """ + # Use recall to find related mental models + # NO tags parameter - we want ALL mental models regardless of scope + # Use low max_tokens since we only need mental models, not memories + recall_result = await memory_engine.recall_async( + bank_id=bank_id, + query=query, + max_tokens=5000, # Token budget for mental models + fact_type=["mental_model"], # Only retrieve mental models + request_context=request_context, + _quiet=True, # Suppress logging + # NO tags parameter - intentionally get ALL mental models + ) + + # If no mental models returned, return empty list + # When fact_type=["mental_model"], results come back in `results` field + if not recall_result.results: + return [] + + # Trust recall's relevance filtering - fetch full data for each mental model + results = [] + for mm in recall_result.results: + # Fetch full mental model data from DB to get history, source_memory_ids, tags + row = await conn.fetchrow( + f""" + SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at + FROM {fq_table("memory_units")} + WHERE id = $1 AND bank_id = $2 AND fact_type = 'mental_model' + """, + uuid.UUID(mm.id), + bank_id, + ) + + if row: + history = row["history"] + if isinstance(history, str): + history = json.loads(history) + elif history is None: + history = [] + + results.append( + { + "id": row["id"], + "text": row["text"], + "proof_count": row["proof_count"] or 1, + "history": history, + "tags": row["tags"] or [], # Include tags for LLM tag routing + "source_memory_ids": row["source_memory_ids"] or [], + "similarity": 1.0, # Retrieved via recall so assumed relevant + } + ) + + return results + + +async def _consolidate_with_llm( + memory_engine: "MemoryEngine", + fact_text: str, + fact_tags: list[str], + mental_models: list[dict[str, Any]], + mission: str, +) -> list[dict[str, Any]]: + """ + Single LLM call to extract durable knowledge and decide on consolidation actions. + + This handles ALL cases: + - No related mental models: extracts durable knowledge, returns create action + - Related models exist: compares and returns update/create actions + - Purely ephemeral fact: returns empty array + + Returns: + List of actions, each being: + - {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."} + - {"action": "create", "tags": [...], "text": "...", "reason": "..."} + - [] if fact is purely ephemeral (no durable knowledge) + """ + # Format mental models WITH their tags (or "None" if empty) + if mental_models: + mental_models_text = "\n".join( + f'- ID: {mm["id"]}, Tags: {json.dumps(mm["tags"])}, Text: "{mm["text"]}" (proof_count: {mm["proof_count"]})' + for mm in mental_models + ) + else: + mental_models_text = "None (this is a new topic - create if fact contains durable knowledge)" + + # Only include mission section if mission is set and not the default + mission_section = "" + if mission and mission != "General memory consolidation": + mission_section = f""" +MISSION CONTEXT: {mission} + +Focus on DURABLE knowledge that serves this mission, not ephemeral state. +""" + + user_prompt = CONSOLIDATION_USER_PROMPT.format( + mission_section=mission_section, + fact_text=fact_text, + fact_tags=json.dumps(fact_tags), + mental_models_text=mental_models_text, + ) + + messages = [ + {"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ] + + try: + result = await memory_engine._llm_config.call( + messages=messages, + skip_validation=True, # Raw JSON response + scope="consolidation", + ) + # Parse JSON response - should be an array + if isinstance(result, str): + result = json.loads(result) + # Ensure result is a list + if isinstance(result, list): + return result + # Handle legacy single-action format for backward compatibility + if isinstance(result, dict): + if result.get("related_ids") and result.get("consolidated_text"): + # Convert old format to new format + return [ + { + "action": "update", + "learning_id": result["related_ids"][0], + "text": result["consolidated_text"], + "reason": result.get("reason", ""), + } + ] + return [] + return [] + except Exception as e: + logger.warning(f"Error in consolidation LLM call: {e}") + return [] + + +async def _create_mental_model_directly( + conn: "Connection", + memory_engine: "MemoryEngine", + bank_id: str, + source_memory_id: uuid.UUID, + mental_model_text: str, + tags: list[str] | None = None, + event_date: datetime | None = None, + occurred_start: datetime | None = None, + perf: ConsolidationPerfLog | None = None, +) -> dict[str, Any]: + """ + Create a mental model directly with pre-processed text (no LLM call). + + Used when the classify LLM has already provided the learning text. + This avoids the redundant second LLM call. + """ + # Generate embedding for the mental model (convert to string for pgvector) + t0 = time.time() + embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [mental_model_text]) + embedding_str = str(embeddings[0]) if embeddings else None + if perf: + perf.record_timing("embedding", time.time() - t0) + + # Create the mental model as a memory_unit + now = datetime.now(timezone.utc) + mm_event_date = event_date or now + mm_occurred_start = occurred_start or now + mm_tags = tags or [] + + t0 = time.time() + mental_model_id = uuid.uuid4() + row = await conn.fetchrow( + f""" + INSERT INTO {fq_table("memory_units")} ( + id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history, + tags, event_date, occurred_start + ) + VALUES ($1, $2, $3, 'mental_model', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8) + RETURNING id + """, + mental_model_id, + bank_id, + mental_model_text, + embedding_str, + [source_memory_id], + mm_tags, + mm_event_date, + mm_occurred_start, + ) + + # Create links between memory and mental model (includes entity links, memory_links) + await _create_memory_links(conn, source_memory_id, mental_model_id) + if perf: + perf.record_timing("db_write", time.time() - t0) + + logger.debug(f"Created mental model {mental_model_id} from memory {source_memory_id} (tags: {mm_tags})") + + return {"action": "created", "mental_model_id": str(row["id"]), "tags": mm_tags} diff --git a/hindsight-api/hindsight_api/engine/consolidation/prompts.py b/hindsight-api/hindsight_api/engine/consolidation/prompts.py new file mode 100644 index 00000000..59e12977 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/consolidation/prompts.py @@ -0,0 +1,91 @@ +"""Prompts for the consolidation engine.""" + +CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (mental models) and merge with existing knowledge when appropriate. + +You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text. + +## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE +Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state. + +Examples of extracting durable knowledge: +- "User moved to Room 203" -> "Room 203 exists" (location exists, not where user is now) +- "User visited Acme Corp at Room 105" -> "Acme Corp is located in Room 105" +- "User took the elevator to floor 3" -> "Floor 3 is accessible by elevator" +- "User met Sarah at the lobby" -> "Sarah can be found at the lobby" + +DO NOT track current user position/state as knowledge - that changes constantly. +DO track permanent facts learned from the user's actions. + +## PRESERVE SPECIFIC DETAILS +Keep names, locations, numbers, and other specifics. Do NOT: +- Abstract into general principles +- Generate business insights +- Make knowledge generic + +GOOD examples: +- Fact: "John likes pizza" -> "John likes pizza" +- Fact: "Alice works at Google" -> "Alice works at Google" + +BAD examples: +- "John likes pizza" -> "Understanding dietary preferences helps..." (TOO ABSTRACT) +- "User is at Room 203" -> "User is currently at Room 203" (EPHEMERAL STATE) + +## MERGE RULES (when comparing to existing mental models): +1. REDUNDANT: Same information worded differently → update existing +2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y") +3. UPDATE: New state replacing old state → update with history + +## TAG ROUTING RULES: +Tags define visibility scopes. The fact and each mental model have tags (can be empty = global). + +| Fact Tags | Model Tags | Action | +|-----------|------------|--------| +| [alice] | [alice] | UPDATE the model (same scope) | +| [alice] | [] | UPDATE the model (global absorbs all scopes) | +| [alice] | [bob] | CREATE new untagged model (cross-scope insight) | +| [] | [alice] | UPDATE the model (untagged facts can update any scope) | +| [] | [] | UPDATE the model (global to global) | + +When NO existing model matches the fact's topic: CREATE new model with fact's tags. + +## MULTIPLE ACTIONS: +One fact can trigger MULTIPLE actions. For example: +- Update a scoped model [alice] about pizza preferences +- AND update a global model [] about pizza in general + +Output an ARRAY of actions (can be empty, one, or many). + +## CRITICAL RULES: +- NEVER merge facts about DIFFERENT people +- NEVER merge unrelated topics (food preferences vs work vs hobbies) +- When merging contradictions, capture the CHANGE (before → after) +- Keep mental models focused on ONE specific topic per person +- Cross-scope insights (alice's fact about bob's topic) become UNTAGGED (global) +- The "text" field MUST contain durable knowledge, not ephemeral state""" + +CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge. +{mission_section} +NEW FACT: {fact_text} +FACT TAGS: {fact_tags} + +EXISTING MENTAL MODELS: +{mental_models_text} + +Instructions: +1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X") +2. Then compare with existing mental models: + - If a model covers the same topic: UPDATE it with the new knowledge + - If no model covers the topic: CREATE a new one + - If fact is about different scope: apply tag routing rules + +Output JSON array of actions (ALWAYS an array, even for single action): +[ + {{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}}, + {{"action": "create", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}} +] + +If NO consolidation is needed (fact is purely ephemeral with no durable knowledge): +[] + +If no models exist and fact contains durable knowledge: +[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]""" diff --git a/hindsight-api/hindsight_api/engine/directives/__init__.py b/hindsight-api/hindsight_api/engine/directives/__init__.py new file mode 100644 index 00000000..6ca566f0 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/directives/__init__.py @@ -0,0 +1,5 @@ +"""Directives module for hard rules injected into prompts.""" + +from .models import Directive + +__all__ = ["Directive"] diff --git a/hindsight-api/hindsight_api/engine/directives/models.py b/hindsight-api/hindsight_api/engine/directives/models.py new file mode 100644 index 00000000..f8d7d763 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/directives/models.py @@ -0,0 +1,37 @@ +"""Pydantic models for directives.""" + +from datetime import datetime, timezone +from uuid import UUID + +from pydantic import BaseModel, Field + + +class Directive(BaseModel): + """A directive is a hard rule injected into prompts. + + Directives are user-defined rules that guide agent behavior. Unlike mental models + which are automatically consolidated from memories, directives are explicit + instructions that are always included in relevant prompts. + + Examples: + - "Always respond in formal English" + - "Never share personal data with third parties" + - "Prefer conservative investment recommendations" + """ + + id: UUID = Field(description="Unique identifier") + bank_id: str = Field(description="Bank this directive belongs to") + name: str = Field(description="Human-readable name") + content: str = Field(description="The directive text to inject into prompts") + priority: int = Field(default=0, description="Higher priority directives are injected first") + is_active: bool = Field(default=True, description="Whether this directive is currently active") + tags: list[str] = Field(default_factory=list, description="Tags for filtering") + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created" + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated" + ) + + class Config: + from_attributes = True diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index f6211416..ecf0f6a4 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -11,6 +11,7 @@ This implements a sophisticated memory architecture that combines: import asyncio import contextvars +import json import logging import time import uuid @@ -141,7 +142,7 @@ from .llm_wrapper import LLMConfig from .query_analyzer import QueryAnalyzer from .reflect import run_reflect_agent from .reflect.models import MentalModelInput -from .reflect.tools import tool_expand, tool_learn, tool_lookup, tool_recall +from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models from .response_models import ( VALID_RECALL_FACT_TYPES, EntityObservation, @@ -501,269 +502,101 @@ class MemoryEngine(MemoryEngineInterface): logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}") - async def _handle_refresh_mental_models(self, task_dict: dict[str, Any]): + async def _handle_consolidation(self, task_dict: dict[str, Any]): """ - Handler for refresh mental models tasks. + Handler for consolidation tasks. - This is the main background job that: - 1. Identifies mental models (structural from mission + emergent from entities) - 2. Generates summaries for each mental model + Consolidates new memories into learnings for a bank. Args: - task_dict: Dict with 'bank_id', 'operation_id', optional 'tags', optional 'subtype' - """ - import time + task_dict: Dict with 'bank_id' + Raises: + ValueError: If bank_id is missing + Exception: Any exception from consolidation (propagates to execute_task for retry) + """ bank_id = task_dict.get("bank_id") - operation_id = task_dict.get("operation_id") - tags = task_dict.get("tags") # Tags to apply to created mental models - subtype = task_dict.get("subtype") # Optional filter: "structural", "emergent", "pinned", or "learned" if not bank_id: - raise ValueError("bank_id is required for refresh mental models task") - - refresh_structural = subtype is None or subtype == "structural" - refresh_emergent = subtype is None or subtype == "emergent" - refresh_pinned = subtype is None or subtype == "pinned" - refresh_learned = subtype is None or subtype == "learned" - subtype_desc = f" (subtype={subtype})" if subtype else " (all)" + raise ValueError("bank_id is required for consolidation task") from hindsight_api.models import RequestContext - internal_context = RequestContext() - pool = await self._get_pool() - - from .mental_models.emergent import ( - detect_entity_candidates, - evaluate_emergent_models, - filter_candidates_by_mission, - ) - - # ===== Phase 1: Identify mental models (with buffered logging) ===== - phase1_start = time.perf_counter() - id_log: list[str] = [] # Log buffer for identification phase - - # Step 1: Get the bank's mission (required - should have been validated before scheduling) - profile = await self.get_bank_profile(bank_id, request_context=internal_context) - mission = profile.get("mission") or "" - if not mission: - raise ValueError(f"Cannot refresh mental models: no mission is set for bank '{bank_id}'") - - structural_removed: list[str] = [] - emergent_removed: list[str] = [] - emergent_promoted: list[str] = [] - - # Step 2: Derive structural models (LLM sees existing ones and decides what to keep) - if refresh_structural: - existing_structural = await self.list_mental_models( - bank_id, subtype="structural", request_context=internal_context - ) - id_log.append(f"structural: {len(existing_structural) if existing_structural else 0} existing") - models_to_remove = await self._derive_structural_models_internal( - bank_id, mission, pool, existing_models=existing_structural, tags=tags - ) - for model_id in models_to_remove: - structural_removed.append(model_id) - await self.delete_mental_model(bank_id, model_id, request_context=internal_context) - if structural_removed: - id_log.append(f"structural removed: {structural_removed}") - else: - id_log.append("structural: skipped (subtype filter)") - - # Step 3: Evaluate existing emergent models - removed_entity_ids: set[str] = set() # Track entity_ids we removed (to prevent re-promotion) - if refresh_emergent: - existing_emergent = await self.list_mental_models( - bank_id, subtype="emergent", request_context=internal_context - ) - if existing_emergent: - id_log.append(f"emergent: {len(existing_emergent)} existing") - # Build model_id -> entity_id mapping for tracking - model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent} - models_to_remove = await evaluate_emergent_models(self._llm_config, existing_emergent) - for model_id in models_to_remove: - emergent_removed.append(model_id) - # Track the entity_id so we don't re-promote it - entity_id = model_to_entity.get(model_id) - if entity_id: - removed_entity_ids.add(str(entity_id)) - await self.delete_mental_model(bank_id, model_id, request_context=internal_context) - if emergent_removed: - id_log.append(f"emergent removed: {emergent_removed}") - else: - id_log.append("emergent: 0 existing") - - # Step 4: Detect emergent candidates (entities worth promoting) - candidates = await detect_entity_candidates(pool, bank_id) - id_log.append(f"emergent candidates detected: {len(candidates)}") - - # Step 5: Filter candidates by mission relevance - if candidates and mission: - candidates = await filter_candidates_by_mission(self._llm_config, mission, candidates) - id_log.append(f"emergent candidates after mission filter: {len(candidates)}") - - # Step 6: Filter out candidates whose entity was just removed (they failed evaluation) - if removed_entity_ids: - original_count = len(candidates) - candidates = [c for c in candidates if c.entity_id not in removed_entity_ids] - if len(candidates) < original_count: - id_log.append(f"emergent excluded (failed evaluation): {original_count - len(candidates)}") - - # Step 7: Promote filtered candidates to mental models (with tags if provided) - for candidate in candidates: - if candidate.entity_id: - emergent_promoted.append(candidate.name) - await self._promote_entity_internal(bank_id, candidate.entity_id, pool, tags=tags) - if emergent_promoted: - id_log.append(f"emergent promoted: {emergent_promoted}") - else: - id_log.append("emergent: skipped (subtype filter)") - - phase1_duration_ms = int((time.perf_counter() - phase1_start) * 1000) - - # Output single log for Phase 1 - logger.info( - f"[MENTAL_MODELS] Identification complete for bank={bank_id}{subtype_desc} " - f"in {phase1_duration_ms}ms: {', '.join(id_log)}" - ) - - # ===== Phase 2: Generate summaries in parallel ===== - models = await self.list_mental_models(bank_id, request_context=internal_context) - - # Filter models to only those being refreshed based on subtype - # NOTE: Directives (subtype='directive') are NEVER refreshed - they have user-provided content - models_to_refresh = [] - for m in models: - model_subtype = m["subtype"] - # Skip directives - they have user-defined content that should never be regenerated - if model_subtype == "directive": - continue - if model_subtype == "structural" and refresh_structural: - models_to_refresh.append(m) - elif model_subtype == "emergent" and refresh_emergent: - models_to_refresh.append(m) - elif model_subtype == "pinned" and refresh_pinned: - models_to_refresh.append(m) - elif model_subtype == "learned" and refresh_learned: - models_to_refresh.append(m) - - # Get concurrency limit from config - from ..config import get_config - - config = get_config() - concurrency = config.mental_model_refresh_concurrency - - # Use semaphore to limit concurrent refreshes - semaphore = asyncio.Semaphore(concurrency) - # Track results with timing: model_id -> {status, duration_ms, iterations, tool_calls, observations} - refresh_results: dict[str, dict[str, Any]] = {} - - async def refresh_with_semaphore(model: dict) -> None: - """Refresh a single model with semaphore-controlled concurrency.""" - async with semaphore: - model_id = model["id"] - model_name = model["name"] - start_time = time.perf_counter() - try: - result = await self.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=internal_context, - _return_agent_result=True, # Get agent stats for logging - ) - duration_ms = int((time.perf_counter() - start_time) * 1000) - if result and isinstance(result, tuple): - _, agent_result = result - refresh_results[model_id] = { - "status": "success", - "name": model_name, - "duration_ms": duration_ms, - "phases": len(agent_result.phases_completed) if agent_result else 0, - "memories_analyzed": agent_result.memories_analyzed if agent_result else 0, - "observations": len(agent_result.observations) if agent_result else 0, - } - else: - refresh_results[model_id] = { - "status": "success", - "name": model_name, - "duration_ms": duration_ms, - } - except Exception as e: - duration_ms = int((time.perf_counter() - start_time) * 1000) - refresh_results[model_id] = { - "status": "failed", - "name": model_name, - "duration_ms": duration_ms, - "error": str(e), - } - - # Run all refreshes in parallel (bounded by semaphore) - phase2_start = time.perf_counter() - await asyncio.gather(*[refresh_with_semaphore(m) for m in models_to_refresh]) - phase2_duration_ms = int((time.perf_counter() - phase2_start) * 1000) - - # Build summary for each model - model_summaries: list[str] = [] - for model_id, info in refresh_results.items(): - if info["status"] == "success": - parts = [f"{info['name']}"] - if "iterations" in info: - parts.append(f"iter={info['iterations']}") - if "tool_calls" in info: - parts.append(f"tools={info['tool_calls']}") - if "observations" in info: - parts.append(f"obs={info['observations']}") - parts.append(f"{info['duration_ms']}ms") - model_summaries.append(f"[{' '.join(parts)}]") - else: - model_summaries.append( - f"[{info['name']} FAILED: {info.get('error', 'unknown')} {info['duration_ms']}ms]" - ) - - success_count = sum(1 for r in refresh_results.values() if r["status"] == "success") - failed_count = len(refresh_results) - success_count - - # Output single log for Phase 2 - logger.info( - f"[MENTAL_MODELS] Refresh complete for bank={bank_id}, operation={operation_id}: " - f"{success_count}/{len(models_to_refresh)} succeeded in {phase2_duration_ms}ms (concurrency={concurrency}). " - f"Models: {' '.join(model_summaries)}" - ) - - async def _handle_refresh_single_mental_model(self, task_dict: dict[str, Any]): - """ - Handler for single mental model refresh tasks. - - Refreshes content for a specific mental model. - - Args: - task_dict: Dict with 'bank_id', 'model_id', 'operation_id' - """ - bank_id = task_dict.get("bank_id") - model_id = task_dict.get("model_id") - operation_id = task_dict.get("operation_id") - - if not bank_id or not model_id: - raise ValueError("bank_id and model_id are required for refresh mental model task") - - logger.info( - f"[MENTAL_MODEL_TASK] Starting refresh for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" - ) - - from hindsight_api.models import RequestContext + from .consolidation import run_consolidation_job internal_context = RequestContext() - - # Refresh content for the model - result = await self.refresh_mental_model( + result = await run_consolidation_job( + memory_engine=self, bank_id=bank_id, - model_id=model_id, request_context=internal_context, ) - if result: - logger.info(f"[MENTAL_MODEL_TASK] Completed refresh for model_id={model_id}, bank_id={bank_id}") - else: - logger.warning(f"[MENTAL_MODEL_TASK] Model not found: model_id={model_id}, bank_id={bank_id}") + logger.info(f"[CONSOLIDATION] bank={bank_id} completed: {result.get('memories_processed', 0)} processed") + + async def _handle_create_reflection(self, task_dict: dict[str, Any]): + """ + Handler for create_reflection tasks. + + Runs reflect with the source query and updates the reflection with the generated content. + The reflection should already exist in the database (created during submit_async_create_reflection). + + Args: + task_dict: Dict with 'bank_id', 'reflection_id', 'source_query', 'max_tokens', 'operation_id' + + Raises: + ValueError: If required fields are missing + Exception: Any exception from reflect/update (propagates to execute_task for retry) + """ + bank_id = task_dict.get("bank_id") + reflection_id = task_dict.get("reflection_id") + source_query = task_dict.get("source_query") + max_tokens = task_dict.get("max_tokens", 2048) + + if not bank_id or not reflection_id or not source_query: + raise ValueError("bank_id, reflection_id, and source_query are required for create_reflection task") + + logger.info(f"[CREATE_REFLECTION_TASK] Starting for bank_id={bank_id}, reflection_id={reflection_id}") + + from hindsight_api.models import RequestContext + + internal_context = RequestContext() + + # Run reflect to generate content + reflect_result = await self.reflect_async( + bank_id=bank_id, + query=source_query, + max_tokens=max_tokens, + request_context=internal_context, + ) + + generated_content = reflect_result.text or "No content generated" + + # Build reflect_response payload to store + reflect_response = { + "text": reflect_result.text, + "based_on": { + fact_type: [ + { + "id": str(fact.id), + "text": fact.text, + "type": fact_type, + } + for fact in facts + ] + for fact_type, facts in reflect_result.based_on.items() + }, + "mental_models": [], # Mental models are included in based_on["mental-models"] + } + + # Update the reflection with the generated content and reflect_response + await self.update_reflection( + bank_id=bank_id, + reflection_id=reflection_id, + content=generated_content, + reflect_response=reflect_response, + request_context=internal_context, + ) + + logger.info(f"[CREATE_REFLECTION_TASK] Completed for bank_id={bank_id}, reflection_id={reflection_id}") async def execute_task(self, task_dict: dict[str, Any]): """ @@ -801,10 +634,10 @@ class MemoryEngine(MemoryEngineInterface): try: if task_type == "batch_retain": await self._handle_batch_retain(task_dict) - elif task_type == "refresh_mental_models": - await self._handle_refresh_mental_models(task_dict) - elif task_type == "refresh_mental_model": - await self._handle_refresh_single_mental_model(task_dict) + elif task_type == "consolidation": + await self._handle_consolidation(task_dict) + elif task_type == "create_reflection": + await self._handle_create_reflection(task_dict) else: logger.error(f"Unknown task type: {task_type}") # Don't retry unknown task types @@ -1483,6 +1316,17 @@ class MemoryEngine(MemoryEngineInterface): except Exception as e: logger.warning(f"Post-retain hook error (non-fatal): {e}") + # Trigger consolidation as a tracked async operation if enabled + from ..config import get_config + + config = get_config() + if config.enable_mental_models: + try: + await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context) + except Exception as e: + # Log but don't fail the retain - consolidation is non-critical + logger.warning(f"Failed to submit consolidation task for bank {bank_id}: {e}") + if return_usage: return result, total_usage return result @@ -1598,6 +1442,7 @@ class MemoryEngine(MemoryEngineInterface): tags: list[str] | None = None, tags_match: TagsMatch = "any", _connection_budget: int | None = None, + _quiet: bool = False, ) -> RecallResultModel: """ Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods). @@ -1680,9 +1525,10 @@ class MemoryEngine(MemoryEngineInterface): effective_budget = budget if budget is not None else Budget.MID thinking_budget = budget_mapping[effective_budget] - # Log recall start with tags if present - tags_info = f", tags={tags} ({tags_match})" if tags else "" - logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}") + # Log recall start with tags if present (skip if quiet mode for internal operations) + if not _quiet: + tags_info = f", tags={tags} ({tags_match})" if tags else "" + logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}") # Backpressure: limit concurrent recalls to prevent overwhelming the database result = None @@ -1711,6 +1557,7 @@ class MemoryEngine(MemoryEngineInterface): tags=tags, tags_match=tags_match, connection_budget=_connection_budget, + quiet=_quiet, ) break # Success - exit retry loop except Exception as e: @@ -1832,6 +1679,7 @@ class MemoryEngine(MemoryEngineInterface): tags: list[str] | None = None, tags_match: TagsMatch = "any", connection_budget: int | None = None, + quiet: bool = False, ) -> RecallResultModel: """ Search implementation with modular retrieval and reranking. @@ -2462,13 +2310,15 @@ class MemoryEngine(MemoryEngineInterface): log_buffer.append( f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}" ) - logger.info("\n" + "\n".join(log_buffer)) + if not quiet: + logger.info("\n" + "\n".join(log_buffer)) return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict) except Exception as e: log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}") - logger.error("\n" + "\n".join(log_buffer)) + if not quiet: + logger.error("\n" + "\n".join(log_buffer)) raise Exception(f"Failed to search memories: {str(e)}") def _filter_by_token_budget( @@ -2724,6 +2574,79 @@ class MemoryEngine(MemoryEngineInterface): except Exception as e: raise Exception(f"Failed to delete agent data: {str(e)}") + async def clear_mental_models( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, int]: + """ + Clear all mental models for a bank. + + Args: + bank_id: Bank ID to clear mental models for + request_context: Request context for authentication. + + Returns: + Dictionary with count of deleted mental models + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + async with acquire_with_retry(pool) as conn: + async with conn.transaction(): + # Count mental models before deletion + count = await conn.fetchval( + f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'", + bank_id, + ) + + # Delete all mental models + await conn.execute( + f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'", + bank_id, + ) + + # Reset consolidation timestamp + await conn.execute( + f"UPDATE {fq_table('banks')} SET last_consolidated_at = NULL WHERE bank_id = $1", + bank_id, + ) + + return {"deleted_count": count or 0} + + async def run_consolidation( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, int]: + """ + Run memory consolidation to create/update mental models. + + Args: + bank_id: Bank ID to run consolidation for + request_context: Request context for authentication. + + Returns: + Dictionary with consolidation stats + """ + await self._authenticate_tenant(request_context) + + from .consolidation import run_consolidation_job + + result = await run_consolidation_job( + memory_engine=self, + bank_id=bank_id, + request_context=request_context, + ) + + return { + "processed": result.get("processed", 0), + "created": result.get("created", 0), + "updated": result.get("updated", 0), + "skipped": result.get("skipped", 0), + } + async def get_graph_data( self, bank_id: str | None = None, @@ -3417,6 +3340,7 @@ class MemoryEngine(MemoryEngineInterface): request_context: "RequestContext", tags: list[str] | None = None, tags_match: TagsMatch = "any", + exclude_reflection_ids: list[str] | None = None, ) -> ReflectResult: """ Reflect and formulate an answer using an agentic loop with tools. @@ -3438,6 +3362,10 @@ class MemoryEngine(MemoryEngineInterface): context: Additional context string to include in agent prompt max_tokens: Max tokens (currently unused, reserved for future) response_schema: Optional JSON Schema for structured output (not yet supported) + tags: Optional tags to filter memories + tags_match: How to match tags - "any" (OR), "all" (AND) + exclude_reflection_ids: Optional list of reflection IDs to exclude from search + (used when refreshing a reflection to avoid circular reference) Returns: ReflectResult containing: @@ -3490,46 +3418,72 @@ class MemoryEngine(MemoryEngineInterface): # (not held during LLM calls which can be slow) pool = await self._get_pool() + # Get bank stats for freshness info + bank_stats = await self.get_bank_stats(bank_id, request_context=request_context) + last_consolidated_at = bank_stats.last_consolidated_at if hasattr(bank_stats, "last_consolidated_at") else None + pending_consolidation = bank_stats.pending_consolidation if hasattr(bank_stats, "pending_consolidation") else 0 + # Create tool callbacks that acquire connections only when needed - async def lookup_fn(model_id: str | None = None) -> dict[str, Any]: + from .reflect.tools import tool_search_reflections + from .retain import embedding_utils + + async def search_reflections_fn(q: str, max_results: int = 5) -> dict[str, Any]: + # Generate embedding for the query + embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [q]) + query_embedding = embeddings[0] async with pool.acquire() as conn: - return await tool_lookup(conn, bank_id, model_id, tags=tags, tags_match=tags_match) + return await tool_search_reflections( + conn, + bank_id, + q, + query_embedding, + max_results=max_results, + tags=tags, + tags_match=tags_match, + exclude_ids=exclude_reflection_ids, + ) + + async def search_mental_models_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]: + return await tool_search_mental_models( + self, + bank_id, + q, + request_context, + max_tokens=max_tokens, + tags=tags, + tags_match=tags_match, + last_consolidated_at=last_consolidated_at, + pending_consolidation=pending_consolidation, + ) async def recall_fn(q: str, max_tokens: int = 4096) -> dict[str, Any]: return await tool_recall( self, bank_id, q, request_context, max_tokens=max_tokens, tags=tags, tags_match=tags_match ) - async def learn_fn(input: MentalModelInput) -> dict[str, Any]: - async with pool.acquire() as conn: - result = await tool_learn(conn, bank_id, input, tags=tags) - # If a new model was created, trigger background refresh - if result.get("status") == "created" and result.get("model_id"): - try: - await self.refresh_mental_model_async( - bank_id=bank_id, - model_id=result["model_id"], - request_context=request_context, - ) - logger.info(f"[REFLECT] Triggered background refresh for learned model: {result['model_id']}") - except Exception as e: - logger.warning(f"[REFLECT] Failed to trigger refresh for {result['model_id']}: {e}") - return result - async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]: async with pool.acquire() as conn: return await tool_expand(conn, bank_id, memory_ids, depth) - # Load directives (mental models with subtype='directive') + # Load directives from the dedicated directives table # Directives are hard rules that must be followed in all responses - # Filter by tags if provided (same logic as other mental models) - directives = await self.list_mental_models( + directives_raw = await self.list_directives( bank_id=bank_id, - subtype="directive", tags=tags, tags_match=tags_match, + active_only=True, request_context=request_context, ) + # Convert directive format to the expected format for reflect agent + # The agent expects: name, description (optional), observations (list of {title, content}) + directives = [ + { + "name": d["name"], + "description": d["content"], # Use content as description + "observations": [], # Directives use content directly, not observations + } + for d in directives_raw + ] if directives: logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives") @@ -3539,9 +3493,9 @@ class MemoryEngine(MemoryEngineInterface): bank_id=bank_id, query=query, bank_profile=profile, - lookup_fn=lookup_fn, + search_reflections_fn=search_reflections_fn, + search_mental_models_fn=search_mental_models_fn, recall_fn=recall_fn, - learn_fn=learn_fn, expand_fn=expand_fn, context=context, max_iterations=max_iterations, @@ -3598,11 +3552,10 @@ class MemoryEngine(MemoryEngineInterface): ) ) - # Extract mental models from lookup tool outputs - only include models the agent actually used - # agent_result.used_model_ids contains validated IDs from the done action - used_model_ids_set = set(agent_result.used_model_ids) if agent_result.used_model_ids else set() - based_on["mental_model"] = [] - mental_models_result: list[MentalModelRef] = [] + # Extract mental models from tool outputs - only include models the agent actually used + # agent_result.used_mental_model_ids contains validated IDs from the done action + used_model_ids_set = set(agent_result.used_mental_model_ids) if agent_result.used_mental_model_ids else set() + based_on["mental-models"] = [] seen_model_ids: set[str] = set() for tc in agent_result.tool_trace: if tc.tool == "get_mental_model": @@ -3615,32 +3568,69 @@ class MemoryEngine(MemoryEngineInterface): if used_model_ids_set and model_id not in used_model_ids_set: continue # Skip models not actually used by the agent seen_model_ids.add(model_id) - # Add to based_on as MemoryFact with type "mental_model" + # Add to based_on as MemoryFact with type "mental-models" model_name = model.get("name", "") model_summary = model.get("summary") or model.get("description", "") - based_on["mental_model"].append( + based_on["mental-models"].append( MemoryFact( id=model_id, text=f"{model_name}: {model_summary}", - fact_type="mental_model", + fact_type="mental-models", context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})", occurred_start=None, occurred_end=None, ) ) - mental_models_result.append( - MentalModelRef( + elif tc.tool == "search_mental_models": + # Search mental models - include all returned models (filtered by used_model_ids_set if specified) + for model in tc.output.get("mental_models", []): + model_id = model.get("id") + if model_id and model_id not in seen_model_ids: + # Only include models that the agent declared as used (or all if none specified) + if used_model_ids_set and model_id not in used_model_ids_set: + continue # Skip models not actually used by the agent + seen_model_ids.add(model_id) + # Add to based_on as MemoryFact with type "mental-models" + model_name = model.get("name", "") + model_summary = model.get("summary") or model.get("description", "") + based_on["mental-models"].append( + MemoryFact( id=model_id, - name=model_name, - type=model.get("type", "concept"), - subtype=model.get("subtype", "structural"), - description=model.get("description", ""), - summary=model.get("summary"), + text=f"{model_name}: {model_summary}", + fact_type="mental-models", + context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})", + occurred_start=None, + occurred_end=None, + ) + ) + elif tc.tool == "search_reflections": + # Search reflections - include all returned reflections (filtered by used_reflection_ids_set if specified) + used_reflection_ids_set = ( + set(agent_result.used_reflection_ids) if agent_result.used_reflection_ids else set() + ) + for reflection in tc.output.get("reflections", []): + reflection_id = reflection.get("id") + if reflection_id and reflection_id not in seen_model_ids: + # Only include reflections that the agent declared as used (or all if none specified) + if used_reflection_ids_set and reflection_id not in used_reflection_ids_set: + continue # Skip reflections not actually used by the agent + seen_model_ids.add(reflection_id) + # Add to based_on as MemoryFact with type "mental-models" (reflections are synthesized knowledge) + reflection_name = reflection.get("name", "") + reflection_content = reflection.get("content", "") + based_on["mental-models"].append( + MemoryFact( + id=reflection_id, + text=f"{reflection_name}: {reflection_content}", + fact_type="mental-models", + context="reflection (user-curated)", + occurred_start=None, + occurred_end=None, ) ) # List all models lookup - don't add to based_on (too verbose, just a listing) - # Add directives to mental_models list (they are mental models with subtype='directive') + # Add directives to based_on["mental-models"] (they are mental models with subtype='directive') for directive in directives: # Extract summary from observations summary_parts: list[str] = [] @@ -3661,14 +3651,16 @@ class MemoryEngine(MemoryEngineInterface): if not summary_parts and directive.get("description"): summary_parts.append(directive["description"]) - mental_models_result.append( - MentalModelRef( + directive_name = directive.get("name", "") + directive_summary = "; ".join(summary_parts) if summary_parts else "" + based_on["mental-models"].append( + MemoryFact( id=directive.get("id", ""), - name=directive.get("name", ""), - type="directive", - subtype="directive", - description=directive.get("description", ""), - summary="; ".join(summary_parts) if summary_parts else None, + text=f"{directive_name}: {directive_summary}", + fact_type="mental-models", + context="directive (directive)", + occurred_start=None, + occurred_end=None, ) ) @@ -3688,7 +3680,6 @@ class MemoryEngine(MemoryEngineInterface): usage=None, # Token tracking not yet implemented for agentic loop tool_trace=tool_trace_result, llm_trace=llm_trace_result, - mental_models=mental_models_result, directives_applied=directives_applied_result, ) @@ -4078,955 +4069,6 @@ class MemoryEngine(MemoryEngineInterface): "observations": observations, } - # ========================================================================= - # Mental Models - # ========================================================================= - - async def list_mental_models( - self, - bank_id: str, - *, - subtype: str | None = None, - tags: list[str] | None = None, - tags_match: TagsMatch = "any", - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """List mental models for a bank, optionally filtered by subtype or tags. - - Args: - bank_id: Bank identifier - subtype: Filter by subtype (structural, emergent, pinned) - tags: Filter by tags - returns models that match according to tags_match - tags_match: How to match tags - "any" (OR), "all" (AND), or "exact" - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - query = f""" - SELECT id, bank_id, subtype, name, description, observations, - version, entity_id, links, tags, last_updated, created_at - FROM {fq_table("mental_models")} - WHERE bank_id = $1 - """ - params: list[Any] = [bank_id] - - if subtype: - query += f" AND subtype = ${len(params) + 1}" - params.append(subtype) - # Note: Directives are included in API listing for admin visibility. - # They are excluded from the reflect agent's tool_lookup (in tools.py) since they're in the system prompt. - - # Tags filtering: include untagged models OR models with matching tags - if tags: - if tags_match == "any": - # OR match: model has no tags OR model has at least one matching tag - query += f" AND (tags = '{{}}' OR tags && ${len(params) + 1})" - elif tags_match == "all": - # AND match: model has no tags OR model has all specified tags - query += f" AND (tags = '{{}}' OR tags @> ${len(params) + 1})" - elif tags_match == "any_strict": - # OR match, strict: model must have at least one matching tag (no untagged) - query += f" AND tags && ${len(params) + 1}" - elif tags_match == "all_strict": - # AND match, strict: model must have all specified tags (no untagged) - query += f" AND tags @> ${len(params) + 1}" - else: # exact - # Exact match: model has no tags OR model has exactly the specified tags - query += f" AND (tags = '{{}}' OR tags = ${len(params) + 1})" - params.append(tags) - - query += " ORDER BY created_at ASC" - - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch(query, *params) - - return [self._row_to_mental_model(row) for row in rows] - - async def get_mental_model( - self, - bank_id: str, - model_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """Get a mental model by ID.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - row = await conn.fetchrow( - f""" - SELECT id, bank_id, subtype, name, description, observations, - version, entity_id, links, tags, last_updated, created_at - FROM {fq_table("mental_models")} - WHERE bank_id = $1 AND id = $2 - """, - bank_id, - model_id, - ) - - return self._row_to_mental_model(row) if row else None - - async def refresh_mental_model( - self, - bank_id: str, - model_id: str, - *, - request_context: "RequestContext", - _return_agent_result: bool = False, - ) -> dict[str, Any] | tuple[dict[str, Any] | None, Any] | None: - """Refresh the observations for a mental model using the 4-phase reflect loop. - - The 4-phase loop: - 1. SEED: Get diverse memory sample, generate candidate observations - 2. EVIDENCE HUNT: For each candidate, search for supporting/contradicting evidence - 3. VALIDATE: Keep/discard/merge candidates based on evidence, extract quotes - 4. COMPARE: Merge new observations with existing mental model - - Uses the model's stored tags to filter recall results. - - Args: - bank_id: Bank identifier - model_id: Mental model ID - request_context: Request context for authentication - _return_agent_result: Internal flag to return (model, agent_result) tuple for logging - - Returns: - Updated mental model dict, or (model, agent_result) tuple if _return_agent_result=True - """ - await self._authenticate_tenant(request_context) - - # Validate operation if validator is configured - if self._operation_validator: - from hindsight_api.extensions.operation_validator import RefreshMentalModelContext - - ctx = RefreshMentalModelContext( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - await self._validate_operation(self._operation_validator.validate_refresh_mental_model(ctx)) - - pool = await self._get_pool() - start_time = time.time() - - # Get the mental model - model = await self.get_mental_model(bank_id, model_id, request_context=request_context) - if not model: - return None - - # Don't refresh directives - their observations are user-provided and static - if model.get("subtype") == "directive": - logger.info(f"[MENTAL_MODELS] Skipping refresh for directive '{model_id}' - observations are static") - if _return_agent_result: - return (model, None) - return model - - # Import refresh state functions and typed models - from .reflect.mental_model_reflect import ( - BankProfile, - DirectiveMentalModel, - check_needs_refresh, - compute_refresh_state, - ) - - # Check if refresh is actually needed by comparing state hashes - # Get current state inputs - total_memories = await self._count_memories_since(bank_id, None, pool) - bank_profile_dict = await self.get_bank_profile(bank_id, request_context=request_context) - directives_dicts = await self.list_mental_models(bank_id, subtype="directive", request_context=request_context) - - # Convert to typed models at the boundary - bank_profile = BankProfile.model_validate(bank_profile_dict) - directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts] - - # Get stored refresh_state from the model - stored_refresh_state = model.get("refresh_state") - - # Check if refresh is needed - refresh_check = check_needs_refresh( - stored_state=stored_refresh_state, - current_memories_count=total_memories, - bank_profile=bank_profile, - directives=directives, - ) - - if not refresh_check.needs_refresh: - logger.info(f"[MENTAL_MODELS] Skipping refresh for '{model_id}' - nothing changed since last refresh") - if _return_agent_result: - return (model, None) - return model - - logger.info( - f"[MENTAL_MODELS] Refresh needed for '{model_id}': {', '.join(refresh_check.reasons)} " - f"(memories: {total_memories})" - ) - - # Use the model's stored tags for filtering recall - model_tags = model.get("tags") or None - current_version = model.get("version", 0) - - # Import the 4-phase mental model reflect - from .reflect.mental_model_reflect import run_mental_model_reflect - from .reflect.tools import tool_recall - - metrics = get_metrics_collector() - - # Get existing observations (convert Observation models to dicts for the reflect loop) - from .reflect.observations import Observation - - raw_observations = model.get("observations", []) - existing_observations = [obs.model_dump() if isinstance(obs, Observation) else obs for obs in raw_observations] - - # Create callback for getting diverse memories - async def get_diverse_memories() -> list[dict]: - """Get a diverse sample of memories for seeding observations.""" - # Get recent memories (last 30 days) - recent_result = await tool_recall( - self, - bank_id, - "recent activity and events", - request_context, - max_tokens=4096, - tags=model_tags, - tags_match="any" if model_tags else None, - ) - recent_memories = recent_result.get("memories", []) - - # Get memories related to the mental model topic - topic_result = await tool_recall( - self, - bank_id, - model.get("name", "") + " " + model.get("description", ""), - request_context, - max_tokens=4096, - tags=model_tags, - tags_match="any" if model_tags else None, - ) - topic_memories = topic_result.get("memories", []) - - # Combine and deduplicate - seen_ids = set() - diverse_memories = [] - for mem in recent_memories + topic_memories: - mem_id = mem.get("id") - if mem_id and mem_id not in seen_ids: - seen_ids.add(mem_id) - diverse_memories.append(mem) - - return diverse_memories - - # Create callback for recall - async def recall_fn(query: str, max_tokens: int) -> dict: - return await tool_recall( - self, - bank_id, - query, - request_context, - max_tokens=max_tokens, - tags=model_tags, - tags_match="any" if model_tags else None, - ) - - with metrics.record_operation("mental_model_refresh_4phase", bank_id=bank_id, source="api"): - result = await run_mental_model_reflect( - llm_config=self._reflect_llm_config, - bank_id=bank_id, - mental_model_id=model_id, - mental_model_name=model.get("name", ""), - existing_observations=existing_observations, - current_version=current_version, - get_diverse_memories_fn=get_diverse_memories, - recall_fn=recall_fn, - topic=model.get("description"), - ) - - # Update the model with the new observations - import json - - # Convert observations to serializable format - observations_list = [ - { - "title": obs.title, - "content": obs.content, - "evidence": [ - { - "memory_id": ev.memory_id, - "quote": ev.quote, - "relevance": ev.relevance, - "timestamp": ev.timestamp.isoformat(), - } - for ev in obs.evidence - ], - "created_at": obs.created_at.isoformat(), - } - for obs in result.observations - ] - - # Compute refresh_state snapshot (using values fetched at start of refresh) - refresh_state = compute_refresh_state( - memories_count=total_memories, - bank_profile=bank_profile, - directives=directives, - ) - - observations_json = { - "observations": observations_list, - "version": result.version, - "last_refresh_at": refresh_state.last_refresh_at, - "refresh_state": refresh_state.model_dump(), - } - - async with acquire_with_retry(pool) as conn: - # Save the new version first - await self.save_mental_model_version( - conn, - bank_id, - model_id, - observations_list, - result.version, - ) - - # Update the mental model with new observations and version - updated_row = await conn.fetchrow( - f""" - UPDATE {fq_table("mental_models")} - SET observations = $1::jsonb, version = $2, last_updated = NOW() - WHERE bank_id = $3 AND id = $4 - RETURNING id, bank_id, subtype, name, description, observations, - version, entity_id, links, tags, last_updated, created_at - """, - json.dumps(observations_json), - result.version, - bank_id, - model_id, - ) - - model_result = self._row_to_mental_model(updated_row) if updated_row else None - - # Call post-operation hook if validator is configured - if self._operation_validator: - from hindsight_api.extensions.operation_validator import RefreshMentalModelResult - - duration_ms = int((time.time() - start_time) * 1000) - result_ctx = RefreshMentalModelResult( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - model_name=model.get("name"), - observations_count=len(result.observations), - input_tokens=result.input_tokens, - output_tokens=result.output_tokens, - total_tokens=result.total_tokens, - duration_ms=duration_ms, - success=True, - error=None, - ) - try: - await self._operation_validator.on_refresh_mental_model_complete(result_ctx) - except Exception as e: - logger.warning(f"Post-refresh-mental-model hook error (non-fatal): {e}") - - if _return_agent_result: - return (model_result, result) - return model_result - - async def refresh_mental_model_async( - self, - bank_id: str, - model_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Submit a background job to refresh a specific mental model. - - This is useful for: - - Refreshing content for newly created learned models - - Refreshing content for pinned models after description changes - - Manual refresh of a specific model without touching others - - Args: - bank_id: Bank identifier - model_id: Mental model ID to refresh - - Returns: - Dict with operation_id to track progress - """ - await self._authenticate_tenant(request_context) - - # Verify the model exists - model = await self.get_mental_model(bank_id, model_id, request_context=request_context) - if not model: - raise ValueError(f"Mental model '{model_id}' not found in bank '{bank_id}'") - - pool = await self._get_pool() - - import json - - operation_id = uuid.uuid4() - - # Insert operation record into database - async with acquire_with_retry(pool) as conn: - await conn.execute( - f""" - INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata) - VALUES ($1, $2, $3, $4) - """, - operation_id, - bank_id, - "refresh_mental_model", - json.dumps({"model_id": model_id}), - ) - - # Submit task to background queue - task_payload = { - "type": "refresh_mental_model", - "operation_id": str(operation_id), - "bank_id": bank_id, - "model_id": model_id, - } - - await self._task_backend.submit_task(task_payload) - - logger.info( - f"[MENTAL_MODEL] Refresh task queued for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" - ) - - return { - "operation_id": str(operation_id), - "model_id": model_id, - "status": "queued", - } - - async def refresh_mental_models( - self, - bank_id: str, - *, - tags: list[str] | None = None, - subtype: str | None = None, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Submit a background job to refresh mental models for a bank. - - The background job will (depending on subtype filter): - 1. Derive structural models from the bank's mission (if subtype is None or "structural") - 2. Detect emergent candidates (entities worth promoting) (if subtype is None or "emergent") - 3. Filter candidates by mission relevance - 4. Create/update mental models with specified tags - 5. Generate summaries for refreshed mental models - - Args: - bank_id: Bank identifier - tags: Tags to apply to newly created mental models - subtype: Only refresh models of this subtype ("structural" or "emergent"). - If None, refreshes all subtypes. - - Raises: - ValueError: If no mission is set for the bank - - Returns: - Dict with operation_id to track progress - """ - await self._authenticate_tenant(request_context) - - # Check that mission is set before scheduling the task - profile = await self.get_bank_profile(bank_id, request_context=request_context) - mission = profile.get("mission") or "" - if not mission: - raise ValueError( - f"Cannot refresh mental models: no mission is set for bank '{bank_id}'. Set a mission first." - ) - - pool = await self._get_pool() - - import json - - operation_id = uuid.uuid4() - - # Insert operation record into database - async with acquire_with_retry(pool) as conn: - await conn.execute( - f""" - INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata) - VALUES ($1, $2, $3, $4) - """, - operation_id, - bank_id, - "refresh_mental_models", - json.dumps({}), - ) - - # Submit task to background queue - task_payload = { - "type": "refresh_mental_models", - "operation_id": str(operation_id), - "bank_id": bank_id, - } - if tags: - task_payload["tags"] = tags - if subtype: - task_payload["subtype"] = subtype - - await self._task_backend.submit_task(task_payload) - - logger.info(f"[MENTAL_MODELS] Refresh task queued for bank_id={bank_id}, operation_id={operation_id}") - - return { - "operation_id": str(operation_id), - "status": "queued", - } - - async def _derive_structural_models_internal( - self, - bank_id: str, - mission: str, - pool, - existing_models: list[dict[str, Any]] | None = None, - tags: list[str] | None = None, - ) -> list[str]: - """ - Internal method to derive structural models without auth check. - - Args: - bank_id: Bank identifier - mission: The bank's mission - pool: Database connection pool - existing_models: Optional list of existing structural models - tags: Tags to apply to created mental models - - Returns: - List of model IDs to remove (existing models not in LLM output) - """ - from .mental_models.models import MentalModelSubtype - from .mental_models.structural import derive_structural_models - - templates, models_to_remove = await derive_structural_models( - self._llm_config, mission, existing_models=existing_models - ) - - model_tags = tags or [] - created_count = 0 - async with acquire_with_retry(pool) as conn: - for template in templates: - try: - await conn.fetchrow( - f""" - INSERT INTO {fq_table("mental_models")} - (id, bank_id, subtype, name, description, tags) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (id, bank_id) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - tags = EXCLUDED.tags - RETURNING id - """, - template.id, - bank_id, - MentalModelSubtype.STRUCTURAL.value, - template.name, - template.description, - model_tags, - ) - created_count += 1 - except Exception as e: - logger.warning(f"[MENTAL_MODELS] Failed to create structural model {template.id}: {e}") - - logger.info(f"[MENTAL_MODELS] Created/updated {created_count} structural models for bank {bank_id}") - return models_to_remove - - async def _promote_entity_internal( - self, bank_id: str, entity_id: str, pool, tags: list[str] | None = None - ) -> dict[str, Any] | None: - """Internal method to promote entity to mental model without auth check. - - Args: - bank_id: Bank identifier - entity_id: Entity ID to promote - pool: Database connection pool - tags: Tags to apply to the created mental model - """ - from .mental_models.models import MentalModelSubtype - - async with acquire_with_retry(pool) as conn: - # Get entity info - entity = await conn.fetchrow( - f"SELECT id, canonical_name FROM {fq_table('entities')} WHERE id = $1 AND bank_id = $2", - uuid.UUID(entity_id), - bank_id, - ) - - if not entity: - return None - - # Create mental model from entity - model_id = f"entity-{entity['canonical_name'].lower().replace(' ', '-')}" - row = await conn.fetchrow( - f""" - INSERT INTO {fq_table("mental_models")} - (id, bank_id, subtype, name, description, entity_id, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (id, bank_id) DO NOTHING - RETURNING id, bank_id, subtype, name, description, observations, - entity_id, links, tags, last_updated, created_at - """, - model_id, - bank_id, - MentalModelSubtype.EMERGENT.value, - entity["canonical_name"], - f"Mental model for {entity['canonical_name']}", - entity["id"], - tags or [], # Apply tags from refresh operation - ) - - return self._row_to_mental_model(row) if row else None - - async def create_mental_model( - self, - bank_id: str, - name: str, - description: str, - *, - subtype: str = "pinned", - observations: list[dict[str, Any]] | None = None, - tags: list[str] | None = None, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Create a mental model. - - Supports two subtypes: - - 'pinned': User-defined topic, observations are LLM-generated on refresh - - 'directive': User-defined hard rules, observations are provided at creation - - For directives, observations must be provided and will NOT be regenerated. - For pinned models, observations are generated by the reflect agent on refresh. - - Args: - bank_id: Bank identifier - name: Human-readable name for the mental model - description: One-liner description for quick scanning - subtype: 'pinned' (default) or 'directive' - observations: For directives, list of {title, text} dicts. Ignored for pinned. - tags: Tags for scoped visibility - - Returns: - The created mental model - """ - import json - - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - from .mental_models.models import MentalModelSubtype - - # Validate subtype - if subtype not in ("pinned", "directive"): - raise ValueError(f"Invalid subtype '{subtype}'. Must be 'pinned' or 'directive'.") - - # For directives, observations must be provided - if subtype == "directive": - if not observations: - raise ValueError("Directives require observations to be provided") - subtype_enum = MentalModelSubtype.DIRECTIVE - model_id = f"directive-{name.lower().replace(' ', '-').replace('/', '-')}" - # Format observations for storage - observations_json = json.dumps({"observations": observations}) - else: - subtype_enum = MentalModelSubtype.PINNED - model_id = f"pinned-{name.lower().replace(' ', '-').replace('/', '-')}" - observations_json = None - - async with acquire_with_retry(pool) as conn: - # Check if model already exists - existing = await conn.fetchrow( - f"SELECT id FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2", - bank_id, - model_id, - ) - if existing: - raise ValueError(f"Mental model with name '{name}' already exists") - - row = await conn.fetchrow( - f""" - INSERT INTO {fq_table("mental_models")} - (id, bank_id, subtype, name, description, observations, tags, last_updated) - VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8) - RETURNING id, bank_id, subtype, name, description, observations, - entity_id, links, tags, last_updated, created_at - """, - model_id, - bank_id, - subtype_enum.value, - name, - description, - observations_json, - tags or [], - datetime.now(UTC) if subtype == "directive" else None, - ) - - logger.info(f"[MENTAL_MODELS] Created {subtype} mental model '{name}' (id={model_id}) for bank {bank_id}") - return self._row_to_mental_model(row) - - async def delete_mental_model( - self, - bank_id: str, - model_id: str, - *, - request_context: "RequestContext", - ) -> bool: - """Delete a mental model.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - result = await conn.execute( - f"DELETE FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2", - bank_id, - model_id, - ) - - return result == "DELETE 1" - - async def update_mental_model( - self, - bank_id: str, - model_id: str, - *, - name: str | None = None, - description: str | None = None, - request_context: "RequestContext", - ) -> dict | None: - """Update a mental model's name and/or description. - - Returns the updated mental model dict, or None if not found. - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - # Build dynamic update query - updates = [] - params = [bank_id, model_id] - param_idx = 3 - - if name is not None: - updates.append(f"name = ${param_idx}") - params.append(name) - param_idx += 1 - - if description is not None: - updates.append(f"description = ${param_idx}") - params.append(description) - param_idx += 1 - - if not updates: - return None - - query = f""" - UPDATE {fq_table("mental_models")} - SET {", ".join(updates)} - WHERE bank_id = $1 AND id = $2 - RETURNING id, bank_id, subtype, name, description, observations, version, entity_id, links, tags, last_updated, created_at - """ - - row = await conn.fetchrow(query, *params) - - if not row: - return None - - return self._row_to_mental_model(row) - - async def save_mental_model_version( - self, - conn, - bank_id: str, - model_id: str, - observations: list[dict], - new_version: int, - ) -> None: - """Save a new version of mental model observations. - - Args: - conn: Database connection - bank_id: Bank identifier - model_id: Mental model ID - observations: List of observation dicts - new_version: Version number to save - """ - import json - - from ..config import get_config - - config = get_config() - max_versions = getattr(config, "mental_model_max_versions", 10) - - # Save the new version - await conn.execute( - f""" - INSERT INTO {fq_table("mental_model_versions")} - (mental_model_id, bank_id, version, observations) - VALUES ($1, $2, $3, $4::jsonb) - ON CONFLICT (mental_model_id, bank_id, version) DO UPDATE - SET observations = EXCLUDED.observations, created_at = NOW() - """, - model_id, - bank_id, - new_version, - json.dumps({"observations": observations}), - ) - - # Clean up old versions (keep only max_versions) - await conn.execute( - f""" - DELETE FROM {fq_table("mental_model_versions")} - WHERE mental_model_id = $1 AND bank_id = $2 AND version <= $3::int - $4::int - """, - model_id, - bank_id, - new_version, - max_versions, - ) - - async def get_mental_model_versions( - self, - bank_id: str, - model_id: str, - *, - request_context: "RequestContext", - ) -> list[dict]: - """List version history for a mental model. - - Args: - bank_id: Bank identifier - model_id: Mental model ID - - Returns: - List of version summaries sorted by version descending - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT version, created_at, - jsonb_array_length(observations->'observations') as observation_count - FROM {fq_table("mental_model_versions")} - WHERE mental_model_id = $1 AND bank_id = $2 - ORDER BY version DESC - """, - model_id, - bank_id, - ) - - return [ - { - "version": row["version"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "observation_count": row["observation_count"] or 0, - } - for row in rows - ] - - async def get_mental_model_version( - self, - bank_id: str, - model_id: str, - version: int, - *, - request_context: "RequestContext", - ) -> dict | None: - """Get a specific version of mental model observations. - - Args: - bank_id: Bank identifier - model_id: Mental model ID - version: Version number to retrieve - - Returns: - Version data with observations, or None if not found - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - row = await conn.fetchrow( - f""" - SELECT version, observations, created_at - FROM {fq_table("mental_model_versions")} - WHERE mental_model_id = $1 AND bank_id = $2 AND version = $3 - """, - model_id, - bank_id, - version, - ) - - if not row: - return None - - import json - - observations_data = row["observations"] - if isinstance(observations_data, str): - observations_data = json.loads(observations_data) - - observations = observations_data.get("observations", []) if isinstance(observations_data, dict) else [] - - return { - "version": row["version"], - "observations": self._parse_observations(observations), - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - } - - def _row_to_mental_model(self, row) -> dict[str, Any]: - """Convert a database row to a mental model dict.""" - import json - - # Parse observations JSON - can be a dict {"observations": [...]} or a list [] - observations_data = row.get("observations") - last_refresh_at = None - refresh_state = None - if observations_data is None: - observations_raw = [] - elif isinstance(observations_data, str): - observations_data = json.loads(observations_data) - observations_raw = ( - observations_data.get("observations", []) if isinstance(observations_data, dict) else observations_data - ) - if isinstance(observations_data, dict): - last_refresh_at = observations_data.get("last_refresh_at") - refresh_state = observations_data.get("refresh_state") - elif isinstance(observations_data, list): - observations_raw = observations_data - elif isinstance(observations_data, dict): - observations_raw = observations_data.get("observations", []) - last_refresh_at = observations_data.get("last_refresh_at") - refresh_state = observations_data.get("refresh_state") - else: - observations_raw = [] - - # Parse observations into typed models - observations = self._parse_observations(observations_raw) - - return { - "id": row["id"], - "bank_id": row["bank_id"], - "subtype": row["subtype"], - "name": row["name"], - "description": row["description"], - "observations": observations, - "version": row.get("version", 0), - "entity_id": str(row["entity_id"]) if row["entity_id"] else None, - "links": row["links"] or [], - "tags": list(row["tags"]) if row.get("tags") else [], - "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None, - "last_refresh_at": last_refresh_at, - "refresh_state": refresh_state, - "created_at": row["created_at"].isoformat(), - } - def _parse_observations(self, observations_raw: list): """Parse raw observation dicts into typed Observation models. @@ -5115,10 +4157,10 @@ class MemoryEngine(MemoryEngineInterface): fact_ids: list[str], ) -> int: """ - Remove fact IDs from mental model observations when memories are deleted. + Remove fact IDs from mental model source_memory_ids when memories are deleted. - Uses JSONB path operations to find and update mental models that reference - the deleted fact IDs in their observations. + Mental models are now stored in memory_units with fact_type='mental_model' + and have a source_memory_ids column (UUID[]) tracking their source memories. Args: conn: Database connection @@ -5131,45 +4173,29 @@ class MemoryEngine(MemoryEngineInterface): if not fact_ids: return 0 - # Convert fact_ids to a jsonb array for efficient comparison - import json + # Convert string IDs to UUIDs for the array comparison + import uuid as uuid_module - fact_ids_json = json.dumps(fact_ids) + fact_uuids = [uuid_module.UUID(fid) for fid in fact_ids] - # Update mental models by removing the deleted fact IDs from all observations - # This uses jsonb_set to update each observation's fact_ids array + # Update mental models (memory_units with fact_type='mental_model') + # by removing the deleted fact IDs from source_memory_ids + # Use array subtraction: source_memory_ids - deleted_ids result = await conn.execute( f""" - UPDATE {fq_table("mental_models")} - SET observations = jsonb_set( - observations, - '{{observations}}', - ( - SELECT COALESCE(jsonb_agg( - jsonb_set( - observation, - '{{fact_ids}}', - ( - SELECT COALESCE(jsonb_agg(fid), '[]'::jsonb) - FROM jsonb_array_elements_text(observation->'fact_ids') AS fid - WHERE NOT (fid::text = ANY($2::text[])) - ) - ) - ), '[]'::jsonb) - FROM jsonb_array_elements(observations->'observations') AS observation - ) + UPDATE {fq_table("memory_units")} + SET source_memory_ids = ( + SELECT COALESCE(array_agg(elem), ARRAY[]::uuid[]) + FROM unnest(source_memory_ids) AS elem + WHERE elem != ALL($2::uuid[]) ), - last_updated = NOW() + updated_at = NOW() WHERE bank_id = $1 - AND EXISTS ( - SELECT 1 - FROM jsonb_array_elements(observations->'observations') AS observation, - jsonb_array_elements_text(observation->'fact_ids') AS fid - WHERE fid::text = ANY($2::text[]) - ) + AND fact_type = 'mental_model' + AND source_memory_ids && $2::uuid[] """, bank_id, - fact_ids, + fact_uuids, ) # Parse the result to get number of updated rows @@ -5180,34 +4206,1113 @@ class MemoryEngine(MemoryEngineInterface): ) return updated_count - async def list_operations( + # ========================================================================= + # LEARNINGS CRUD + # ========================================================================= + + async def list_learnings( self, bank_id: str, *, + tags: list[str] | None = None, + tags_match: str = "any", + limit: int = 100, + offset: int = 0, request_context: "RequestContext", ) -> list[dict[str, Any]]: - """List async operations for a bank.""" + """List learnings for a bank. + + Args: + bank_id: Bank identifier + tags: Optional tags to filter by + tags_match: How to match tags - 'any', 'all', or 'exact' + limit: Maximum number of results + offset: Offset for pagination + request_context: Request context for authentication + + Returns: + List of learning dicts + """ await self._authenticate_tenant(request_context) pool = await self._get_pool() async with acquire_with_retry(pool) as conn: - # Get total count - total_row = await conn.fetchrow( - f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE bank_id = $1", + # Build tag filter + tag_filter = "" + params: list[Any] = [bank_id, limit, offset] + if tags: + if tags_match == "all": + tag_filter = " AND tags @> $4::varchar[]" + elif tags_match == "exact": + tag_filter = " AND tags = $4::varchar[]" + else: # any + tag_filter = " AND tags && $4::varchar[]" + params.append(tags) + + rows = await conn.fetch( + f""" + SELECT id, bank_id, text, proof_count, history, mission_context, + pre_mission_change, tags, created_at, updated_at + FROM {fq_table("learnings")} + WHERE bank_id = $1 {tag_filter} + ORDER BY proof_count DESC, updated_at DESC + LIMIT $2 OFFSET $3 + """, + *params, + ) + + return [self._row_to_learning(row) for row in rows] + + async def get_learning( + self, + bank_id: str, + learning_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Get a single learning by ID. + + Args: + bank_id: Bank identifier + learning_id: Learning UUID + request_context: Request context for authentication + + Returns: + Learning dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + SELECT id, bank_id, text, proof_count, history, mission_context, + pre_mission_change, tags, created_at, updated_at + FROM {fq_table("learnings")} + WHERE bank_id = $1 AND id = $2 + """, bank_id, + learning_id, + ) + + return self._row_to_learning(row) if row else None + + async def create_learning( + self, + bank_id: str, + text: str, + *, + proof_count: int = 1, + tags: list[str] | None = None, + mission_context: str | None = None, + request_context: "RequestContext", + ) -> dict[str, Any]: + """Create a new learning. + + Args: + bank_id: Bank identifier + text: The learning text + proof_count: Initial proof count (default 1) + tags: Optional tags for scoped visibility + mission_context: Hash of mission when created + request_context: Request context for authentication + + Returns: + The created learning dict + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + # Generate embedding for the learning text + embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text]) + # Convert embedding to string for asyncpg vector type + embedding_str = str(embedding[0]) if embedding else None + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + INSERT INTO {fq_table("learnings")} + (bank_id, text, proof_count, mission_context, embedding, tags) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, bank_id, text, proof_count, history, mission_context, + pre_mission_change, tags, created_at, updated_at + """, + bank_id, + text, + proof_count, + mission_context, + embedding_str, + tags or [], + ) + + logger.info(f"[LEARNINGS] Created learning for bank {bank_id}: {text[:50]}...") + return self._row_to_learning(row) + + async def update_learning( + self, + bank_id: str, + learning_id: str, + *, + text: str | None = None, + increment_proof: bool = False, + add_history: dict[str, Any] | None = None, + mark_pre_mission_change: bool = False, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Update a learning. + + Args: + bank_id: Bank identifier + learning_id: Learning UUID + text: New text (if changing) + increment_proof: Whether to increment proof_count + add_history: History entry to append (for contradictions) + mark_pre_mission_change: Whether to mark as pre-mission-change + request_context: Request context for authentication + + Returns: + Updated learning dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build dynamic update + updates = ["updated_at = NOW()"] + params: list[Any] = [bank_id, learning_id] + param_idx = 3 + + if text is not None: + updates.append(f"text = ${param_idx}") + params.append(text) + param_idx += 1 + # Also update embedding (convert to string for asyncpg vector type) + embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text]) + if embedding: + updates.append(f"embedding = ${param_idx}") + params.append(str(embedding[0])) + param_idx += 1 + + if increment_proof: + updates.append("proof_count = proof_count + 1") + + if add_history: + import json + + updates.append(f"history = history || ${param_idx}::jsonb") + params.append(json.dumps([add_history])) + param_idx += 1 + + if mark_pre_mission_change: + updates.append("pre_mission_change = TRUE") + + query = f""" + UPDATE {fq_table("learnings")} + SET {", ".join(updates)} + WHERE bank_id = $1 AND id = $2 + RETURNING id, bank_id, text, proof_count, history, mission_context, + pre_mission_change, tags, created_at, updated_at + """ + + row = await conn.fetchrow(query, *params) + + return self._row_to_learning(row) if row else None + + async def delete_learning( + self, + bank_id: str, + learning_id: str, + *, + request_context: "RequestContext", + ) -> bool: + """Delete a learning. + + Args: + bank_id: Bank identifier + learning_id: Learning UUID + request_context: Request context for authentication + + Returns: + True if deleted, False if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + result = await conn.execute( + f"DELETE FROM {fq_table('learnings')} WHERE bank_id = $1 AND id = $2", + bank_id, + learning_id, + ) + + return result == "DELETE 1" + + def _row_to_learning(self, row) -> dict[str, Any]: + """Convert a database row to a learning dict.""" + import json + + # Parse history - asyncpg may return JSONB as string in some cases + history = row["history"] + if isinstance(history, str): + history = json.loads(history) + elif history is None: + history = [] + + return { + "id": str(row["id"]), + "bank_id": row["bank_id"], + "text": row["text"], + "proof_count": row["proof_count"], + "history": history, + "mission_context": row["mission_context"], + "pre_mission_change": row["pre_mission_change"], + "tags": row["tags"] or [], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } + + async def mark_learnings_pre_mission_change( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> int: + """Mark all learnings as pre-mission-change when mission changes. + + Args: + bank_id: Bank identifier + request_context: Request context for authentication + + Returns: + Number of learnings marked + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + result = await conn.execute( + f""" + UPDATE {fq_table("learnings")} + SET pre_mission_change = TRUE, updated_at = NOW() + WHERE bank_id = $1 AND pre_mission_change = FALSE + """, + bank_id, + ) + + # Also update bank's mission_changed_at + await conn.execute( + f""" + UPDATE {fq_table("banks")} + SET mission_changed_at = NOW() + WHERE bank_id = $1 + """, + bank_id, + ) + + count = int(result.split()[-1]) if result and "UPDATE" in result else 0 + if count > 0: + logger.info(f"[LEARNINGS] Marked {count} learnings as pre-mission-change for bank {bank_id}") + return count + + # ========================================================================= + # MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models + # ========================================================================= + + async def list_mental_models_consolidated( + self, + bank_id: str, + *, + tags: list[str] | None = None, + tags_match: str = "any", + limit: int = 100, + offset: int = 0, + request_context: "RequestContext", + ) -> list[dict[str, Any]]: + """List auto-consolidated mental models for a bank. + + Mental models are stored in memory_units with fact_type='mental_model'. + They are automatically created and updated by the consolidation engine. + + Args: + bank_id: Bank identifier + tags: Optional tags to filter by + tags_match: How to match tags - 'any', 'all', or 'exact' + limit: Maximum number of results + offset: Offset for pagination + request_context: Request context for authentication + + Returns: + List of mental model dicts + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build tag filter + tag_filter = "" + params: list[Any] = [bank_id, limit, offset] + if tags: + if tags_match == "all": + tag_filter = " AND tags @> $4::varchar[]" + elif tags_match == "exact": + tag_filter = " AND tags = $4::varchar[]" + else: # any + tag_filter = " AND tags && $4::varchar[]" + params.append(tags) + + rows = await conn.fetch( + f""" + SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND fact_type = 'mental_model' {tag_filter} + ORDER BY updated_at DESC NULLS LAST + LIMIT $2 OFFSET $3 + """, + *params, + ) + + return [self._row_to_mental_model_consolidated(row) for row in rows] + + async def get_mental_model_consolidated( + self, + bank_id: str, + model_id: str, + *, + include_source_memories: bool = True, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Get a single mental model by ID. + + Args: + bank_id: Bank identifier + model_id: Mental model ID + include_source_memories: Whether to include full source memory details + request_context: Request context for authentication + + Returns: + Mental model dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND id = $2 AND fact_type = 'mental_model' + """, + bank_id, + model_id, + ) + + if not row: + return None + + result = self._row_to_mental_model_consolidated(row) + + # Fetch source memories if requested and source_memory_ids exist + if include_source_memories and result.get("source_memory_ids"): + source_ids = [uuid.UUID(sid) if isinstance(sid, str) else sid for sid in result["source_memory_ids"]] + source_rows = await conn.fetch( + f""" + SELECT id, text, fact_type, context, occurred_start, mentioned_at + FROM {fq_table("memory_units")} + WHERE id = ANY($1::uuid[]) + ORDER BY mentioned_at DESC NULLS LAST + """, + source_ids, + ) + result["source_memories"] = [ + { + "id": str(r["id"]), + "text": r["text"], + "type": r["fact_type"], + "context": r["context"], + "occurred_start": r["occurred_start"].isoformat() if r["occurred_start"] else None, + "mentioned_at": r["mentioned_at"].isoformat() if r["mentioned_at"] else None, + } + for r in source_rows + ] + + return result + + def _row_to_mental_model_consolidated(self, row: Any) -> dict[str, Any]: + """Convert a database row to a mental model dict.""" + import json + + history = row["history"] + if isinstance(history, str): + history = json.loads(history) + elif history is None: + history = [] + + # Convert source_memory_ids to strings + source_memory_ids = row.get("source_memory_ids") or [] + source_memory_ids = [str(sid) for sid in source_memory_ids] + + return { + "id": str(row["id"]), + "bank_id": row["bank_id"], + "text": row["text"], + "proof_count": row["proof_count"] or 1, + "history": history, + "tags": row["tags"] or [], + "source_memory_ids": source_memory_ids, + "source_memories": [], # Populated separately when fetching full details + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } + + # ========================================================================= + # REFLECTIONS CRUD + # ========================================================================= + + async def list_reflections( + self, + bank_id: str, + *, + tags: list[str] | None = None, + tags_match: str = "any", + limit: int = 100, + offset: int = 0, + request_context: "RequestContext", + ) -> list[dict[str, Any]]: + """List pinned reflections for a bank. + + Args: + bank_id: Bank identifier + tags: Optional tags to filter by + tags_match: How to match tags - 'any', 'all', or 'exact' + limit: Maximum number of results + offset: Offset for pagination + request_context: Request context for authentication + + Returns: + List of pinned reflection dicts + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build tag filter + tag_filter = "" + params: list[Any] = [bank_id, limit, offset] + if tags: + if tags_match == "all": + tag_filter = " AND tags @> $4::varchar[]" + elif tags_match == "exact": + tag_filter = " AND tags = $4::varchar[]" + else: # any + tag_filter = " AND tags && $4::varchar[]" + params.append(tags) + + rows = await conn.fetch( + f""" + SELECT id, bank_id, name, source_query, content, tags, + last_refreshed_at, created_at, reflect_response + FROM {fq_table("reflections")} + WHERE bank_id = $1 {tag_filter} + ORDER BY last_refreshed_at DESC + LIMIT $2 OFFSET $3 + """, + *params, + ) + + return [self._row_to_reflection(row) for row in rows] + + async def get_reflection( + self, + bank_id: str, + reflection_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Get a single pinned reflection by ID. + + Args: + bank_id: Bank identifier + reflection_id: Pinned reflection UUID + request_context: Request context for authentication + + Returns: + Pinned reflection dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + SELECT id, bank_id, name, source_query, content, tags, + last_refreshed_at, created_at, reflect_response + FROM {fq_table("reflections")} + WHERE bank_id = $1 AND id = $2 + """, + bank_id, + reflection_id, + ) + + return self._row_to_reflection(row) if row else None + + async def create_reflection( + self, + bank_id: str, + name: str, + source_query: str, + content: str, + *, + tags: list[str] | None = None, + request_context: "RequestContext", + ) -> dict[str, Any]: + """Create a new pinned reflection. + + Args: + bank_id: Bank identifier + name: Human-readable name for the reflection + source_query: The query that generated this reflection + content: The synthesized content + tags: Optional tags for scoped visibility + request_context: Request context for authentication + + Returns: + The created pinned reflection dict + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + # Generate embedding for the content + embedding_text = f"{name} {content}" + embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text]) + # Convert embedding to string for asyncpg vector type + embedding_str = str(embedding[0]) if embedding else None + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + INSERT INTO {fq_table("reflections")} + (bank_id, name, source_query, content, embedding, tags) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, bank_id, name, source_query, content, tags, + last_refreshed_at, created_at + """, + bank_id, + name, + source_query, + content, + embedding_str, + tags or [], + ) + + logger.info(f"[REFLECTIONS] Created pinned reflection '{name}' for bank {bank_id}") + return self._row_to_reflection(row) + + async def refresh_reflection( + self, + bank_id: str, + reflection_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Refresh a pinned reflection by re-running its source query. + + This method: + 1. Gets the pinned reflection + 2. Runs the source_query through reflect + 3. Updates the content with the new synthesis + 4. Updates last_refreshed_at + + Args: + bank_id: Bank identifier + reflection_id: Pinned reflection UUID + request_context: Request context for authentication + + Returns: + Updated pinned reflection dict or None if not found + """ + await self._authenticate_tenant(request_context) + + # Get the current reflection + reflection = await self.get_reflection(bank_id, reflection_id, request_context=request_context) + if not reflection: + return None + + # Run reflect with the source query, excluding the reflection being refreshed + reflect_result = await self.reflect_async( + bank_id=bank_id, + query=reflection["source_query"], + request_context=request_context, + exclude_reflection_ids=[reflection_id], + ) + + # Build reflect_response payload to store + reflect_response_payload = { + "text": reflect_result.text, + "based_on": { + fact_type: [ + { + "id": str(fact.id), + "text": fact.text, + "type": fact_type, + } + for fact in facts + ] + for fact_type, facts in reflect_result.based_on.items() + }, + "mental_models": [], # Mental models are included in based_on["mental-models"] + } + + # Update the reflection with new content and reflect_response + return await self.update_reflection( + bank_id, + reflection_id, + content=reflect_result.text, + reflect_response=reflect_response_payload, + request_context=request_context, + ) + + async def update_reflection( + self, + bank_id: str, + reflection_id: str, + *, + name: str | None = None, + content: str | None = None, + reflect_response: dict[str, Any] | None = None, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Update a pinned reflection. + + Args: + bank_id: Bank identifier + reflection_id: Pinned reflection UUID + name: New name (if changing) + content: New content (if changing) + reflect_response: Full reflect API response payload (if changing) + request_context: Request context for authentication + + Returns: + Updated pinned reflection dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build dynamic update + updates = [] + params: list[Any] = [bank_id, reflection_id] + param_idx = 3 + + if name is not None: + updates.append(f"name = ${param_idx}") + params.append(name) + param_idx += 1 + + if content is not None: + updates.append(f"content = ${param_idx}") + params.append(content) + param_idx += 1 + updates.append("last_refreshed_at = NOW()") + # Also update embedding (convert to string for asyncpg vector type) + embedding_text = f"{name or ''} {content}" + embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text]) + if embedding: + updates.append(f"embedding = ${param_idx}") + params.append(str(embedding[0])) + param_idx += 1 + + if reflect_response is not None: + updates.append(f"reflect_response = ${param_idx}") + params.append(json.dumps(reflect_response)) + param_idx += 1 + + if not updates: + return None + + query = f""" + UPDATE {fq_table("reflections")} + SET {", ".join(updates)} + WHERE bank_id = $1 AND id = $2 + RETURNING id, bank_id, name, source_query, content, tags, + last_refreshed_at, created_at, reflect_response + """ + + row = await conn.fetchrow(query, *params) + + return self._row_to_reflection(row) if row else None + + async def delete_reflection( + self, + bank_id: str, + reflection_id: str, + *, + request_context: "RequestContext", + ) -> bool: + """Delete a pinned reflection. + + Args: + bank_id: Bank identifier + reflection_id: Pinned reflection UUID + request_context: Request context for authentication + + Returns: + True if deleted, False if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + result = await conn.execute( + f"DELETE FROM {fq_table('reflections')} WHERE bank_id = $1 AND id = $2", + bank_id, + reflection_id, + ) + + return result == "DELETE 1" + + def _row_to_reflection(self, row) -> dict[str, Any]: + """Convert a database row to a reflection dict.""" + reflect_response = row.get("reflect_response") + # Parse JSON string to dict if needed (asyncpg may return JSONB as string) + if isinstance(reflect_response, str): + try: + reflect_response = json.loads(reflect_response) + except json.JSONDecodeError: + reflect_response = None + return { + "id": str(row["id"]), + "bank_id": row["bank_id"], + "name": row["name"], + "source_query": row["source_query"], + "content": row["content"], + "tags": row["tags"] or [], + "last_refreshed_at": row["last_refreshed_at"].isoformat() if row["last_refreshed_at"] else None, + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "reflect_response": reflect_response, + } + + # ========================================================================= + # Directives - Hard rules injected into prompts + # ========================================================================= + + async def list_directives( + self, + bank_id: str, + *, + tags: list[str] | None = None, + tags_match: str = "any", + active_only: bool = True, + limit: int = 100, + offset: int = 0, + request_context: "RequestContext", + ) -> list[dict[str, Any]]: + """List directives for a bank. + + Args: + bank_id: Bank identifier + tags: Optional tags to filter by + tags_match: How to match tags - 'any', 'all', or 'exact' + active_only: Only return active directives (default True) + limit: Maximum number of results + offset: Offset for pagination + request_context: Request context for authentication + + Returns: + List of directive dicts + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build filters + filters = ["bank_id = $1"] + params: list[Any] = [bank_id] + param_idx = 2 + + if active_only: + filters.append("is_active = TRUE") + + if tags: + if tags_match == "all": + filters.append(f"tags @> ${param_idx}::varchar[]") + elif tags_match == "exact": + filters.append(f"tags = ${param_idx}::varchar[]") + else: # any + filters.append(f"tags && ${param_idx}::varchar[]") + params.append(tags) + param_idx += 1 + + params.extend([limit, offset]) + + rows = await conn.fetch( + f""" + SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at + FROM {fq_table("directives")} + WHERE {" AND ".join(filters)} + ORDER BY priority DESC, created_at DESC + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """, + *params, + ) + + return [self._row_to_directive(row) for row in rows] + + async def get_directive( + self, + bank_id: str, + directive_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Get a single directive by ID. + + Args: + bank_id: Bank identifier + directive_id: Directive UUID + request_context: Request context for authentication + + Returns: + Directive dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at + FROM {fq_table("directives")} + WHERE bank_id = $1 AND id = $2 + """, + bank_id, + directive_id, + ) + + return self._row_to_directive(row) if row else None + + async def create_directive( + self, + bank_id: str, + name: str, + content: str, + *, + priority: int = 0, + is_active: bool = True, + tags: list[str] | None = None, + request_context: "RequestContext", + ) -> dict[str, Any]: + """Create a new directive. + + Args: + bank_id: Bank identifier + name: Human-readable name for the directive + content: The directive text to inject into prompts + priority: Higher priority directives are injected first (default 0) + is_active: Whether this directive is active (default True) + tags: Optional tags for filtering + request_context: Request context for authentication + + Returns: + The created directive dict + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + INSERT INTO {fq_table("directives")} + (bank_id, name, content, priority, is_active, tags) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at + """, + bank_id, + name, + content, + priority, + is_active, + tags or [], + ) + + logger.info(f"[DIRECTIVES] Created directive '{name}' for bank {bank_id}") + return self._row_to_directive(row) + + async def update_directive( + self, + bank_id: str, + directive_id: str, + *, + name: str | None = None, + content: str | None = None, + priority: int | None = None, + is_active: bool | None = None, + tags: list[str] | None = None, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Update a directive. + + Args: + bank_id: Bank identifier + directive_id: Directive UUID + name: New name (optional) + content: New content (optional) + priority: New priority (optional) + is_active: New active status (optional) + tags: New tags (optional) + request_context: Request context for authentication + + Returns: + Updated directive dict or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + # Build update query dynamically + updates = ["updated_at = now()"] + params: list[Any] = [] + param_idx = 1 + + if name is not None: + updates.append(f"name = ${param_idx}") + params.append(name) + param_idx += 1 + + if content is not None: + updates.append(f"content = ${param_idx}") + params.append(content) + param_idx += 1 + + if priority is not None: + updates.append(f"priority = ${param_idx}") + params.append(priority) + param_idx += 1 + + if is_active is not None: + updates.append(f"is_active = ${param_idx}") + params.append(is_active) + param_idx += 1 + + if tags is not None: + updates.append(f"tags = ${param_idx}") + params.append(tags) + param_idx += 1 + + params.extend([bank_id, directive_id]) + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + UPDATE {fq_table("directives")} + SET {", ".join(updates)} + WHERE bank_id = ${param_idx} AND id = ${param_idx + 1} + RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at + """, + *params, + ) + + return self._row_to_directive(row) if row else None + + async def delete_directive( + self, + bank_id: str, + directive_id: str, + *, + request_context: "RequestContext", + ) -> bool: + """Delete a directive. + + Args: + bank_id: Bank identifier + directive_id: Directive UUID + request_context: Request context for authentication + + Returns: + True if deleted, False if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + result = await conn.execute( + f"DELETE FROM {fq_table('directives')} WHERE bank_id = $1 AND id = $2", + bank_id, + directive_id, + ) + + return result == "DELETE 1" + + def _row_to_directive(self, row) -> dict[str, Any]: + """Convert a database row to a directive dict.""" + return { + "id": str(row["id"]), + "bank_id": row["bank_id"], + "name": row["name"], + "content": row["content"], + "priority": row["priority"], + "is_active": row["is_active"], + "tags": row["tags"] or [], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } + + async def list_operations( + self, + bank_id: str, + *, + status: str | None = None, + limit: int = 20, + offset: int = 0, + request_context: "RequestContext", + ) -> dict[str, Any]: + """List async operations for a bank with optional filtering and pagination. + + Args: + bank_id: Bank identifier + status: Optional status filter (pending, completed, failed) + limit: Maximum number of operations to return (default 20) + offset: Number of operations to skip (default 0) + request_context: Request context for authentication + + Returns: + Dict with total count and list of operations, sorted by most recent first + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build WHERE clause + where_conditions = ["bank_id = $1"] + params: list[Any] = [bank_id] + + if status: + # Map API status to DB statuses (pending includes processing) + if status == "pending": + where_conditions.append("status IN ('pending', 'processing')") + else: + where_conditions.append(f"status = ${len(params) + 1}") + params.append(status) + + where_clause = " AND ".join(where_conditions) + + # Get total count (with filter) + total_row = await conn.fetchrow( + f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE {where_clause}", + *params, ) total = total_row["total"] if total_row else 0 - # Get recent operations + # Get operations with pagination operations = await conn.fetch( f""" SELECT operation_id, operation_type, created_at, status, error_message FROM {fq_table("async_operations")} - WHERE bank_id = $1 + WHERE {where_clause} ORDER BY created_at DESC - LIMIT 50 + LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} """, - bank_id, + *params, + limit, + offset, ) return { @@ -5219,7 +5324,8 @@ class MemoryEngine(MemoryEngineInterface): "items_count": 0, "document_id": None, "created_at": row["created_at"].isoformat(), - "status": row["status"], + # Map DB status to API status (processing -> pending for simplicity) + "status": "pending" if row["status"] in ("pending", "processing") else row["status"], "error_message": row["error_message"], } for row in operations @@ -5353,19 +5459,57 @@ class MemoryEngine(MemoryEngineInterface): # Return updated profile return await self.get_bank_profile(bank_id, request_context=request_context) - async def submit_async_retain( + async def _submit_async_operation( self, bank_id: str, - contents: list[dict[str, Any]], + operation_type: str, + task_type: str, + task_payload: dict[str, Any], *, - request_context: "RequestContext", - document_tags: list[str] | None = None, + result_metadata: dict[str, Any] | None = None, + dedupe_by_bank: bool = False, ) -> dict[str, Any]: - """Submit a batch retain operation to run asynchronously.""" - await self._authenticate_tenant(request_context) + """Generic helper to submit an async operation. + + Args: + bank_id: Bank identifier + operation_type: Operation type for the async_operations record (e.g., 'consolidation', 'retain') + task_type: Task type for the task payload (e.g., 'consolidation', 'batch_retain') + task_payload: Additional task payload fields (operation_id and bank_id are added automatically) + result_metadata: Optional metadata to store with the operation record + dedupe_by_bank: If True, skip creating a new task if one is already pending for this bank+operation_type + + Returns: + Dict with operation_id and optionally deduplicated=True if an existing task was found + """ + import json + pool = await self._get_pool() - import json + # Check for existing pending task if deduplication is enabled + # Note: We only check 'pending', not 'processing', because a processing task + # uses a watermark from when it started - new memories added after that point + # would need another consolidation run to be processed. + if dedupe_by_bank: + async with acquire_with_retry(pool) as conn: + existing = await conn.fetchrow( + f""" + SELECT operation_id FROM {fq_table("async_operations")} + WHERE bank_id = $1 AND operation_type = $2 AND status = 'pending' + LIMIT 1 + """, + bank_id, + operation_type, + ) + if existing: + logger.debug( + f"{operation_type} task already pending for bank_id={bank_id}, " + f"skipping duplicate (existing operation_id={existing['operation_id']})" + ) + return { + "operation_id": str(existing["operation_id"]), + "deduplicated": True, + } operation_id = uuid.uuid4() @@ -5378,25 +5522,131 @@ class MemoryEngine(MemoryEngineInterface): """, operation_id, bank_id, - "retain", - json.dumps({"items_count": len(contents)}), + operation_type, + json.dumps(result_metadata or {}), ) - # Submit task to background queue - task_payload = { - "type": "batch_retain", + # Build and submit task payload + full_payload = { + "type": task_type, "operation_id": str(operation_id), "bank_id": bank_id, - "contents": contents, + **task_payload, } - if document_tags: - task_payload["document_tags"] = document_tags - await self._task_backend.submit_task(task_payload) + await self._task_backend.submit_task(full_payload) - logger.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}") + logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}") return { "operation_id": str(operation_id), - "items_count": len(contents), } + + async def submit_async_retain( + self, + bank_id: str, + contents: list[dict[str, Any]], + *, + request_context: "RequestContext", + document_tags: list[str] | None = None, + ) -> dict[str, Any]: + """Submit a batch retain operation to run asynchronously.""" + await self._authenticate_tenant(request_context) + + task_payload: dict[str, Any] = {"contents": contents} + if document_tags: + task_payload["document_tags"] = document_tags + + result = await self._submit_async_operation( + bank_id=bank_id, + operation_type="retain", + task_type="batch_retain", + task_payload=task_payload, + result_metadata={"items_count": len(contents)}, + dedupe_by_bank=False, + ) + + result["items_count"] = len(contents) + return result + + async def submit_async_consolidation( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any]: + """Submit a consolidation operation to run asynchronously. + + Deduplicates by bank_id - if there's already a pending consolidation for this bank, + returns the existing operation_id instead of creating a new one. + + Args: + bank_id: Bank identifier + request_context: Request context for authentication + + Returns: + Dict with operation_id + """ + await self._authenticate_tenant(request_context) + return await self._submit_async_operation( + bank_id=bank_id, + operation_type="consolidation", + task_type="consolidation", + task_payload={}, + dedupe_by_bank=True, + ) + + async def submit_async_create_reflection( + self, + bank_id: str, + name: str, + source_query: str, + *, + tags: list[str] | None = None, + max_tokens: int = 2048, + request_context: "RequestContext", + ) -> dict[str, Any]: + """Submit an async reflection creation operation. + + This: + 1. Creates the reflection in the database immediately (with placeholder content) + 2. Schedules a background task to run reflect and update the content + 3. Returns operation_id for tracking + + Args: + bank_id: Bank identifier + name: Human-readable name for the reflection + source_query: The query to run to generate content + tags: Optional tags for scoped visibility + max_tokens: Maximum tokens for the reflect response + request_context: Request context for authentication + + Returns: + Dict with operation_id + """ + await self._authenticate_tenant(request_context) + + # 1. Create the reflection in the database with placeholder content + reflection = await self.create_reflection( + bank_id=bank_id, + name=name, + source_query=source_query, + content="Generating content...", # Placeholder + tags=tags, + request_context=request_context, + ) + reflection_id = reflection["id"] + + # 2. Submit async operation + return await self._submit_async_operation( + bank_id=bank_id, + operation_type="create_reflection", + task_type="create_reflection", + task_payload={ + "reflection_id": reflection_id, + "source_query": source_query, + "max_tokens": max_tokens, + }, + result_metadata={"reflection_id": reflection_id, "name": name, "source_query": source_query}, + dedupe_by_bank=False, + ) diff --git a/hindsight-api/hindsight_api/engine/mental_models/__init__.py b/hindsight-api/hindsight_api/engine/mental_models/__init__.py index cd0fba08..61f54af2 100644 --- a/hindsight-api/hindsight_api/engine/mental_models/__init__.py +++ b/hindsight-api/hindsight_api/engine/mental_models/__init__.py @@ -1,16 +1,12 @@ """ Mental models module for Hindsight. -Mental models are synthesized summaries that represent understanding. They come -in different subtypes based on how they were created: +Mental models contain directives - hard rules that are injected into reflect prompts. +Directives are user-defined and their observations are user-provided (not LLM-generated). -- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team") - These are created upfront based on what any agent with this role would need. - -- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.) - These surface organically as facts are retained. - -- Pinned: User-defined models that persist across refreshes. +Other types of consolidated knowledge are handled by: +- Learnings: Automatic bottom-up consolidation from facts +- Pinned Reflections: User-curated living documents """ from .models import MentalModel, MentalModelSubtype diff --git a/hindsight-api/hindsight_api/engine/mental_models/emergent.py b/hindsight-api/hindsight_api/engine/mental_models/emergent.py deleted file mode 100644 index ec5c8925..00000000 --- a/hindsight-api/hindsight_api/engine/mental_models/emergent.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Emergent mental model detection and promotion. - -Emergent models are discovered from data patterns: -- Named entity extraction (people, projects, systems) -- Temporal clustering (events with multiple references) -- Causal patterns ("Because X, we do Y") -- Behavioral anchors ("After X, we started Y") -- Reference frequency (anything mentioned repeatedly) - -When a pattern is detected, it goes through a mission filter to check relevance, -and if relevant, is promoted to a mental model. -""" - -import logging -from typing import TYPE_CHECKING - -from pydantic import BaseModel, Field - -from .models import EmergentCandidate - -if TYPE_CHECKING: - from ..llm_wrapper import LLMConfig - -logger = logging.getLogger(__name__) - - -class MissionFilterCandidate(BaseModel): - """Result of mission filtering for a single candidate.""" - - name: str - promote: bool = Field(description="True if this is a specific named entity worth tracking") - reason: str = Field(description="Brief explanation for the decision") - - -class MissionFilterResponse(BaseModel): - """Response from LLM for mission filtering.""" - - candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate") - - -def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str: - """Build the prompt for filtering candidates by mission relevance.""" - candidate_list = "\n".join( - [f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates] - ) - - return f"""Filter these detected entities. For each one, decide: promote=true or promote=false. - -MISSION: {mission} - -DETECTED ENTITIES: -{candidate_list} - -=== DECISION RULES === - -Set promote=true ONLY for specific, named entities: -- Person names: "John", "Maria", "Alice Chen", "Dr. Smith" -- Named organizations: "Google", "Acme Corp", "Frontend Team" -- Named places: "Central Park Zoo", "NYC Office", "Building A" -- Named projects: "Project Phoenix", "Auth Service v2" - -Set promote=false for EVERYTHING ELSE, including: -- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event -- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community -- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience - -THE TEST: Is this a specific name you'd find in a contact list or org chart? -- "John" → YES (promote=true) -- "kids" → NO (promote=false) -- "community" → NO (promote=false) -- "Maria" → YES (promote=true) -- "park" → NO (promote=false) - -When in doubt, set promote=false.""" - - -def get_mission_filter_system_message() -> str: - """System message for mission filtering.""" - return """You filter entities for promotion. Output JSON with 'candidates' array. - -Rules: -- promote=true ONLY for specific names (people, organizations, named places/projects) -- promote=false for common words, generic categories, abstract concepts - -Examples: -- "John" → promote=true (person name) -- "kids" → promote=false (generic category) -- "community" → promote=false (abstract concept) -- "Google" → promote=true (organization name) -- "motivation" → promote=false (abstract concept) - -When in doubt, promote=false. Most entities should be rejected.""" - - -async def filter_candidates_by_mission( - llm_config: "LLMConfig", - mission: str, - candidates: list[EmergentCandidate], -) -> list[EmergentCandidate]: - """ - Filter emergent candidates to keep only specific, named entities. - - Args: - llm_config: LLM configuration - mission: The bank's mission (used for context) - candidates: List of detected candidates - - Returns: - Filtered list of candidates that are specific named entities - """ - if not candidates: - return [] - - if not mission: - # No mission = no filtering, keep all candidates - logger.debug("[EMERGENT] No mission set, skipping filter") - return candidates - - prompt = build_mission_filter_prompt(mission, candidates) - - try: - result = await llm_config.call( - messages=[ - {"role": "system", "content": get_mission_filter_system_message()}, - {"role": "user", "content": prompt}, - ], - response_format=MissionFilterResponse, - scope="mental_model_mission_filter", - ) - - # Build name -> promote map - promote_map = {c.name: c.promote for c in result.candidates} - - # Filter candidates - filtered = [] - for candidate in candidates: - if candidate.name in promote_map: - if promote_map[candidate.name]: - filtered.append(candidate) - logger.debug(f"[EMERGENT] Promoting '{candidate.name}'") - else: - logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'") - else: - # Candidate not in response - reject by default - logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting") - - logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted") - return filtered - - except Exception as e: - logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}") - return [] - - -async def evaluate_emergent_models( - llm_config: "LLMConfig", - models: list[dict], -) -> list[str]: - """ - Evaluate existing emergent models to check if they should be kept. - - This re-evaluates emergent models using the same filtering criteria - as new candidates. Models that are generic/abstract will be removed. - - Args: - llm_config: LLM configuration - models: List of existing emergent model dicts with 'name', 'id' - - Returns: - List of model IDs that should be REMOVED (no longer valid) - """ - if not models: - return [] - - # Convert existing models to candidates for evaluation - candidates = [ - EmergentCandidate( - name=m["name"], - detection_method="existing_emergent_model", - mention_count=0, - ) - for m in models - ] - - # Build a simple prompt for re-evaluation - names_list = "\n".join([f"- {m['name']}" for m in models]) - prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove). - -EXISTING MODELS: -{names_list} - -=== DECISION RULES === - -Set promote=true ONLY for specific, named entities: -- Person names: "John", "Maria", "Alice Chen", "Dr. Smith" -- Named organizations: "Google", "Acme Corp", "Frontend Team" -- Named places: "Central Park Zoo", "NYC Office", "Building A" -- Named projects: "Project Phoenix", "Auth Service v2" - -Set promote=false for EVERYTHING ELSE, including: -- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event -- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community -- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience - -THE TEST: Is this a specific name you'd find in a contact list or org chart? -- "John" → YES (promote=true) -- "kids" → NO (promote=false) -- "community" → NO (promote=false) - -When in doubt, set promote=false.""" - - try: - result = await llm_config.call( - messages=[ - {"role": "system", "content": get_mission_filter_system_message()}, - {"role": "user", "content": prompt}, - ], - response_format=MissionFilterResponse, - scope="mental_model_emergent_evaluation", - ) - - # Build name -> promote map - promote_map = {c.name: c.promote for c in result.candidates} - - # Find models to remove - models_to_remove = [] - for model in models: - name = model["name"] - if name in promote_map: - if not promote_map[name]: - models_to_remove.append(model["id"]) - else: - logger.debug(f"[EMERGENT] Keeping '{name}'") - else: - # Model not in response - remove to be safe - logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal") - models_to_remove.append(model["id"]) - - logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal") - return models_to_remove - - except Exception as e: - logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}") - return [] - - -async def detect_entity_candidates( - pool, - bank_id: str, - min_mentions: int = 5, - top_percent: int = 20, -) -> list[EmergentCandidate]: - """ - Detect entities that are candidates for promotion to mental models. - - Args: - pool: Database connection pool - bank_id: Bank identifier - min_mentions: Minimum mention count to consider - top_percent: Only consider top X% by mention count - - Returns: - List of entity candidates - """ - from ..db_utils import acquire_with_retry - from ..memory_engine import fq_table - - candidates = [] - - async with acquire_with_retry(pool) as conn: - # Get entities that meet criteria and don't already have mental models - rows = await conn.fetch( - f""" - WITH ranked AS ( - SELECT - e.id, - e.canonical_name, - e.mention_count, - PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct - FROM {fq_table("entities")} e - LEFT JOIN {fq_table("mental_models")} mm - ON mm.entity_id = e.id AND mm.bank_id = e.bank_id - WHERE e.bank_id = $1 - AND e.mention_count >= $2 - AND mm.id IS NULL -- Not already a mental model - ) - SELECT id, canonical_name, mention_count - FROM ranked - WHERE rank_pct <= $3 - ORDER BY mention_count DESC - LIMIT 50 - """, - bank_id, - min_mentions, - top_percent / 100.0, - ) - - for row in rows: - candidates.append( - EmergentCandidate( - name=row["canonical_name"], - detection_method="named_entity_extraction", - mention_count=row["mention_count"], - entity_id=str(row["id"]), - relevance_score=0.0, - ) - ) - - logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates") - return candidates diff --git a/hindsight-api/hindsight_api/engine/mental_models/models.py b/hindsight-api/hindsight_api/engine/mental_models/models.py index 12353111..e1ff9fea 100644 --- a/hindsight-api/hindsight_api/engine/mental_models/models.py +++ b/hindsight-api/hindsight_api/engine/mental_models/models.py @@ -9,12 +9,14 @@ from pydantic import BaseModel, Field class MentalModelSubtype(str, Enum): - """Subtype of mental model - how it was created.""" + """Subtype of mental model. + + Currently only DIRECTIVE is supported. Other types of consolidated knowledge + are handled by: + - Learnings: Automatic bottom-up consolidation from facts + - Pinned Reflections: User-curated living documents + """ - STRUCTURAL = "structural" # Derived from mission, created upfront - EMERGENT = "emergent" # Discovered from data patterns - LEARNED = "learned" # Formed through reflection - PINNED = "pinned" # User-defined topic, observations LLM-generated DIRECTIVE = "directive" # User-defined hard rules, observations user-provided @@ -49,50 +51,3 @@ class MentalModel(BaseModel): created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="When this model was created" ) - - -class StructuralModelTemplate(BaseModel): - """ - A template for a structural mental model. - - Generated by LLM based on the bank's mission. Represents what any agent - with this role would need to track. - """ - - id: str = Field(default="", description="Existing model ID to keep, or empty for new models") - name: str = Field(description="Human-readable name") - description: str = Field(description="What this model should track") - initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model") - - -class StructuralModelDerivationResponse(BaseModel): - """Response from LLM for structural model derivation.""" - - templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission") - - -class EmergentCandidate(BaseModel): - """ - A candidate for promotion to emergent mental model. - - Detected through pattern analysis of facts. - """ - - name: str = Field(description="Name of the detected pattern/entity") - detection_method: str = Field(description="How this candidate was detected") - mention_count: int = Field(default=0, description="How many times referenced") - entity_id: str | None = Field(default=None, description="Entity ID if detected as entity") - relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)") - - -class ResearchResult(BaseModel): - """ - Result from the research endpoint. - - Contains the answer along with the mental models and facts used. - """ - - answer: str = Field(description="The synthesized answer") - mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed") - facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed") - question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)") diff --git a/hindsight-api/hindsight_api/engine/mental_models/structural.py b/hindsight-api/hindsight_api/engine/mental_models/structural.py deleted file mode 100644 index e27d0789..00000000 --- a/hindsight-api/hindsight_api/engine/mental_models/structural.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Structural mental model derivation from bank mission. - -Structural models are derived from the bank's mission - they represent what -any agent with this role would need to track. For example: - -Mission: "Be a PM for engineering team" -Structural models: - - Team Structure (who's on the team, roles) - - Project Overview (current projects, status) - - Processes (how releases work, how decisions are made) - - Key Systems (what we own, dependencies) -""" - -import logging -from typing import TYPE_CHECKING - -from pydantic import BaseModel, Field - -from .models import StructuralModelTemplate - -if TYPE_CHECKING: - from ..llm_wrapper import LLMConfig - -logger = logging.getLogger(__name__) - - -class StructuralDerivationResponse(BaseModel): - """Response from LLM for structural model derivation.""" - - templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission") - - -class StructuralRelevanceResult(BaseModel): - """Result of evaluating a structural model's relevance to the mission.""" - - name: str - relevant: bool - reason: str - - -class StructuralRelevanceResponse(BaseModel): - """Response from LLM for structural model relevance evaluation.""" - - models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model") - - -def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str: - """Build the prompt for deriving structural models from a mission.""" - existing_section = "" - if existing_models: - model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models]) - existing_section = f""" -EXISTING STRUCTURAL MODELS: -{model_list} - -IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value. -Models not included in your output will be REMOVED. -""" - - return f"""Given this agent mission, identify the KEY THINGS to track to achieve it. - -MISSION: {mission} -{existing_section} -IMPORTANT CONSTRAINTS: -- Return 0-3 structural models MAXIMUM (less is better!) -- Only include models for SPECIFIC, CONCRETE things the agent needs to track -- Each model must be DIRECTLY tied to achieving the mission -- If the mission is simple, return 0 models (empty array is fine) -- If existing models are provided and you want to keep one, use its EXACT id -- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists) - -GOOD examples (specific, actionable): -- Mission: "Be a PM for engineering team" → "Team Members" (track who's on the team) -- Mission: "Track customer feedback" → "Customer Issues" (track specific complaints/requests) -- Mission: "Manage project X" → "Project X Milestones" (track progress) - -BAD examples (too generic, don't create these): -- "Processes", "Workflows", "Key Systems", "Important Events" -- "Communication", "Collaboration", "Progress", "Status" -- Generic role-based models not tied to the specific mission - -For each model: -1. id: Use EXACT existing id if keeping a model, or leave empty for new models -2. name: Short, specific name (e.g., "Team Members", "Sprint Goals") -3. description: One line describing what to track -4. initial_probes: 2-3 search queries to find relevant information - -Return ONLY the models that should exist. Existing models not in your output will be deleted.""" - - -def get_structural_derivation_system_message() -> str: - """System message for structural model derivation.""" - return """You identify the key things to track for a mission. Be VERY selective. - -Rules: -- Maximum 3 models (prefer fewer) -- Only SPECIFIC, CONCRETE things - not generic categories -- Each must DIRECTLY help achieve the mission -- Empty array is valid if no models are truly needed -- If existing models are shown and you want to keep one, return its EXACT id -- Never create duplicates - if a similar model exists, keep the existing one - -Output JSON with 'templates' array (can be empty).""" - - -def _normalize_id(text: str) -> str: - """Normalize a string to a canonical form for comparison. - - Removes common suffixes, pluralization, and normalizes separators. - """ - # Lowercase and normalize separators - normalized = text.lower().replace(" ", "-").replace("_", "-") - - # Remove common suffixes that indicate the same concept - suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"] - for suffix in suffixes_to_remove: - if normalized.endswith(suffix) and len(normalized) > len(suffix): - normalized = normalized[: -len(suffix)] - - return normalized - - -def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None: - """Find an existing model ID that is similar to the new ID. - - Returns the existing ID if a similar one is found, None otherwise. - """ - if not existing_models: - return None - - new_normalized = _normalize_id(new_id) - - for model in existing_models: - existing_id = model.get("id", "") - existing_normalized = _normalize_id(existing_id) - - # Check if one is a prefix of the other (normalized) - if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized): - return existing_id - - # Check if they're the same when normalized - if new_normalized == existing_normalized: - return existing_id - - return None - - -async def derive_structural_models( - llm_config: "LLMConfig", - mission: str, - existing_models: list[dict] | None = None, -) -> tuple[list[StructuralModelTemplate], list[str]]: - """ - Derive structural model templates from a bank's mission. - - This combines derivation and evaluation in one call. The LLM sees existing - models and decides which to keep. Any existing model not in the output - will be marked for removal. - - Args: - llm_config: LLM configuration for calling the model - mission: The bank's mission (e.g., "Be a PM for engineering team") - existing_models: Optional list of existing model dicts with 'name', 'description', 'id' - - Returns: - Tuple of (templates to create/keep, IDs of existing models to remove) - - Raises: - Exception: If LLM call fails - """ - prompt = build_structural_derivation_prompt(mission, existing_models) - - result = await llm_config.call( - messages=[ - {"role": "system", "content": get_structural_derivation_system_message()}, - {"role": "user", "content": prompt}, - ], - response_format=StructuralDerivationResponse, - scope="mental_model_structural_derivation", - ) - - templates = result.templates - logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models") - - # Build set of existing IDs for quick lookup - existing_ids = {m["id"] for m in existing_models} if existing_models else set() - - # Process templates: validate IDs, deduplicate, assign stable IDs - processed_templates: list[StructuralModelTemplate] = [] - kept_existing_ids: set[str] = set() - - for template in templates: - # If LLM returned an ID, check if it's a valid existing ID - if template.id and template.id in existing_ids: - # LLM is keeping an existing model - kept_existing_ids.add(template.id) - processed_templates.append(template) - logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}") - else: - # New model or LLM didn't return a valid ID - # Generate ID from name - generated_id = template.name.lower().replace(" ", "-").replace("_", "-") - - # Check for similar existing models to prevent near-duplicates - similar_id = _find_similar_existing_id(generated_id, existing_models) - if similar_id and similar_id not in kept_existing_ids: - # Use the existing similar model instead of creating a new one - logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'") - template.id = similar_id - kept_existing_ids.add(similar_id) - else: - template.id = generated_id - - processed_templates.append(template) - - # Find existing models to remove (not kept in LLM output) - models_to_remove = [] - if existing_models: - for model in existing_models: - if model["id"] not in kept_existing_ids: - logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal") - models_to_remove.append(model["id"]) - - if models_to_remove: - logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed") - - return processed_templates, models_to_remove diff --git a/hindsight-api/hindsight_api/engine/reflect/agent.py b/hindsight-api/hindsight_api/engine/reflect/agent.py index bd9d38af..4f09d5b6 100644 --- a/hindsight-api/hindsight_api/engine/reflect/agent.py +++ b/hindsight-api/hindsight_api/engine/reflect/agent.py @@ -1,5 +1,10 @@ """ Reflect agent - agentic loop for reflection with native tool calling. + +Uses hierarchical retrieval: +1. search_reflections - User-curated summaries (highest quality) +2. search_mental_models - Consolidated knowledge with freshness +3. recall - Raw facts as ground truth """ import asyncio @@ -8,7 +13,7 @@ import logging import time from typing import TYPE_CHECKING, Any, Awaitable, Callable -from .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, ToolCall +from .models import DirectiveInfo, LLMCall, ReflectAgentResult, ToolCall from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools from .tools_schema import get_reflect_tools @@ -46,6 +51,32 @@ logger = logging.getLogger(__name__) DEFAULT_MAX_ITERATIONS = 10 +def _normalize_tool_name(name: str) -> str: + """Normalize tool name from various LLM output formats. + + Some LLMs output tool names in non-standard formats: + - 'functions.done' (OpenAI-style prefix) + - 'call=functions.done' (some models) + - 'call=done' (some models) + + Returns the normalized tool name (e.g., 'done', 'recall', etc.) + """ + # Handle 'call=functions.name' or 'call=name' format + if name.startswith("call="): + name = name[len("call=") :] + + # Handle 'functions.name' format + if name.startswith("functions."): + name = name[len("functions.") :] + + return name + + +def _is_done_tool(name: str) -> bool: + """Check if the tool name represents the 'done' tool.""" + return _normalize_tool_name(name) == "done" + + async def _generate_structured_output( answer: str, response_schema: dict, @@ -153,10 +184,10 @@ async def run_reflect_agent( bank_id: str, query: str, bank_profile: dict[str, Any], - lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]], + search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]], recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]], - learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None, context: str | None = None, max_iterations: int = DEFAULT_MAX_ITERATIONS, max_tokens: int | None = None, @@ -166,19 +197,20 @@ async def run_reflect_agent( """ Execute the reflect agent loop using native tool calling. - The agent iteratively calls tools to gather information and learn, - then provides a final answer via the done() tool. + The agent uses hierarchical retrieval: + 1. search_reflections - User-curated summaries (try first) + 2. search_mental_models - Consolidated knowledge with freshness + 3. recall - Raw facts as ground truth Args: llm_config: LLM provider for agent calls bank_id: Bank identifier query: Question to answer bank_profile: Bank profile with name and mission - lookup_fn: Tool callback for lookup (model_id) -> result + search_reflections_fn: Tool callback for searching reflections (query, max_results) -> result + search_mental_models_fn: Tool callback for searching mental models (query, max_results) -> result recall_fn: Tool callback for recall (query, max_tokens) -> result - expand_fn: Tool callback for expand (memory_id, depth) -> result - learn_fn: Optional tool callback for learn (MentalModelInput) -> result. - If None, learn tool is disabled. + expand_fn: Tool callback for expand (memory_ids, depth) -> result context: Optional additional context max_iterations: Maximum number of iterations before forcing response max_tokens: Maximum tokens for the final response @@ -188,7 +220,6 @@ async def run_reflect_agent( Returns: ReflectAgentResult with final answer and metadata """ - enable_learn = learn_fn is not None reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}" start_time = time.time() @@ -199,7 +230,7 @@ async def run_reflect_agent( directive_rules = _extract_directive_rules(directives) if directives else None # Get tools for this agent (with directive compliance field if directives exist) - tools = get_reflect_tools(enable_learn=enable_learn, directive_rules=directive_rules) + tools = get_reflect_tools(directive_rules=directive_rules) # Build initial messages (directives are injected into system prompt at START and END) system_prompt = build_system_prompt_for_tools(bank_profile, context, directives=directives) @@ -209,7 +240,6 @@ async def run_reflect_agent( ] # Tracking - mental_models_created: list[str] = [] total_tools_called = 0 tool_trace: list[ToolCall] = [] tool_trace_summary: list[dict[str, Any]] = [] @@ -218,45 +248,8 @@ async def run_reflect_agent( # Track available IDs for validation (prevents hallucinated citations) available_memory_ids: set[str] = set() - available_model_ids: set[str] = set() - - # Pre-fetch mental models so the agent always starts with this knowledge - prefetch_start = time.time() - models_result = await lookup_fn(None) # List all mental models - prefetch_duration = int((time.time() - prefetch_start) * 1000) - - # Track available model IDs - if isinstance(models_result, dict) and "models" in models_result: - for model in models_result["models"]: - if "id" in model: - available_model_ids.add(model["id"]) - - # Add to context history for the agent - context_history.append({"tool": "list_mental_models", "output": models_result}) - - # Add to tool trace - tool_trace.append( - ToolCall( - tool="list_mental_models", - input={"tool": "list_mental_models"}, - output=models_result, - duration_ms=prefetch_duration, - iteration=0, - ) - ) - tool_trace_summary.append( - { - "tool": "list_mental_models", - "input_summary": "(prefetch)", - "duration_ms": prefetch_duration, - "output_chars": len(json.dumps(models_result, default=str)), - } - ) - total_tools_called += 1 - - # Include in the user message so the agent sees it - models_info = json.dumps(models_result, indent=2, default=str) - messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```" + available_reflection_ids: set[str] = set() + available_mental_model_ids: set[str] = set() def _get_llm_trace() -> list[LLMCall]: return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace] @@ -315,7 +308,6 @@ async def run_reflect_agent( structured_output=structured_output, iterations=iteration + 1, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), directives_applied=directives_applied, @@ -334,12 +326,14 @@ async def run_reflect_agent( llm_duration = int((time.time() - llm_start) * 1000) llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration}) - except Exception: - llm_trace.append( - {"scope": f"agent_{iteration + 1}_err", "duration_ms": int((time.time() - llm_start) * 1000)} - ) + except Exception as e: + err_duration = int((time.time() - llm_start) * 1000) + logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)") + llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration}) # Guardrail: If no evidence gathered yet, retry - has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids) + has_gathered_evidence = ( + bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids) + ) if not has_gathered_evidence and iteration < max_iterations - 1: continue prompt = build_final_prompt(query, context_history, bank_profile, context) @@ -366,7 +360,6 @@ async def run_reflect_agent( structured_output=structured_output, iterations=iteration + 1, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), directives_applied=directives_applied, @@ -390,7 +383,6 @@ async def run_reflect_agent( structured_output=structured_output, iterations=iteration + 1, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), directives_applied=directives_applied, @@ -420,17 +412,18 @@ async def run_reflect_agent( structured_output=structured_output, iterations=iteration + 1, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), directives_applied=directives_applied, ) - # Check for done tool call (handle both 'done' and 'functions.done') - done_call = next((tc for tc in result.tool_calls if tc.name == "done" or tc.name == "functions.done"), None) + # Check for done tool call (handle various LLM output formats) + done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None) if done_call: # Guardrail: Require evidence before done - has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids) + has_gathered_evidence = ( + bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids) + ) if not has_gathered_evidence and iteration < max_iterations - 1: # Add assistant message and fake tool result asking for evidence messages.append( @@ -445,7 +438,7 @@ async def run_reflect_agent( "tool_call_id": done_call.id, "content": json.dumps( { - "error": "You must call recall() or list_mental_models() to gather evidence before providing your final answer." + "error": "You must search for information first. Use search_reflections(), search_mental_models(), or recall() before providing your final answer." } ), } @@ -456,10 +449,10 @@ async def run_reflect_agent( return await _process_done_tool( done_call, available_memory_ids, - available_model_ids, + available_reflection_ids, + available_mental_model_ids, iteration + 1, total_tools_called, - mental_models_created, tool_trace, _get_llm_trace(), _log_completion, @@ -469,8 +462,8 @@ async def run_reflect_agent( response_schema=response_schema, ) - # Execute other tools in parallel (exclude done and functions.done) - other_tools = [tc for tc in result.tool_calls if tc.name not in ("done", "functions.done")] + # Execute other tools in parallel (exclude done tool in all its format variants) + other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)] if other_tools: # Add assistant message with tool calls messages.append( @@ -482,7 +475,14 @@ async def run_reflect_agent( # Execute tools in parallel tool_tasks = [ - _execute_tool_with_timing(tc, lookup_fn, recall_fn, expand_fn, learn_fn) for tc in other_tools + _execute_tool_with_timing( + tc, + search_reflections_fn, + search_mental_models_fn, + recall_fn, + expand_fn, + ) + for tc in other_tools ] tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True) total_tools_called += len(other_tools) @@ -490,38 +490,46 @@ async def run_reflect_agent( # Process results and add to messages for tc, result_data in zip(other_tools, tool_results): if isinstance(result_data, Exception): - # Tool execution failed - log and raise to fail the request - logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}") - raise RuntimeError(f"Reflect tool '{tc.name}' failed: {result_data}") + # Tool execution failed - send error back to LLM so it can try again + logger.warning(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}") + output = {"error": f"Tool execution failed: {result_data}"} + duration_ms = 0 + else: + output, duration_ms = result_data - output, duration_ms = result_data + # Normalize tool name for consistent tracking + normalized_tool_name = _normalize_tool_name(tc.name) - # Check if tool returned an error response + # Check if tool returned an error response - log but continue (LLM will see the error) if isinstance(output, dict) and "error" in output: - logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} returned error: {output['error']}") - raise RuntimeError(f"Reflect tool '{tc.name}' error: {output['error']}") + logger.warning( + f"[REFLECT {reflect_id}] Tool {normalized_tool_name} returned error: {output['error']}" + ) - # Track created mental models - if tc.name == "learn" and isinstance(output, dict) and "model_id" in output: - mental_models_created.append(output["model_id"]) + # Track available IDs from tool results (only for successful responses) + if ( + normalized_tool_name == "search_reflections" + and isinstance(output, dict) + and "reflections" in output + ): + for reflection in output["reflections"]: + if "id" in reflection: + available_reflection_ids.add(reflection["id"]) - # Track available memory IDs from recall - if tc.name == "recall" and isinstance(output, dict) and "memories" in output: + if ( + normalized_tool_name == "search_mental_models" + and isinstance(output, dict) + and "mental_models" in output + ): + for mm in output["mental_models"]: + if "id" in mm: + available_mental_model_ids.add(mm["id"]) + + if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output: for memory in output["memories"]: if "id" in memory: available_memory_ids.add(memory["id"]) - # Track available model IDs - if tc.name in ("list_mental_models", "get_mental_model") and isinstance(output, dict): - if output.get("found") and "model" in output: - model_id = output["model"].get("id") - if model_id: - available_model_ids.add(model_id) - elif "models" in output: - for model in output["models"]: - if "id" in model: - available_model_ids.add(model["id"]) - # Add tool result message messages.append( { @@ -565,7 +573,6 @@ async def run_reflect_agent( text=answer, iterations=max_iterations, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), directives_applied=directives_applied, @@ -587,10 +594,10 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]: async def _process_done_tool( done_call: "LLMToolCall", available_memory_ids: set[str], - available_model_ids: set[str], + available_reflection_ids: set[str], + available_mental_model_ids: set[str], iterations: int, total_tools_called: int, - mental_models_created: list[str], tool_trace: list[ToolCall], llm_trace: list[LLMCall], log_completion: Callable, @@ -606,9 +613,10 @@ async def _process_done_tool( if not answer: answer = "No answer provided." - # Validate IDs + # Validate IDs (only include IDs that were actually retrieved) used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids] - used_model_ids = [mid for mid in args.get("model_ids", []) if mid in available_model_ids] + used_reflection_ids = [rid for rid in args.get("reflection_ids", []) if rid in available_reflection_ids] + used_mental_model_ids = [mid for mid in args.get("mental_model_ids", []) if mid in available_mental_model_ids] # Generate structured output if schema provided structured_output = None @@ -621,25 +629,32 @@ async def _process_done_tool( structured_output=structured_output, iterations=iterations, tools_called=total_tools_called, - mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=llm_trace, used_memory_ids=used_memory_ids, - used_model_ids=used_model_ids, + used_reflection_ids=used_reflection_ids, + used_mental_model_ids=used_mental_model_ids, directives_applied=directives_applied, ) async def _execute_tool_with_timing( tc: "LLMToolCall", - lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]], + search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]], recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]], - learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None, ) -> tuple[dict[str, Any], int]: """Execute a tool call and return result with timing.""" start = time.time() - result = await _execute_tool(tc.name, tc.arguments, lookup_fn, recall_fn, expand_fn, learn_fn) + result = await _execute_tool( + tc.name, + tc.arguments, + search_reflections_fn, + search_mental_models_fn, + recall_fn, + expand_fn, + ) duration_ms = int((time.time() - start) * 1000) return result, duration_ms @@ -647,24 +662,28 @@ async def _execute_tool_with_timing( async def _execute_tool( tool_name: str, args: dict[str, Any], - lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]], + search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]], recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]], - learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None, ) -> dict[str, Any]: """Execute a single tool by name.""" - # Normalize tool name - some LLMs return 'functions.done' instead of 'done' - if tool_name.startswith("functions."): - tool_name = tool_name[len("functions.") :] + # Normalize tool name for various LLM output formats + tool_name = _normalize_tool_name(tool_name) - if tool_name == "list_mental_models": - return await lookup_fn(None) + if tool_name == "search_reflections": + query = args.get("query") + if not query: + return {"error": "search_reflections requires a query parameter"} + max_results = args.get("max_results") or 5 + return await search_reflections_fn(query, max_results) - elif tool_name == "get_mental_model": - model_id = args.get("model_id") - if not model_id: - return {"error": "get_mental_model requires model_id"} - return await lookup_fn(model_id) + elif tool_name == "search_mental_models": + query = args.get("query") + if not query: + return {"error": "search_mental_models requires a query parameter"} + max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000 + return await search_mental_models_fn(query, max_tokens) elif tool_name == "recall": query = args.get("query") @@ -673,15 +692,6 @@ async def _execute_tool( max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000 return await recall_fn(query, max_tokens) - elif tool_name == "learn": - if learn_fn is None: - return {"error": "learn tool is not available"} - name = args.get("name") - description = args.get("description") - if not name or not description: - return {"error": "learn requires name and description"} - return await learn_fn(MentalModelInput(name=name, description=description)) - elif tool_name == "expand": memory_ids = args.get("memory_ids", []) if not memory_ids: @@ -695,21 +705,22 @@ async def _execute_tool( def _summarize_input(tool_name: str, args: dict[str, Any]) -> str: """Create a summary of tool input for logging, showing all params.""" - if tool_name == "list_mental_models": - return "()" - elif tool_name == "get_mental_model": - return f"(model_id={args.get('model_id', '?')})" + if tool_name == "search_reflections": + query = args.get("query", "") + query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'" + max_results = args.get("max_results") or 5 + return f"(query={query_preview}, max_results={max_results})" + elif tool_name == "search_mental_models": + query = args.get("query", "") + query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'" + max_tokens = max(args.get("max_tokens") or 5000, 1000) + return f"(query={query_preview}, max_tokens={max_tokens})" elif tool_name == "recall": query = args.get("query", "") query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'" # Show actual value used (default 2048, min 1000) max_tokens = max(args.get("max_tokens") or 2048, 1000) return f"(query={query_preview}, max_tokens={max_tokens})" - elif tool_name == "learn": - name = args.get("name", "?") - desc = args.get("description", "") - desc_preview = f"'{desc[:20]}...'" if len(desc) > 20 else f"'{desc}'" - return f"(name='{name}', description={desc_preview})" elif tool_name == "expand": memory_ids = args.get("memory_ids", []) depth = args.get("depth", "chunk") @@ -718,6 +729,9 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str: answer = args.get("answer", "") answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'" memory_ids = args.get("memory_ids", []) - model_ids = args.get("model_ids", []) - return f"(answer={answer_preview}, memory_ids={len(memory_ids)}, model_ids={len(model_ids)})" + reflection_ids = args.get("reflection_ids", []) + mental_model_ids = args.get("mental_model_ids", []) + return ( + f"(answer={answer_preview}, mem={len(memory_ids)}, ref={len(reflection_ids)}, mm={len(mental_model_ids)})" + ) return str(args) diff --git a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py b/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py deleted file mode 100644 index df7fab15..00000000 --- a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py +++ /dev/null @@ -1,1213 +0,0 @@ -""" -Diff-Based Mental Model Reflect Agent. - -This module implements a multi-phase agentic loop for generating and updating -mental model observations with evidence-grounded quotes and computed trends. - -Phases: -0. UPDATE EXISTING: Search for new evidence for existing observations -1. SEED: Generate NEW candidate observations (skipping already-tracked patterns) -2. EVIDENCE HUNT: For each new candidate, search for supporting/contradicting evidence -3. VALIDATE: Validate new candidates and extract quotes -4. COMPARE: Merge updated existing + new validated observations -""" - -import asyncio -import hashlib -import json -import logging -import time -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Awaitable, Callable - -from pydantic import BaseModel, Field, field_validator - -from .observations import ( - CandidateObservation, - CandidateWithEvidence, - Observation, - ObservationEvidence, - verify_evidence_quotes, -) -from .prompts import ( - COMPARE_PHASE_SYSTEM_PROMPT, - SEED_PHASE_SYSTEM_PROMPT, - UPDATE_EXISTING_SYSTEM_PROMPT, - VALIDATE_PHASE_SYSTEM_PROMPT, - build_compare_phase_prompt, - build_seed_phase_prompt, - build_update_existing_prompt, - build_validate_phase_prompt, -) - -if TYPE_CHECKING: - from ..llm_wrapper import LLMProvider - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Typed Models for Refresh State Tracking -# ============================================================================= - - -class DispositionTraits(BaseModel): - """Disposition traits for a memory bank.""" - - skepticism: int = Field(default=3, ge=1, le=5) - literalism: int = Field(default=3, ge=1, le=5) - empathy: int = Field(default=3, ge=1, le=5) - - -class BankProfile(BaseModel): - """Bank profile with mission and disposition.""" - - bank_id: str = Field(default="") - name: str = Field(default="") - mission: str | None = Field(default=None) - disposition: DispositionTraits = Field(default_factory=DispositionTraits) - - @field_validator("disposition", mode="before") - @classmethod - def parse_disposition(cls, v: DispositionTraits | dict | None) -> DispositionTraits: - """Parse disposition from various formats.""" - if v is None: - return DispositionTraits() - if isinstance(v, DispositionTraits): - return v - if isinstance(v, dict): - return DispositionTraits.model_validate(v) - # Handle Pydantic v1 style models - if hasattr(v, "model_dump"): - return DispositionTraits.model_validate(v.model_dump()) - return DispositionTraits() - - -class DirectiveObservation(BaseModel): - """A single observation in a directive mental model.""" - - title: str = Field(default="") - content: str = Field(default="") - text: str = Field(default="") # Legacy field - - @property - def effective_content(self) -> str: - """Get content, falling back to text for legacy data.""" - return self.content or self.text - - -class DirectiveMentalModel(BaseModel): - """A directive mental model with its observations.""" - - id: str = Field(default="") - name: str = Field(default="") - observations: list[DirectiveObservation] = Field(default_factory=list) - - @field_validator("observations", mode="before") - @classmethod - def parse_observations(cls, v: list | None) -> list[DirectiveObservation]: - """Parse observations from various formats.""" - if not v: - return [] - result = [] - for obs in v: - if isinstance(obs, DirectiveObservation): - result.append(obs) - elif isinstance(obs, dict): - result.append(DirectiveObservation.model_validate(obs)) - return result - - -class RefreshState(BaseModel): - """State snapshot at the time of last refresh. - - Used to determine if a refresh is needed by comparing current state - against this stored snapshot. - """ - - last_refresh_at: str = Field(description="ISO timestamp of last refresh") - memories_count: int = Field(default=0, description="Total memory count at refresh time") - mission_hash: str = Field(default="", description="Hash of bank mission text") - disposition_hash: str = Field(default="", description="Hash of bank disposition values") - directives_hash: str = Field(default="", description="Hash of all directive observations") - - -class RefreshCheckResult(BaseModel): - """Result of checking if a mental model needs refresh.""" - - needs_refresh: bool = Field(description="Whether a refresh is needed") - reasons: list[str] = Field(default_factory=list, description="Reasons why refresh is needed") - current_state: RefreshState | None = Field(default=None, description="Current state for comparison") - - -def _hash_string(s: str) -> str: - """Create a short hash of a string.""" - if not s: - return "" - return hashlib.sha256(s.encode()).hexdigest()[:16] - - -def _hash_mission(mission: str | None) -> str: - """Hash the bank mission text.""" - return _hash_string(mission or "") - - -def _hash_disposition(disposition: DispositionTraits) -> str: - """Hash the bank disposition values.""" - # Create deterministic string from disposition values - return _hash_string( - f"skepticism:{disposition.skepticism}|literalism:{disposition.literalism}|empathy:{disposition.empathy}" - ) - - -def _hash_directives(directives: list[DirectiveMentalModel]) -> str: - """Hash all directive observations.""" - if not directives: - return "" - - # Create deterministic string from all directive observations - parts = [] - for directive in sorted(directives, key=lambda d: d.id or d.name): - directive_id = directive.id or directive.name - for obs in directive.observations: - parts.append(f"{directive_id}:{obs.title}:{obs.effective_content}") - - return _hash_string("|".join(parts)) - - -def compute_refresh_state( - memories_count: int, - bank_profile: BankProfile, - directives: list[DirectiveMentalModel], -) -> RefreshState: - """Compute the current refresh state from inputs. - - Args: - memories_count: Total number of memories in the bank - bank_profile: Bank profile with mission and disposition - directives: List of directive mental models - """ - return RefreshState( - last_refresh_at=datetime.now(timezone.utc).isoformat(), - memories_count=memories_count, - mission_hash=_hash_mission(bank_profile.mission), - disposition_hash=_hash_disposition(bank_profile.disposition), - directives_hash=_hash_directives(directives), - ) - - -def check_needs_refresh( - stored_state: dict | RefreshState | None, - current_memories_count: int, - bank_profile: BankProfile, - directives: list[DirectiveMentalModel], -) -> RefreshCheckResult: - """Check if a mental model needs refresh by comparing states. - - Args: - stored_state: Previously stored refresh state (or None if never refreshed) - current_memories_count: Current total memory count - bank_profile: Current bank profile - directives: Current directive mental models - - Returns: - RefreshCheckResult with needs_refresh flag and reasons - """ - # Compute current state - current_state = compute_refresh_state(current_memories_count, bank_profile, directives) - - # Never refreshed = definitely needs refresh - if stored_state is None: - return RefreshCheckResult( - needs_refresh=True, - reasons=["never_refreshed"], - current_state=current_state, - ) - - # Parse stored state if dict - if isinstance(stored_state, dict): - try: - stored = RefreshState.model_validate(stored_state) - except Exception: - return RefreshCheckResult( - needs_refresh=True, - reasons=["invalid_stored_state"], - current_state=current_state, - ) - else: - stored = stored_state - - # Compare states - reasons: list[str] = [] - - if current_memories_count > stored.memories_count: - reasons.append("new_memories") - - if current_state.mission_hash != stored.mission_hash: - reasons.append("mission_changed") - - if current_state.disposition_hash != stored.disposition_hash: - reasons.append("disposition_changed") - - if current_state.directives_hash != stored.directives_hash: - reasons.append("directives_changed") - - return RefreshCheckResult( - needs_refresh=len(reasons) > 0, - reasons=reasons, - current_state=current_state, - ) - - -class PhaseTokenUsage(BaseModel): - """Token usage from a phase's LLM calls.""" - - input_tokens: int = Field(default=0) - output_tokens: int = Field(default=0) - total_tokens: int = Field(default=0) - - def __add__(self, other: "PhaseTokenUsage") -> "PhaseTokenUsage": - """Allow aggregating token usage.""" - return PhaseTokenUsage( - input_tokens=self.input_tokens + other.input_tokens, - output_tokens=self.output_tokens + other.output_tokens, - total_tokens=self.total_tokens + other.total_tokens, - ) - - -class SeedPhaseResult(BaseModel): - """Result from the seed phase.""" - - candidates: list[CandidateObservation] = Field(default_factory=list) - token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) - - -class UpdateExistingResult(BaseModel): - """Result from the update existing phase.""" - - updated_observations: list[dict] = Field(default_factory=list) - contradicted_titles: list[str] = Field(default_factory=list) - token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) - - -class ValidatePhaseResult(BaseModel): - """Result from the validate phase.""" - - verified_observations: list[dict] = Field(default_factory=list) - token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) - - -class ComparePhaseResult(BaseModel): - """Result from the compare phase.""" - - observations: list[Observation] = Field(default_factory=list) - changes: dict = Field(default_factory=dict) - token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) - - -class MentalModelReflectResult(BaseModel): - """Result from the mental model reflect process.""" - - observations: list[Observation] = Field(default_factory=list, description="Final validated observations") - version: int = Field(default=1, description="New version number for this mental model") - changes: dict = Field(default_factory=dict, description="Summary of changes made") - phases_completed: list[str] = Field(default_factory=list, description="Which phases were completed") - duration_ms: int = Field(default=0, description="Total duration in milliseconds") - memories_analyzed: int = Field(default=0, description="Number of memories analyzed") - candidates_generated: int = Field(default=0, description="Number of candidate observations generated") - candidates_validated: int = Field(default=0, description="Number of candidates that passed validation") - # Token usage tracking - input_tokens: int = Field(default=0, description="Total input tokens used across all LLM calls") - output_tokens: int = Field(default=0, description="Total output tokens used across all LLM calls") - total_tokens: int = Field(default=0, description="Total tokens used (input + output)") - - -class SeedPhaseOutput(BaseModel): - """Output from the seed phase.""" - - candidates: list[CandidateObservation] = Field(default_factory=list) - - -class ValidatePhaseOutput(BaseModel): - """Output from the validate phase.""" - - observations: list[dict] = Field(default_factory=list) - discarded: list[dict] = Field(default_factory=list) - merged: list[dict] = Field(default_factory=list) - - -class ComparePhaseOutput(BaseModel): - """Output from the compare phase.""" - - observations: list[dict] = Field(default_factory=list) - changes: dict = Field(default_factory=dict) - - -class NewEvidenceItem(BaseModel): - """A new evidence item from the update existing phase.""" - - memory_id: str = Field(description="ID of the memory this quote is from") - quote: str = Field(description="Exact quote from the memory") - relevance: str = Field(default="", description="Why this quote supports the observation") - timestamp: str = Field(default="", description="When the memory was created (ISO format)") - - -class UpdatedObservation(BaseModel): - """Output for a single updated observation.""" - - title: str = Field(default="") - content: str = Field(default="") - existing_evidence_count: int = Field(default=0) - new_evidence: list[NewEvidenceItem] = Field(default_factory=list) - has_contradiction: bool = Field(default=False) - contradiction_note: str | None = Field(default=None) - - -class UpdateExistingPhaseOutput(BaseModel): - """Output from the update existing phase.""" - - updated_observations: list[UpdatedObservation] = Field(default_factory=list) - - -class ObservationWithEvidence(BaseModel): - """An existing observation with newly found evidence for the update phase.""" - - observation: dict = Field(description="The original observation dict") - supporting_memories: list[dict] = Field(default_factory=list, description="Newly found supporting memories") - contradicting_memories: list[dict] = Field(default_factory=list, description="Newly found contradicting memories") - - -async def run_mental_model_reflect( - llm_config: "LLMProvider", - bank_id: str, - mental_model_id: str, - mental_model_name: str, - existing_observations: list[dict], - current_version: int, - get_diverse_memories_fn: Callable[[], Awaitable[list[dict]]], - recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], - topic: str | None = None, - max_candidates: int = 15, -) -> MentalModelReflectResult: - """ - Execute the diff-based mental model reflect loop. - - This is a 5-phase process that efficiently updates existing observations - and discovers new patterns without redundant work: - - Phase 0: UPDATE EXISTING - Search for new evidence for existing observations - Phase 1: SEED - Generate NEW candidate observations (skipping already-tracked patterns) - Phase 2: EVIDENCE HUNT - Search for evidence for new candidates - Phase 3: VALIDATE - Validate new candidates and extract quotes - Phase 4: COMPARE - Merge updated existing + new validated observations - - Args: - llm_config: LLM provider for agent calls - bank_id: Bank identifier - mental_model_id: ID of the mental model being updated - mental_model_name: Name of the mental model (for context) - existing_observations: Current observations in the mental model - current_version: Current version number of the mental model - get_diverse_memories_fn: Async function to get diverse memory sample - recall_fn: Async function for semantic search (query, max_tokens) -> memories - topic: Optional topic focus for the mental model - max_candidates: Maximum number of candidate observations to generate - - Returns: - MentalModelReflectResult with final observations and metadata - """ - reflect_id = f"mm-{bank_id[:8]}-{int(time.time() * 1000) % 100000}" - start_time = time.time() - phases_completed: list[str] = [] - updated_existing: list[dict] = [] - contradicted_observations: list[str] = [] - total_usage = PhaseTokenUsage() # Aggregate token usage across all phases - - logger.info( - f"[MM-REFLECT {reflect_id}] Starting diff-based reflect for mental model '{mental_model_name}' " - f"({len(existing_observations)} existing observations)" - ) - - # ========================================================================== - # PHASE 0: UPDATE EXISTING (if there are existing observations) - # ========================================================================== - if existing_observations: - logger.info(f"[MM-REFLECT {reflect_id}] Phase 0: UPDATE EXISTING - Searching for new evidence") - phase0_start = time.time() - - update_result = await _run_update_existing_phase( - llm_config=llm_config, - existing_observations=existing_observations, - recall_fn=recall_fn, - reflect_id=reflect_id, - ) - updated_existing = update_result.updated_observations - contradicted_observations = update_result.contradicted_titles - total_usage = total_usage + update_result.token_usage - - phase0_duration = int((time.time() - phase0_start) * 1000) - logger.info( - f"[MM-REFLECT {reflect_id}] Phase 0 complete: " - f"{len(updated_existing)} observations updated, " - f"{len(contradicted_observations)} flagged for removal ({phase0_duration}ms)" - ) - phases_completed.append("update_existing") - - # ========================================================================== - # PHASE 1: SEED (with existing observations context) - # ========================================================================== - logger.info(f"[MM-REFLECT {reflect_id}] Phase 1: SEED - Getting diverse memories") - phase1_start = time.time() - - # Get diverse memory sample - seed_memories = await get_diverse_memories_fn() - if not seed_memories: - logger.warning(f"[MM-REFLECT {reflect_id}] No memories found for seeding") - # Return updated existing observations if we have them - if updated_existing: - return MentalModelReflectResult( - observations=[_dict_to_observation(obs) for obs in updated_existing], - version=current_version + 1, - changes={ - "note": "Updated existing observations, no new patterns found", - "updated": len(updated_existing), - }, - phases_completed=phases_completed + ["seed_empty"], - duration_ms=int((time.time() - start_time) * 1000), - ) - return MentalModelReflectResult( - observations=[_dict_to_observation(obs) for obs in existing_observations], - version=current_version + 1, # Always increment version when refresh runs - changes={"note": "No memories available for analysis"}, - phases_completed=["seed_empty"], - duration_ms=int((time.time() - start_time) * 1000), - ) - - # Generate NEW candidate observations (pass existing to avoid rediscovering them) - seed_result = await _run_seed_phase( - llm_config=llm_config, - memories=seed_memories, - topic=topic or mental_model_name, - max_candidates=max_candidates, - reflect_id=reflect_id, - existing_observations=existing_observations, # Pass existing to skip them - ) - candidates = seed_result.candidates - total_usage = total_usage + seed_result.token_usage - - phase1_duration = int((time.time() - phase1_start) * 1000) - logger.info( - f"[MM-REFLECT {reflect_id}] Phase 1 complete: {len(candidates)} NEW candidates from {len(seed_memories)} memories ({phase1_duration}ms)" - ) - phases_completed.append("seed") - - # If no new candidates but we updated existing, return those - if not candidates and updated_existing: - logger.info(f"[MM-REFLECT {reflect_id}] No new patterns found, returning updated existing observations") - return MentalModelReflectResult( - observations=[_dict_to_observation(obs) for obs in updated_existing], - version=current_version + 1, - changes={ - "note": "Updated existing observations with new evidence, no new patterns discovered", - "updated": len(updated_existing), - "contradicted": contradicted_observations, - }, - phases_completed=phases_completed, - duration_ms=int((time.time() - start_time) * 1000), - memories_analyzed=len(seed_memories), - input_tokens=total_usage.input_tokens, - output_tokens=total_usage.output_tokens, - total_tokens=total_usage.total_tokens, - ) - - # If no candidates and no existing, return empty - if not candidates: - logger.warning(f"[MM-REFLECT {reflect_id}] No candidates generated in seed phase") - return MentalModelReflectResult( - observations=[_dict_to_observation(obs) for obs in existing_observations], - version=current_version + 1, # Always increment version when refresh runs - changes={"note": "No candidate observations could be generated"}, - phases_completed=phases_completed, - duration_ms=int((time.time() - start_time) * 1000), - memories_analyzed=len(seed_memories), - input_tokens=total_usage.input_tokens, - output_tokens=total_usage.output_tokens, - total_tokens=total_usage.total_tokens, - ) - - # ========================================================================== - # PHASE 2: EVIDENCE HUNT (for new candidates only) - # ========================================================================== - logger.info(f"[MM-REFLECT {reflect_id}] Phase 2: EVIDENCE HUNT - Searching for evidence") - phase2_start = time.time() - - candidates_with_evidence = await _run_evidence_hunt_phase( - candidates=candidates, - recall_fn=recall_fn, - reflect_id=reflect_id, - ) - - phase2_duration = int((time.time() - phase2_start) * 1000) - logger.info( - f"[MM-REFLECT {reflect_id}] Phase 2 complete: Evidence gathered for {len(candidates_with_evidence)} candidates ({phase2_duration}ms)" - ) - phases_completed.append("evidence_hunt") - - # ========================================================================== - # PHASE 3: VALIDATE & REFINE (for new candidates only) - # ========================================================================== - logger.info(f"[MM-REFLECT {reflect_id}] Phase 3: VALIDATE - Validating candidates") - phase3_start = time.time() - - validate_result = await _run_validate_phase( - llm_config=llm_config, - candidates_with_evidence=candidates_with_evidence, - reflect_id=reflect_id, - ) - validated_observations = validate_result.verified_observations - total_usage = total_usage + validate_result.token_usage - - phase3_duration = int((time.time() - phase3_start) * 1000) - logger.info( - f"[MM-REFLECT {reflect_id}] Phase 3 complete: {len(validated_observations)} observations validated ({phase3_duration}ms)" - ) - phases_completed.append("validate") - - # ========================================================================== - # PHASE 4: COMPARE & MERGE - # ========================================================================== - logger.info(f"[MM-REFLECT {reflect_id}] Phase 4: COMPARE - Merging updated existing + new observations") - phase4_start = time.time() - - # Use updated existing (with new evidence) instead of original existing - observations_for_compare = updated_existing if updated_existing else existing_observations - - compare_result = await _run_compare_phase( - llm_config=llm_config, - existing_observations=observations_for_compare, - new_observations=validated_observations, - reflect_id=reflect_id, - ) - final_observations = compare_result.observations - changes = compare_result.changes - total_usage = total_usage + compare_result.token_usage - - # Add contradiction info to changes - if contradicted_observations: - changes["contradicted"] = contradicted_observations - - phase4_duration = int((time.time() - phase4_start) * 1000) - logger.info( - f"[MM-REFLECT {reflect_id}] Phase 4 complete: {len(final_observations)} final observations ({phase4_duration}ms)" - ) - phases_completed.append("compare") - - # ========================================================================== - # FINALIZE - # ========================================================================== - total_duration = int((time.time() - start_time) * 1000) - new_version = current_version + 1 - - logger.info( - f"[MM-REFLECT {reflect_id}] Complete: " - f"v{current_version}→v{new_version}, " - f"{len(final_observations)} observations, " - f"{total_duration}ms total" - ) - - return MentalModelReflectResult( - observations=final_observations, - version=new_version, - changes=changes, - phases_completed=phases_completed, - duration_ms=total_duration, - memories_analyzed=len(seed_memories), - candidates_generated=len(candidates), - candidates_validated=len(validated_observations), - input_tokens=total_usage.input_tokens, - output_tokens=total_usage.output_tokens, - total_tokens=total_usage.total_tokens, - ) - - -async def _run_seed_phase( - llm_config: "LLMProvider", - memories: list[dict], - topic: str, - max_candidates: int, - reflect_id: str, - existing_observations: list[dict] | None = None, -) -> SeedPhaseResult: - """Phase 1: Generate NEW candidate observations from diverse memories. - - If existing_observations are provided, the LLM will be instructed to skip - patterns that are already tracked, focusing only on genuinely new discoveries. - """ - prompt = build_seed_phase_prompt(memories, topic, existing_observations) - - try: - response, token_usage = await llm_config.call( - messages=[ - {"role": "system", "content": SEED_PHASE_SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - response_format=SeedPhaseOutput, - scope="mm_reflect_seed", - return_usage=True, - ) - - usage = PhaseTokenUsage( - input_tokens=token_usage.input_tokens if token_usage else 0, - output_tokens=token_usage.output_tokens if token_usage else 0, - total_tokens=token_usage.total_tokens if token_usage else 0, - ) - - # Parse response - if hasattr(response, "candidates"): - candidates = response.candidates[:max_candidates] - elif isinstance(response, dict) and "candidates" in response: - candidates = [ - CandidateObservation( - content=c.get("content", ""), - seed_memory_ids=c.get("seed_memory_ids", []), - ) - for c in response["candidates"][:max_candidates] - ] - else: - # Try to parse as JSON - try: - data = json.loads(str(response)) - candidates = [ - CandidateObservation( - content=c.get("content", ""), - seed_memory_ids=c.get("seed_memory_ids", []), - ) - for c in data.get("candidates", [])[:max_candidates] - ] - except (json.JSONDecodeError, TypeError): - logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse seed phase response") - candidates = [] - - return SeedPhaseResult(candidates=candidates, token_usage=usage) - - except Exception as e: - logger.error(f"[MM-REFLECT {reflect_id}] Seed phase failed: {e}") - return SeedPhaseResult() - - -def _parse_update_existing_response(response: Any, reflect_id: str) -> UpdateExistingPhaseOutput: - """Parse LLM response into UpdateExistingPhaseOutput. - - Handles multiple response formats: Pydantic model, dict, or JSON string. - """ - # Already a Pydantic model - if isinstance(response, UpdateExistingPhaseOutput): - return response - - # Dict response - validate and convert - if isinstance(response, dict): - try: - return UpdateExistingPhaseOutput.model_validate(response) - except Exception as e: - logger.warning(f"[MM-REFLECT {reflect_id}] Failed to validate dict response: {e}") - return UpdateExistingPhaseOutput() - - # String response - try to parse as JSON - try: - data = json.loads(str(response)) - return UpdateExistingPhaseOutput.model_validate(data) - except (json.JSONDecodeError, TypeError, Exception) as e: - logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse update existing response: {e}") - return UpdateExistingPhaseOutput() - - -async def _run_update_existing_phase( - llm_config: "LLMProvider", - existing_observations: list[dict], - recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], - reflect_id: str, -) -> UpdateExistingResult: - """Phase 0: Search for new evidence for existing observations. - - For each existing observation: - 1. Search for new supporting evidence - 2. Search for contradicting evidence - 3. Extract new quotes and add to the observation - 4. Flag observations with strong contradictions - """ - if not existing_observations: - return UpdateExistingResult() - - # Search for evidence for all existing observations in parallel - async def search_evidence_for_observation(obs: dict) -> ObservationWithEvidence: - content = obs.get("content", "") - existing_evidence = obs.get("evidence", []) - existing_memory_ids = {e.get("memory_id") for e in existing_evidence if isinstance(e, dict)} - - # Search for supporting and contradicting evidence - supporting_query = f"evidence supporting: {content}" - contradicting_query = f"evidence against: {content}" - - supporting_result, contradicting_result = await asyncio.gather( - recall_fn(supporting_query, 2048), - recall_fn(contradicting_query, 2048), - return_exceptions=True, - ) - - supporting_memories: list[dict] = [] - contradicting_memories: list[dict] = [] - - if isinstance(supporting_result, dict) and "memories" in supporting_result: - # Filter out memories we already have evidence from - supporting_memories = [ - m - for m in supporting_result["memories"] - if isinstance(m, dict) and m.get("id") not in existing_memory_ids - ] - if isinstance(contradicting_result, dict) and "memories" in contradicting_result: - contradicting_memories = [m for m in contradicting_result["memories"] if isinstance(m, dict)] - - return ObservationWithEvidence( - observation=obs, - supporting_memories=supporting_memories, - contradicting_memories=contradicting_memories, - ) - - # Run all searches in parallel - tasks = [search_evidence_for_observation(obs) for obs in existing_observations] - search_results = await asyncio.gather(*tasks, return_exceptions=True) - - # Filter out exceptions and collect valid results - valid_results: list[ObservationWithEvidence] = [r for r in search_results if isinstance(r, ObservationWithEvidence)] - - # Log any errors - errors = [r for r in search_results if isinstance(r, Exception)] - if errors: - logger.warning(f"[MM-REFLECT {reflect_id}] {len(errors)} evidence search errors: {errors[:3]}") - - # If no new evidence found for any observation, return originals unchanged - has_new_evidence = any(result.supporting_memories or result.contradicting_memories for result in valid_results) - - if not has_new_evidence: - logger.info(f"[MM-REFLECT {reflect_id}] No new evidence found for existing observations") - return UpdateExistingResult(updated_observations=existing_observations) - - # Build prompt data for LLM - prompt_data = [ - { - "observation": result.observation, - "supporting_memories": result.supporting_memories, - "contradicting_memories": result.contradicting_memories, - } - for result in valid_results - ] - - # Call LLM to extract quotes from new evidence - try: - prompt = build_update_existing_prompt(prompt_data) - - response, token_usage = await llm_config.call( - messages=[ - {"role": "system", "content": UPDATE_EXISTING_SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - response_format=UpdateExistingPhaseOutput, - scope="mm_reflect_update_existing", - return_usage=True, - ) - - usage = PhaseTokenUsage( - input_tokens=token_usage.input_tokens if token_usage else 0, - output_tokens=token_usage.output_tokens if token_usage else 0, - total_tokens=token_usage.total_tokens if token_usage else 0, - ) - - # Parse response into typed model - parsed_response = _parse_update_existing_response(response, reflect_id) - - if not parsed_response.updated_observations: - logger.info(f"[MM-REFLECT {reflect_id}] No updated observations in LLM response") - return UpdateExistingResult(updated_observations=existing_observations, token_usage=usage) - - # Build memory content map for quote verification - memory_content_map: dict[str, str] = {} - for result in valid_results: - for mem in result.supporting_memories + result.contradicting_memories: - mem_id = mem.get("id", "") - mem_content = mem.get("content", mem.get("text", "")) - if mem_id and mem_content: - memory_content_map[mem_id] = mem_content - - # Process updated observations - updated_observations: list[dict] = [] - contradicted_titles: list[str] = [] - total_new_evidence = 0 - - for i, updated in enumerate(parsed_response.updated_observations): - if i >= len(existing_observations): - break - - original_obs = existing_observations[i] - - # Check for contradiction - if updated.has_contradiction: - title = original_obs.get("title", f"Observation {i + 1}") - contradicted_titles.append(title) - logger.info(f"[MM-REFLECT {reflect_id}] Observation '{title}' flagged for contradiction") - - # Verify and add new evidence - existing_evidence = original_obs.get("evidence", []) - verified_new_evidence: list[dict] = [] - - for ev in updated.new_evidence: - memory_content = memory_content_map.get(ev.memory_id, "") - - # Verify quote exists in memory - if ( - ev.quote - and memory_content - and (ev.quote in memory_content or _fuzzy_quote_match(ev.quote, memory_content)) - ): - verified_new_evidence.append( - { - "memory_id": ev.memory_id, - "quote": ev.quote, - "relevance": ev.relevance, - "timestamp": ev.timestamp, - } - ) - - total_new_evidence += len(verified_new_evidence) - - # Merge existing and new evidence - merged_evidence = existing_evidence + verified_new_evidence - - # Create updated observation - updated_obs = { - **original_obs, - "evidence": merged_evidence, - } - updated_observations.append(updated_obs) - - # For any observations not in the response, keep them unchanged - for i in range(len(parsed_response.updated_observations), len(existing_observations)): - updated_observations.append(existing_observations[i]) - - logger.info( - f"[MM-REFLECT {reflect_id}] Updated {len(updated_observations)} observations, " - f"added {total_new_evidence} new evidence items" - ) - - return UpdateExistingResult( - updated_observations=updated_observations, - contradicted_titles=contradicted_titles, - token_usage=usage, - ) - - except Exception as e: - logger.error(f"[MM-REFLECT {reflect_id}] Update existing phase failed: {e}", exc_info=True) - return UpdateExistingResult(updated_observations=existing_observations) - - -async def _run_evidence_hunt_phase( - candidates: list[CandidateObservation], - recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], - reflect_id: str, -) -> list[CandidateWithEvidence]: - """Phase 2: For each candidate, search for supporting and contradicting evidence.""" - results: list[CandidateWithEvidence] = [] - - # Run evidence searches in parallel for all candidates - async def search_evidence_for_candidate(candidate: CandidateObservation) -> CandidateWithEvidence: - # Search for supporting evidence - supporting_query = f"evidence supporting: {candidate.content}" - contradicting_query = f"evidence against: {candidate.content}" - - supporting_result, contradicting_result = await asyncio.gather( - recall_fn(supporting_query, 2048), - recall_fn(contradicting_query, 2048), - return_exceptions=True, - ) - - supporting_memories = [] - contradicting_memories = [] - - if isinstance(supporting_result, dict) and "memories" in supporting_result: - supporting_memories = supporting_result["memories"] - if isinstance(contradicting_result, dict) and "memories" in contradicting_result: - contradicting_memories = contradicting_result["memories"] - - return CandidateWithEvidence( - candidate=candidate, - supporting_memories=supporting_memories, - contradicting_memories=contradicting_memories, - ) - - # Run all searches in parallel - tasks = [search_evidence_for_candidate(c) for c in candidates] - gather_results = await asyncio.gather(*tasks, return_exceptions=True) - - # Filter out exceptions - valid_results: list[CandidateWithEvidence] = [r for r in gather_results if isinstance(r, CandidateWithEvidence)] - - logger.info(f"[MM-REFLECT {reflect_id}] Evidence hunt: {len(valid_results)}/{len(candidates)} candidates processed") - return valid_results - - -async def _run_validate_phase( - llm_config: "LLMProvider", - candidates_with_evidence: list[CandidateWithEvidence], - reflect_id: str, -) -> ValidatePhaseResult: - """Phase 3: Validate candidates and extract exact quotes.""" - # Convert to dict format for prompt - candidates_data = [ - { - "candidate": { - "content": c.candidate.content, - }, - "supporting_memories": c.supporting_memories, - "contradicting_memories": c.contradicting_memories, - } - for c in candidates_with_evidence - ] - - prompt = build_validate_phase_prompt(candidates_data) - - try: - response, token_usage = await llm_config.call( - messages=[ - {"role": "system", "content": VALIDATE_PHASE_SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - response_format=ValidatePhaseOutput, - scope="mm_reflect_validate", - return_usage=True, - ) - - usage = PhaseTokenUsage( - input_tokens=token_usage.input_tokens if token_usage else 0, - output_tokens=token_usage.output_tokens if token_usage else 0, - total_tokens=token_usage.total_tokens if token_usage else 0, - ) - - # Parse response - if hasattr(response, "observations"): - observations = response.observations - elif isinstance(response, dict) and "observations" in response: - observations = response["observations"] - else: - # Try to parse as JSON - try: - data = json.loads(str(response)) - observations = data.get("observations", []) - except (json.JSONDecodeError, TypeError): - logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse validate phase response") - observations = [] - - # Build memory content map for quote verification - memory_content_map: dict[str, str] = {} - for cwe in candidates_with_evidence: - for mem in cwe.supporting_memories + cwe.contradicting_memories: - mem_id = mem.get("id", "") - content = mem.get("content", mem.get("text", "")) - if mem_id and content: - memory_content_map[mem_id] = content - - # Verify quotes in observations - verified_observations = [] - for obs in observations: - evidence = obs.get("evidence", []) - verified_evidence = [] - - for ev in evidence: - mem_id = ev.get("memory_id", "") - quote = ev.get("quote", "") - memory_content = memory_content_map.get(mem_id, "") - - # Check if quote exists in memory (allow partial match for flexibility) - if quote and memory_content and (quote in memory_content or _fuzzy_quote_match(quote, memory_content)): - verified_evidence.append(ev) - else: - logger.debug(f"[MM-REFLECT {reflect_id}] Quote verification failed for memory {mem_id}") - - if verified_evidence: - obs["evidence"] = verified_evidence - verified_observations.append(obs) - else: - logger.debug( - f"[MM-REFLECT {reflect_id}] Observation discarded - no verified evidence: {obs.get('content', '')[:50]}" - ) - - return ValidatePhaseResult(verified_observations=verified_observations, token_usage=usage) - - except Exception as e: - logger.error(f"[MM-REFLECT {reflect_id}] Validate phase failed: {e}") - return ValidatePhaseResult() - - -def _fuzzy_quote_match(quote: str, content: str, threshold: float = 0.8) -> bool: - """Check if a quote roughly matches content (handles minor LLM variations).""" - # Normalize both strings - quote_words = set(quote.lower().split()) - content_words = set(content.lower().split()) - - if not quote_words: - return False - - # Check word overlap - overlap = len(quote_words & content_words) - similarity = overlap / len(quote_words) - - return similarity >= threshold - - -async def _run_compare_phase( - llm_config: "LLMProvider", - existing_observations: list[dict], - new_observations: list[dict], - reflect_id: str, -) -> ComparePhaseResult: - """Phase 4: Merge new observations with existing mental model.""" - # If no existing observations, just convert new ones - if not existing_observations: - final_obs = [_dict_to_observation(obs) for obs in new_observations] - changes = { - "added": [obs.get("content", "") for obs in new_observations], - "kept": [], - "updated": [], - "removed": [], - "merged": [], - } - return ComparePhaseResult(observations=final_obs, changes=changes) - - # If no new observations, keep existing - if not new_observations: - final_obs = [_dict_to_observation(obs) for obs in existing_observations] - changes = { - "added": [], - "kept": [obs.get("content", obs.get("text", "")) for obs in existing_observations], - "updated": [], - "removed": [], - "merged": [], - } - return ComparePhaseResult(observations=final_obs, changes=changes) - - prompt = build_compare_phase_prompt(existing_observations, new_observations) - - try: - response, token_usage = await llm_config.call( - messages=[ - {"role": "system", "content": COMPARE_PHASE_SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - response_format=ComparePhaseOutput, - scope="mm_reflect_compare", - return_usage=True, - ) - - usage = PhaseTokenUsage( - input_tokens=token_usage.input_tokens if token_usage else 0, - output_tokens=token_usage.output_tokens if token_usage else 0, - total_tokens=token_usage.total_tokens if token_usage else 0, - ) - - # Parse response - if hasattr(response, "observations"): - observations_data = response.observations - changes = response.changes if hasattr(response, "changes") else {} - elif isinstance(response, dict): - observations_data = response.get("observations", []) - changes = response.get("changes", {}) - else: - # Try to parse as JSON - try: - data = json.loads(str(response)) - observations_data = data.get("observations", []) - changes = data.get("changes", {}) - except (json.JSONDecodeError, TypeError): - logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse compare phase response") - # Fallback: just use new observations - observations_data = new_observations - changes = {"note": "Compare phase parsing failed, using new observations"} - - # Convert to Observation objects - final_observations = [_dict_to_observation(obs) for obs in observations_data] - return ComparePhaseResult(observations=final_observations, changes=changes, token_usage=usage) - - except Exception as e: - logger.error(f"[MM-REFLECT {reflect_id}] Compare phase failed: {e}") - # Fallback: merge by keeping all - all_obs = existing_observations + new_observations - final_obs = [_dict_to_observation(obs) for obs in all_obs] - changes = {"note": f"Compare phase failed: {e}, keeping all observations"} - return ComparePhaseResult(observations=final_obs, changes=changes) - - -def _dict_to_observation(data: dict) -> Observation: - """Convert a dict to an Observation model.""" - # Handle both new format (title, content, evidence) and legacy format (title, text, memory_ids) - title = data.get("title", "") - content = data.get("content", "") - - if not content: - # Legacy format: use text as content - text = data.get("text", "") - content = text - - if not title: - # Generate title from content (first ~50 chars) - title = content[:50].strip() + ("..." if len(content) > 50 else "") - - # Parse evidence - evidence: list[ObservationEvidence] = [] - evidence_data = data.get("evidence", []) - - if evidence_data: - for ev in evidence_data: - try: - timestamp = ev.get("timestamp") - if isinstance(timestamp, str): - timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - elif timestamp is None: - timestamp = datetime.now(timezone.utc) - - evidence.append( - ObservationEvidence( - memory_id=ev.get("memory_id", ""), - quote=ev.get("quote", ""), - relevance=ev.get("relevance", ""), - timestamp=timestamp, - ) - ) - except Exception: - pass - else: - # Legacy format: memory_ids without quotes - memory_ids = data.get("memory_ids", []) or data.get("fact_ids", []) - for mid in memory_ids: - evidence.append( - ObservationEvidence( - memory_id=mid, - quote="[migrated - quote not available]", - relevance="[migrated]", - timestamp=datetime.now(timezone.utc), - ) - ) - - # Parse created_at - created_at = data.get("created_at") - if isinstance(created_at, str): - try: - created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) - except ValueError: - created_at = datetime.now(timezone.utc) - elif created_at is None: - created_at = datetime.now(timezone.utc) - - return Observation( - title=title, - content=content, - evidence=evidence, - created_at=created_at, - ) diff --git a/hindsight-api/hindsight_api/engine/reflect/models.py b/hindsight-api/hindsight_api/engine/reflect/models.py index 02a16868..71e6d044 100644 --- a/hindsight-api/hindsight_api/engine/reflect/models.py +++ b/hindsight-api/hindsight_api/engine/reflect/models.py @@ -104,11 +104,15 @@ class ReflectAgentResult(BaseModel): ) iterations: int = Field(default=0, description="Number of iterations taken") tools_called: int = Field(default=0, description="Total number of tool calls made") - mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated") tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made") llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made") used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer") - used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer") + used_reflection_ids: list[str] = Field( + default_factory=list, description="Validated reflection IDs actually used in answer" + ) + used_mental_model_ids: list[str] = Field( + default_factory=list, description="Validated mental model IDs actually used in answer" + ) directives_applied: list[DirectiveInfo] = Field( default_factory=list, description="Directive mental models that affected this reflection" ) diff --git a/hindsight-api/hindsight_api/engine/reflect/observations.py b/hindsight-api/hindsight_api/engine/reflect/observations.py index 67708b83..12035360 100644 --- a/hindsight-api/hindsight_api/engine/reflect/observations.py +++ b/hindsight-api/hindsight_api/engine/reflect/observations.py @@ -184,65 +184,3 @@ def compute_trend( return Trend.WEAKENING else: return Trend.STABLE - - -class CandidateObservation(BaseModel): - """A candidate observation generated during the seed phase. - - Candidates are preliminary observations that need evidence validation - before becoming full observations. - """ - - content: str = Field(description="The proposed observation content") - seed_memory_ids: list[str] = Field(default_factory=list, description="Memory IDs that inspired this candidate") - - -class CandidateWithEvidence(BaseModel): - """A candidate observation with gathered supporting and contradicting evidence.""" - - candidate: CandidateObservation - supporting_memories: list[dict] = Field(default_factory=list, description="Memories that support this observation") - contradicting_memories: list[dict] = Field( - default_factory=list, description="Memories that contradict this observation" - ) - - -class MentalModelSnapshot(BaseModel): - """A versioned snapshot of a mental model's observations. - - Used for tracking changes over time and enabling diff views. - """ - - version: int = Field(description="Version number (1-indexed)") - observations: list[Observation] = Field(default_factory=list, description="Observations at this version") - created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), description="When this version was created" - ) - reflect_summary: str | None = Field(default=None, description="Summary of changes in this version") - - -def verify_evidence_quotes( - observation: Observation, - memories: dict[str, str], -) -> tuple[bool, list[str]]: - """Verify that all evidence quotes exist in the referenced memories. - - Args: - observation: The observation to verify - memories: Dict mapping memory_id to memory content - - Returns: - Tuple of (is_valid, list of error messages) - """ - errors = [] - - for evidence in observation.evidence: - memory_content = memories.get(evidence.memory_id) - if memory_content is None: - errors.append(f"Memory {evidence.memory_id} not found") - continue - - if evidence.quote not in memory_content: - errors.append(f"Quote not found in memory {evidence.memory_id}: '{evidence.quote[:50]}...'") - - return len(errors) == 0, errors diff --git a/hindsight-api/hindsight_api/engine/reflect/prompts.py b/hindsight-api/hindsight_api/engine/reflect/prompts.py index 8ff572d9..71099ed4 100644 --- a/hindsight-api/hindsight_api/engine/reflect/prompts.py +++ b/hindsight-api/hindsight_api/engine/reflect/prompts.py @@ -1,5 +1,10 @@ """ System prompts for the reflect agent. + +The reflect agent uses hierarchical retrieval: +1. search_reflections - User-curated summaries (highest quality) +2. search_mental_models - Consolidated knowledge with freshness awareness +3. recall - Raw facts as ground truth fallback """ import json @@ -11,7 +16,7 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]: Extract directive rules as a list of strings. Args: - directives: List of directive mental models with observations + directives: List of directives with name and content Returns: List of directive rule strings @@ -19,25 +24,34 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]: rules = [] for directive in directives: directive_name = directive.get("name", "") - observations = directive.get("observations", []) - if observations: - for obs in observations: - # Support both Pydantic Observation objects and dicts - if hasattr(obs, "title"): - title = obs.title - content = obs.content - else: - title = obs.get("title", "") - content = obs.get("content", "") - if title and content: - rules.append(f"**{title}**: {content}") - elif content: - rules.append(content) - elif directive_name: - # Fallback to description if no observations - desc = directive.get("description", "") - if desc: - rules.append(f"**{directive_name}**: {desc}") + # New format: directives have direct content field + content = directive.get("content", "") + if content: + if directive_name: + rules.append(f"**{directive_name}**: {content}") + else: + rules.append(content) + else: + # Legacy format: check for observations + observations = directive.get("observations", []) + if observations: + for obs in observations: + # Support both Pydantic Observation objects and dicts + if hasattr(obs, "title"): + title = obs.title + obs_content = obs.content + else: + title = obs.get("title", "") + obs_content = obs.get("content", "") + if title and obs_content: + rules.append(f"**{title}**: {obs_content}") + elif obs_content: + rules.append(obs_content) + elif directive_name: + # Fallback to description + desc = directive.get("description", "") + if desc: + rules.append(f"**{directive_name}**: {desc}") return rules @@ -111,24 +125,25 @@ def build_system_prompt_for_tools( bank_profile: dict[str, Any], context: str | None = None, directives: list[dict[str, Any]] | None = None, + has_reflections: bool = False, ) -> str: """ Build the system prompt for tool-calling reflect agent. - This is a simplified prompt since tools are defined separately via the tools parameter. + The agent uses hierarchical retrieval: + 1. search_reflections - User-curated summaries (try first, if available) + 2. search_mental_models - Consolidated knowledge with freshness + 3. recall - Raw facts as ground truth Args: bank_profile: Bank profile with name and mission context: Optional additional context directives: Optional list of directive mental models to inject as hard rules + has_reflections: Whether the bank has any reflections (skip if not) """ name = bank_profile.get("name", "Assistant") mission = bank_profile.get("mission", "") - no_info_rule = ( - "- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results" - ) - parts = [] # Inject directives at the VERY START for maximum prominence @@ -147,8 +162,7 @@ def build_system_prompt_for_tools( "## CRITICAL RULES", "- You must NEVER fabricate information that has no basis in retrieved data", "- You SHOULD synthesize, infer, and reason from the retrieved memories", - "- You MUST call recall() before saying you don't have information", - no_info_rule, + "- You MUST search before saying you don't have information", "", "## How to Reason", "- If memories mention someone did an activity, you can infer they likely enjoyed it", @@ -156,7 +170,56 @@ def build_system_prompt_for_tools( "- Be a thoughtful interpreter, not just a literal repeater", "- When the exact answer isn't stated, use what IS stated to give the best answer", "", - "## Query Strategy (IMPORTANT)", + "## HIERARCHICAL RETRIEVAL STRATEGY", + "", + ] + ) + + # Build retrieval levels based on what's available + if has_reflections: + parts.extend( + [ + "You have access to THREE levels of knowledge. Use them in this order:", + "", + "### 1. REFLECTIONS (search_reflections) - Try First", + "- User-curated summaries about specific topics", + "- HIGHEST quality - manually created and maintained", + "- If a relevant reflection exists and is FRESH, it may fully answer the question", + "- Check `is_stale` field - if stale, also verify with lower levels", + "", + "### 2. MENTAL MODELS (search_mental_models) - Second Priority", + "- Auto-consolidated knowledge from memories", + "- Check `is_stale` field - if stale, ALSO use recall() to verify", + "- Good for understanding patterns and summaries", + "", + "### 3. RAW FACTS (recall) - Ground Truth", + "- Individual memories (world facts and experiences)", + "- Use when: no reflections/models exist, they're stale, or you need specific details", + "- This is the source of truth that other levels are built from", + "", + ] + ) + else: + parts.extend( + [ + "You have access to TWO levels of knowledge. Use them in this order:", + "", + "### 1. MENTAL MODELS (search_mental_models) - Try First", + "- Auto-consolidated knowledge from memories", + "- Check `is_stale` field - if stale, ALSO use recall() to verify", + "- Good for understanding patterns and summaries", + "", + "### 2. RAW FACTS (recall) - Ground Truth", + "- Individual memories (world facts and experiences)", + "- Use when: no mental models exist, they're stale, or you need specific details", + "- This is the source of truth that mental models are built from", + "", + ] + ) + + parts.extend( + [ + "## Query Strategy", "recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:", "", "BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')", @@ -164,44 +227,41 @@ def build_system_prompt_for_tools( " 1. recall('lessons') - find all lesson-related memories", " 2. recall('teaching sessions') - alternative phrasing", " 3. recall('student progress') - find student-related memories", - " 4. recall('topics taught') - find subject matter", "", "Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.", - "- Questions about patterns → search for the individual instances first", - "- Questions comparing things → search for each thing separately", - "- Questions about relationships → search for each party involved", "", "## Workflow", ] ) - # Answer mode: include mental model lookup in workflow + if has_reflections: + parts.extend( + [ + "1. First, try search_reflections() - check if a curated summary exists", + "2. If no reflection or it's stale, try search_mental_models() for consolidated knowledge", + "3. If mental models are stale OR you need specific details, use recall() for raw facts", + "4. Use expand() if you need more context on specific memories", + "5. When ready, call done() with your answer and supporting IDs", + ] + ) + else: + parts.extend( + [ + "1. First, try search_mental_models() - check for consolidated knowledge", + "2. If mental models are stale OR you need specific details, use recall() for raw facts", + "3. Use expand() if you need more context on specific memories", + "4. When ready, call done() with your answer and supporting IDs", + ] + ) + parts.extend( [ - "1. Review the pre-fetched mental models for relevant synthesized knowledge", - "2. If relevant, call get_mental_model(model_id) for full observations", - "3. DECOMPOSE the question into component searches (see Query Strategy above)", - " - Identify entities and concepts in the question", - " - Search for each separately with targeted queries", - "4. Run multiple recall() calls - don't just echo the user's question", - "5. Use expand() if you need more context on specific memories", - "6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so", - "7. When ready, call done() with your answer and supporting memory_ids", - "", - "## When to Use learn() - IMPORTANT", - "ACTIVELY look for opportunities to use learn() when you discover:", - "- A person mentioned in 2+ memories who has no mental model yet", - "- A project or concept the user asks about that has no mental model", - "- A pattern or topic worth tracking for future questions", - "", - "DO NOT wait to be asked - proactively create models when you see the need.", - "Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')", "", "## Output Format: Plain Text Answer", "Call done() with a plain text 'answer' field.", "- Do NOT use markdown formatting", "- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text", - "- Put memory IDs ONLY in the memory_ids array parameter, not in the answer", + "- Put IDs ONLY in the memory_ids/reflection_ids/mental_model_ids arrays, not in the answer", ] ) @@ -295,9 +355,10 @@ def build_agent_prompt( else: parts.append( "\n## Instructions\n" - "Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. " - "If a relevant model exists, use get_mental_model(model_id) to get its observations. " - "Then use recall(query) for specific details not covered by mental models." + "Start by searching for relevant information using the hierarchical retrieval strategy:\n" + "1. Try search_reflections() first for curated summaries\n" + "2. Try search_mental_models() for consolidated knowledge\n" + "3. Use recall() for specific details or to verify stale data" ) return "\n".join(parts) @@ -377,386 +438,3 @@ Your approach: Only say "I don't have information" if the retrieved data is truly unrelated to the question. Do NOT fabricate information that has no basis in the retrieved data.""" - - -# ============================================================================= -# 4-Phase Mental Model Reflect Prompts -# ============================================================================= - -SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations. - -Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic. - -## Important: Avoid Redundancy -If existing observations are provided, DO NOT generate candidates that are essentially the same. -Focus on discovering NEW patterns not already covered by existing observations. - -## Rules -- Generate 5-15 candidate observations for NEW patterns only -- Each candidate should be specific and testable (can be supported or contradicted by evidence) -- Note which memory IDs inspired each candidate (these are seeds, not final evidence) -- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better -- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for -- Skip patterns that are already covered by existing observations - -## Output Format -Return a JSON array of candidate observations: -```json -{ - "candidates": [ - { - "content": "The specific observation/belief/pattern - be detailed and specific", - "seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"] - } - ] -} -``` - -Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations. -Prefer candidates with MORE seed memories - they're more likely to be real patterns. -Return an empty candidates array if no genuinely new patterns are found.""" - - -def build_seed_phase_prompt( - memories: list[dict], - topic: str | None = None, - existing_observations: list[dict] | None = None, -) -> str: - """Build the user prompt for the seed phase. - - Args: - memories: List of memories to analyze - topic: Optional topic focus for the mental model - existing_observations: Optional list of existing observations to avoid rediscovering - """ - parts = [] - - if topic: - parts.append(f"## Topic Focus\n{topic}\n") - - # Include existing observations so we don't rediscover them - if existing_observations: - parts.append("## Existing Observations (DO NOT regenerate these)") - parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n") - for i, obs in enumerate(existing_observations, 1): - title = obs.get("title", "") - content = obs.get("content", "") - parts.append(f"{i}. **{title}**: {content}\n") - parts.append("") - - parts.append("## Memories to Analyze") - parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n") - - for mem in memories: - mem_id = mem.get("id", "unknown") - content = mem.get("content", mem.get("text", "")) - timestamp = mem.get("timestamp", mem.get("created_at", "")) - parts.append(f"[{mem_id}] ({timestamp}): {content}\n") - - parts.append("\n## Instructions") - if existing_observations: - parts.append("Generate candidate observations for NEW patterns not already covered above.") - parts.append("If all patterns are already covered by existing observations, return an empty candidates array.") - else: - parts.append("Generate candidate observations based on patterns you see in these memories.") - parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.") - - return "\n".join(parts) - - -VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence. - -For each candidate, you have: -- Supporting memories (evidence FOR the observation) -- Contradicting memories (evidence AGAINST the observation) - -## Your Task -1. Evaluate each candidate based on the evidence -2. For valid candidates, extract EXACT QUOTES from supporting memories -3. Discard candidates with insufficient or contradicting evidence -4. Merge similar candidates into single, refined observations - -## Rules for Quotes -- Quotes must be EXACT text from the memory, not paraphrased -- Each quote should directly support the observation -- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+) -- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong -- Stronger observations have more supporting evidence - aim for comprehensive coverage - -## Output Format -Return validated observations with evidence: -```json -{ - "observations": [ - { - "title": "Short descriptive title (3-8 words) - like a headline", - "content": "The full observation content - detailed explanation of the pattern/belief", - "evidence": [ - { - "memory_id": "exact_memory_id", - "quote": "Exact quote from the memory text", - "relevance": "Brief explanation of how this supports the observation", - "timestamp": "2024-01-15T10:00:00Z" - } - ] - } - ], - "discarded": [ - { - "content": "The discarded candidate", - "reason": "Why it was discarded (insufficient evidence, contradicted, etc.)" - } - ], - "merged": [ - { - "from": ["candidate 1 content", "candidate 2 content"], - "into": "The merged observation content" - } - ] -} -``` - -## Title Guidelines -- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast") -- NOT a truncated version of the content -- Think of it as a category/tag for the observation - -Be rigorous: only keep observations with clear, verifiable evidence from multiple memories.""" - - -def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str: - """Build the user prompt for the validate phase.""" - parts = ["## Candidates to Validate\n"] - - for i, item in enumerate(candidates_with_evidence, 1): - candidate = item.get("candidate", {}) - supporting = item.get("supporting_memories", []) - contradicting = item.get("contradicting_memories", []) - - parts.append(f"### Candidate {i}: {candidate.get('content', '')}") - - if supporting: - parts.append("\n**Supporting Evidence:**") - for mem in supporting: - mem_id = mem.get("id", "unknown") - content = mem.get("content", mem.get("text", "")) - timestamp = mem.get("timestamp", mem.get("created_at", "")) - parts.append(f"- [{mem_id}] ({timestamp}): {content}") - - if contradicting: - parts.append("\n**Contradicting Evidence:**") - for mem in contradicting: - mem_id = mem.get("id", "unknown") - content = mem.get("content", mem.get("text", "")) - timestamp = mem.get("timestamp", mem.get("created_at", "")) - parts.append(f"- [{mem_id}] ({timestamp}): {content}") - - if not supporting and not contradicting: - parts.append("\n*No additional evidence found*") - - parts.append("") - - parts.append("## Instructions") - parts.append("1. Evaluate each candidate based on its evidence") - parts.append("2. Keep candidates with strong supporting evidence") - parts.append("3. Discard candidates with no evidence or strong contradictions") - parts.append("4. Merge similar candidates") - parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence") - - return "\n".join(parts) - - -COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model. - -You have: -- EXISTING observations (from the current mental model) -- NEW observations (from this reflect cycle) - -## Your Task -Produce the final, complete mental model by: -1. Keeping existing observations that are still valid -2. Updating existing observations with new evidence (ADD new evidence to existing) -3. Adding new observations that don't overlap with existing -4. Removing existing observations that are contradicted by new evidence -5. Merging overlapping observations - -## Rules -- The final model should have no contradictions -- Each observation must have evidence with exact quotes -- COMBINE evidence from both existing and new observations -- If an existing observation has new supporting evidence, ADD ALL the new evidence to it -- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great) -- Observations with more evidence are more reliable - don't limit the number of quotes - -## Output Format -Return the complete, final mental model: -```json -{ - "observations": [ - { - "title": "Short descriptive title (3-8 words)", - "content": "Full observation content - detailed explanation", - "evidence": [ - { - "memory_id": "id", - "quote": "exact quote", - "relevance": "explanation", - "timestamp": "ISO timestamp" - } - ], - "created_at": "ISO timestamp of when observation was first created" - } - ], - "changes": { - "kept": ["Observation that was kept unchanged"], - "updated": [{"from": "old content", "to": "new content", "reason": "why"}], - "added": ["New observation that was added"], - "removed": [{"content": "removed observation", "reason": "why removed"}], - "merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}] - } -} -```""" - - -def build_compare_phase_prompt( - existing_observations: list[dict], - new_observations: list[dict], -) -> str: - """Build the user prompt for the compare phase.""" - parts = [] - - parts.append("## Existing Mental Model Observations") - if existing_observations: - for i, obs in enumerate(existing_observations, 1): - title = obs.get("title", "") - content = obs.get("content", obs.get("text", "")) - evidence = obs.get("evidence", []) - parts.append(f"\n### Existing {i}: {title}") - parts.append(f"Content: {content}") - if evidence: - parts.append(f"Evidence ({len(evidence)} items):") - for ev in evidence[:5]: # Show max 5 evidence items - parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"') - if len(evidence) > 5: - parts.append(f" ... and {len(evidence) - 5} more") - else: - parts.append("*No existing observations*") - - parts.append("\n## New Observations from This Reflect") - if new_observations: - for i, obs in enumerate(new_observations, 1): - title = obs.get("title", "") - content = obs.get("content", "") - evidence = obs.get("evidence", []) - parts.append(f"\n### New {i}: {title}") - parts.append(f"Content: {content}") - if evidence: - parts.append(f"Evidence ({len(evidence)} items):") - for ev in evidence: - parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"') - else: - parts.append("*No new observations*") - - parts.append("\n## Instructions") - parts.append("Merge these into a coherent, non-contradictory mental model.") - parts.append("Preserve all valid evidence. Remove stale or contradicted observations.") - - return "\n".join(parts) - - -# ============================================================================= -# UPDATE EXISTING Phase Prompts (for diff-based refresh) -# ============================================================================= - -UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence. - -For each existing observation, you have been given: -- The original observation (title, content, existing evidence) -- Newly found supporting memories -- Newly found contradicting memories - -## Your Task -1. Extract EXACT QUOTES from new supporting memories to add to the observation -2. Flag observations with strong contradicting evidence for potential removal -3. Keep existing evidence intact - only ADD new evidence - -## Rules for Quotes -- Quotes must be EXACT text from the memory, not paraphrased -- Each quote should directly support the observation -- Include ALL relevant quotes from the new memories - -## Output Format -Return updated observations with new evidence: -```json -{ - "updated_observations": [ - { - "title": "Original title", - "content": "Original content", - "existing_evidence_count": 5, - "new_evidence": [ - { - "memory_id": "exact_memory_id", - "quote": "Exact quote from the memory text", - "relevance": "Brief explanation of how this supports the observation", - "timestamp": "2024-01-15T10:00:00Z" - } - ], - "has_contradiction": false, - "contradiction_note": null - } - ] -} -``` - -If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note.""" - - -def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str: - """Build the user prompt for the update existing phase. - - Args: - observations_with_evidence: List of existing observations with new evidence found - """ - parts = ["## Existing Observations to Update\n"] - - for i, item in enumerate(observations_with_evidence, 1): - obs = item.get("observation", {}) - supporting = item.get("supporting_memories", []) - contradicting = item.get("contradicting_memories", []) - - title = obs.get("title", "") - content = obs.get("content", "") - existing_evidence = obs.get("evidence", []) - - parts.append(f"### Observation {i}: {title}") - parts.append(f"Content: {content}") - parts.append(f"Existing evidence count: {len(existing_evidence)}") - - if supporting: - parts.append("\n**New Supporting Memories:**") - for mem in supporting: - mem_id = mem.get("id", "unknown") - mem_content = mem.get("content", mem.get("text", "")) - timestamp = mem.get("timestamp", mem.get("created_at", "")) - parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}") - - if contradicting: - parts.append("\n**New Contradicting Memories:**") - for mem in contradicting: - mem_id = mem.get("id", "unknown") - mem_content = mem.get("content", mem.get("text", "")) - timestamp = mem.get("timestamp", mem.get("created_at", "")) - parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}") - - if not supporting and not contradicting: - parts.append("\n*No new evidence found*") - - parts.append("") - - parts.append("## Instructions") - parts.append("1. Extract EXACT quotes from new supporting memories") - parts.append("2. Flag observations with strong contradictions") - parts.append("3. Return the updated observations with new evidence added") - - return "\n".join(parts) diff --git a/hindsight-api/hindsight_api/engine/reflect/tools.py b/hindsight-api/hindsight_api/engine/reflect/tools.py index c7fba3ff..ef74ba24 100644 --- a/hindsight-api/hindsight_api/engine/reflect/tools.py +++ b/hindsight-api/hindsight_api/engine/reflect/tools.py @@ -1,16 +1,17 @@ """ Tool implementations for the reflect agent. + +Implements hierarchical retrieval: +1. search_reflections - User-curated summaries (highest quality) +2. search_mental_models - Consolidated knowledge with freshness +3. recall - Raw facts as ground truth """ import logging -import re import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any -from .models import MentalModelInput -from .observations import Observation, ObservationEvidence, Trend - if TYPE_CHECKING: from asyncpg import Connection @@ -19,156 +20,216 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) - -def generate_model_id(name: str) -> str: - """Generate a stable ID from mental model name.""" - # Normalize: lowercase, replace spaces/special chars with hyphens - normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - # Truncate to reasonable length - return normalized[:50] +# Mental model is considered stale if not updated in this many days +STALE_THRESHOLD_DAYS = 7 -def _parse_observations(observations_raw: list) -> list[Observation]: - """Parse raw observation dicts into typed Observation models.""" - observations: list[Observation] = [] - for obs in observations_raw: - if not isinstance(obs, dict): - continue - - try: - parsed = Observation( - title=obs.get("title", ""), - content=obs.get("content", ""), - evidence=[ - ObservationEvidence( - memory_id=ev.get("memory_id", ""), - quote=ev.get("quote", ""), - relevance=ev.get("relevance", ""), - timestamp=ev.get("timestamp"), - ) - for ev in obs.get("evidence", []) - if isinstance(ev, dict) - ], - created_at=obs.get("created_at"), - ) - observations.append(parsed) - except Exception as e: - logger.warning(f"Failed to parse observation: {e}") - continue - - return observations - - -async def tool_lookup( +async def tool_search_reflections( conn: "Connection", bank_id: str, - model_id: str | None = None, + query: str, + query_embedding: list[float], + max_results: int = 5, tags: list[str] | None = None, tags_match: str = "any", + exclude_ids: list[str] | None = None, ) -> dict[str, Any]: """ - List or get mental models. + Search user-curated reflections by semantic similarity. + + Reflections are high-quality, manually created summaries about specific topics. + They should be searched FIRST as they represent the most reliable synthesized knowledge. Args: conn: Database connection bank_id: Bank identifier - model_id: Optional specific model ID to get (if None, lists all) - tags: Optional tags to filter models (when listing) + query: Search query (for logging/tracing) + query_embedding: Pre-computed embedding for semantic search + max_results: Maximum number of reflections to return + tags: Optional tags to filter reflections tags_match: How to match tags - "any" (OR), "all" (AND) + exclude_ids: Optional list of reflection IDs to exclude (e.g., when refreshing a reflection) Returns: - Dict with either a list of models or a single model's details + Dict with matching reflections including content and freshness info """ - if model_id: - # Get specific mental model with full details including observations - row = await conn.fetchrow( - """ - SELECT id, subtype, name, description, observations, entity_id, last_updated - FROM mental_models - WHERE id = $1 AND bank_id = $2 - """, - model_id, - bank_id, - ) - if row: - # Parse observations JSON - obs_data = row["observations"] or {"observations": []} - if isinstance(obs_data, str): - import json + from ..memory_engine import fq_table - obs_data = json.loads(obs_data) - observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data + # Build filters dynamically + filters = "" + params: list[Any] = [bank_id, str(query_embedding), max_results] + next_param = 4 - # Parse observations into typed models - observations = _parse_observations(observations_raw) - - return { - "found": True, - "model": { - "id": row["id"], - "subtype": row["subtype"], - "name": row["name"], - "description": row["description"], - "observations": observations, - "entity_id": str(row["entity_id"]) if row["entity_id"] else None, - "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None, - }, - } - return {"found": False, "model_id": model_id} - else: - # List mental models (compact: id, name, description only) - # Full observations are retrieved via get_mental_model(model_id) - # NOTE: Directives (subtype='directive') are excluded from listing - - # they are injected into the system prompt, not discoverable via tools - # Filter by tags if provided - if tags: - if tags_match == "all": - # All tags must match - rows = await conn.fetch( - """ - SELECT id, subtype, name, description - FROM mental_models - WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive' - ORDER BY last_updated DESC NULLS LAST, created_at DESC - """, - bank_id, - tags, - ) - else: - # Any tag matches (OR) - default - rows = await conn.fetch( - """ - SELECT id, subtype, name, description - FROM mental_models - WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive' - ORDER BY last_updated DESC NULLS LAST, created_at DESC - """, - bank_id, - tags, - ) + if tags: + if tags_match == "all": + filters += f" AND tags @> ${next_param}::varchar[]" else: - rows = await conn.fetch( - """ - SELECT id, subtype, name, description - FROM mental_models - WHERE bank_id = $1 AND subtype != 'directive' - ORDER BY last_updated DESC NULLS LAST, created_at DESC + filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')" + params.append(tags) + next_param += 1 + + if exclude_ids: + filters += f" AND id != ALL(${next_param}::uuid[])" + params.append(exclude_ids) + next_param += 1 + + # Search reflections by embedding similarity + rows = await conn.fetch( + f""" + SELECT + id, name, content, reflect_response, + tags, created_at, last_refreshed_at, + 1 - (embedding <=> $2::vector) as relevance + FROM {fq_table("reflections")} + WHERE bank_id = $1 AND embedding IS NOT NULL {filters} + ORDER BY embedding <=> $2::vector + LIMIT $3 + """, + *params, + ) + + now = datetime.now(timezone.utc) + reflections = [] + + for row in rows: + last_refreshed_at = row["last_refreshed_at"] + if last_refreshed_at and last_refreshed_at.tzinfo is None: + last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc) + + # Calculate freshness + is_stale = False + if last_refreshed_at: + age = now - last_refreshed_at + is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS) + + reflections.append( + { + "id": str(row["id"]), + "name": row["name"], + "content": row["content"], + "reflect_response": row["reflect_response"], + "tags": row["tags"] or [], + "relevance": round(row["relevance"], 4), + "updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None, + "is_stale": is_stale, + } + ) + + return { + "query": query, + "count": len(reflections), + "reflections": reflections, + } + + +async def tool_search_mental_models( + memory_engine: "MemoryEngine", + bank_id: str, + query: str, + request_context: "RequestContext", + max_tokens: int = 5000, + tags: list[str] | None = None, + tags_match: str = "any", + last_consolidated_at: datetime | None = None, + pending_consolidation: int = 0, +) -> dict[str, Any]: + """ + Search consolidated mental models using recall with include_mental_models. + + Mental models are auto-generated from memories. Returns freshness info + so the agent knows if it should also verify with recall(). + + Args: + memory_engine: Memory engine instance + bank_id: Bank identifier + query: Search query + request_context: Request context for authentication + max_tokens: Maximum tokens for results (default 5000) + tags: Optional tags to filter models + tags_match: How to match tags - "any" (OR), "all" (AND) + last_consolidated_at: When consolidation last ran (for staleness check) + pending_consolidation: Number of memories waiting to be consolidated + + Returns: + Dict with matching mental models including freshness info + """ + from ..memory_engine import fq_table + + # Use recall to search mental models (they come back in results field when fact_type=["mental_model"]) + result = await memory_engine.recall_async( + bank_id=bank_id, + query=query, + fact_type=["mental_model"], # Only retrieve mental models + max_tokens=max_tokens, # Token budget controls how many mental models are returned + enable_trace=False, + request_context=request_context, + tags=tags, + tags_match=tags_match, + _connection_budget=1, + _quiet=True, + ) + + mental_models = [] + + # When fact_type=["mental_model"], results come back in `results` field as MemoryFact objects + # We need to fetch additional fields (proof_count, source_memory_ids) from the database + if result.results: + mm_ids = [m.id for m in result.results] + + # Fetch proof_count and source_memory_ids for these mental models + pool = await memory_engine._get_pool() + async with pool.acquire() as conn: + mm_rows = await conn.fetch( + f""" + SELECT id, proof_count, source_memory_ids + FROM {fq_table("memory_units")} + WHERE id = ANY($1::uuid[]) """, - bank_id, + mm_ids, + ) + mm_data = {str(row["id"]): row for row in mm_rows} + + for m in result.results: + # Get additional data from DB lookup + extra = mm_data.get(m.id, {}) + proof_count = extra.get("proof_count", 1) if extra else 1 + source_ids = extra.get("source_memory_ids", []) if extra else [] + # Convert UUIDs to strings + source_memory_ids = [str(sid) for sid in (source_ids or [])] + + # Determine staleness + is_stale = False + staleness_reason = None + if pending_consolidation > 0: + is_stale = True + staleness_reason = f"{pending_consolidation} memories pending consolidation" + + mental_models.append( + { + "id": str(m.id), + "text": m.text, + "proof_count": proof_count, + "source_memory_ids": source_memory_ids, + "tags": m.tags or [], + "is_stale": is_stale, + "staleness_reason": staleness_reason, + } ) - return { - "count": len(rows), - "models": [ - { - "id": row["id"], - "subtype": row["subtype"], - "name": row["name"], - "description": row["description"], - } - for row in rows - ], - } + # Return freshness info (more understandable than raw pending_consolidation count) + if pending_consolidation == 0: + freshness = "up_to_date" + elif pending_consolidation < 10: + freshness = "slightly_stale" + else: + freshness = "stale" + + return { + "query": query, + "count": len(mental_models), + "mental_models": mental_models, + "freshness": freshness, + } async def tool_recall( @@ -185,6 +246,9 @@ async def tool_recall( """ Search memories using TEMPR retrieval. + This is the ground truth - raw facts and experiences. + Use when reflections/mental models don't exist, are stale, or need verification. + Args: memory_engine: Memory engine instance bank_id: Bank identifier @@ -202,13 +266,14 @@ async def tool_recall( result = await memory_engine.recall_async( bank_id=bank_id, query=query, - fact_type=["experience", "world"], # Exclude opinions + fact_type=["experience", "world"], # Exclude opinions and mental_models max_tokens=max_tokens, enable_trace=False, request_context=request_context, tags=tags, tags_match=tags_match, _connection_budget=connection_budget, + _quiet=True, # Suppress logging for internal operations ) memories = [] @@ -230,85 +295,6 @@ async def tool_recall( } -async def tool_learn( - conn: "Connection", - bank_id: str, - input: MentalModelInput, - tags: list[str] | None = None, -) -> dict[str, Any]: - """ - Create a mental model placeholder with subtype='learned'. - - The agent only specifies name and description - actual observations are generated - in the background via refresh, similar to pinned models. - - Args: - conn: Database connection - bank_id: Bank identifier - input: Mental model input data (name, description, optional entity_id) - tags: Tags to apply to new mental models (from reflect context) - - Returns: - Dict with created model info including model_id for background generation - """ - model_id = generate_model_id(input.name) - - # Parse entity_id if provided - entity_uuid = None - if input.entity_id: - try: - entity_uuid = uuid.UUID(input.entity_id) - except ValueError: - logger.warning(f"Invalid entity_id format: {input.entity_id}") - - # Check if model exists - existing = await conn.fetchrow( - "SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2", - model_id, - bank_id, - ) - - if existing: - # Update description only - observations will be regenerated - await conn.execute( - """ - UPDATE mental_models SET - description = $3, - entity_id = $4 - WHERE id = $1 AND bank_id = $2 - """, - model_id, - bank_id, - input.description, - entity_uuid, - ) - status = "updated" - else: - # Insert new model placeholder - observations will be generated in background - await conn.execute( - """ - INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at) - VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW()) - """, - model_id, - bank_id, - input.name, - input.description, - entity_uuid, - tags or [], - ) - status = "created" - - logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation") - - return { - "status": status, - "model_id": model_id, - "name": input.name, - "pending_generation": True, - } - - async def tool_expand( conn: "Connection", bank_id: str, @@ -327,6 +313,8 @@ async def tool_expand( Returns: Dict with results array, each containing memory, chunk, and optionally document data """ + from ..memory_engine import fq_table + if not memory_ids: return {"error": "memory_ids is required and must not be empty"} @@ -344,9 +332,9 @@ async def tool_expand( # Batch fetch all memory units memories = await conn.fetch( - """ + f""" SELECT id, text, chunk_id, document_id, fact_type, context - FROM memory_units + FROM {fq_table("memory_units")} WHERE id = ANY($1) AND bank_id = $2 """, valid_uuids, @@ -363,9 +351,9 @@ async def tool_expand( chunk_map: dict[str, Any] = {} if chunk_ids: chunks = await conn.fetch( - """ + f""" SELECT chunk_id, chunk_text, chunk_index, document_id - FROM chunks + FROM {fq_table("chunks")} WHERE chunk_id = ANY($1) """, chunk_ids, @@ -385,9 +373,9 @@ async def tool_expand( all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct) if all_doc_ids: docs = await conn.fetch( - """ + f""" SELECT id, original_text, metadata, retain_params - FROM documents + FROM {fq_table("documents")} WHERE id = ANY($1) AND bank_id = $2 """, all_doc_ids, diff --git a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py index 060e3bdd..4b85e31e 100644 --- a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py +++ b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py @@ -2,36 +2,62 @@ Tool schema definitions for the reflect agent. These are OpenAI-format tool definitions used with native tool calling. +The reflect agent uses a hierarchical retrieval strategy: +1. search_reflections - User-curated summaries (highest quality, if applicable) +2. search_mental_models - Consolidated knowledge with freshness awareness +3. recall - Raw facts (world/experience) as ground truth fallback """ # Tool definitions in OpenAI format -TOOL_LIST_MENTAL_MODELS = { + +TOOL_SEARCH_REFLECTIONS = { "type": "function", "function": { - "name": "list_mental_models", - "description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.", + "name": "search_reflections", + "description": ( + "Search user-curated reflections (summaries). These are high-quality, manually created " + "summaries about specific topics. Use FIRST when the question might be covered by an " + "existing reflection. Returns reflections with their content and last refresh time." + ), "parameters": { "type": "object", - "properties": {}, - "required": [], + "properties": { + "query": { + "type": "string", + "description": "Search query to find relevant reflections", + }, + "max_results": { + "type": "integer", + "description": "Maximum number of reflections to return (default 5)", + }, + }, + "required": ["query"], }, }, } -TOOL_GET_MENTAL_MODEL = { +TOOL_SEARCH_MENTAL_MODELS = { "type": "function", "function": { - "name": "get_mental_model", - "description": "Get full details of a specific mental model including all observations and memory references.", + "name": "search_mental_models", + "description": ( + "Search consolidated mental models (auto-generated knowledge). These are automatically " + "synthesized from memories. Returns models with freshness info (updated_at, is_stale). " + "If a model is STALE, you should ALSO use recall() to verify with current facts." + ), "parameters": { "type": "object", "properties": { - "model_id": { + "query": { "type": "string", - "description": "ID of the mental model (from list_mental_models results)", + "description": "Search query to find relevant mental models", + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens for results (default 5000). Use higher values for broader searches.", }, }, - "required": ["model_id"], + "required": ["query"], }, }, } @@ -40,7 +66,12 @@ TOOL_RECALL = { "type": "function", "function": { "name": "recall", - "description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.", + "description": ( + "Search raw memories (facts and experiences). This is the ground truth data. " + "Use when: (1) no reflections/mental models exist, (2) mental models are stale, " + "(3) you need specific details not in synthesized knowledge. " + "Returns individual memory facts with their timestamps." + ), "parameters": { "type": "object", "properties": { @@ -58,28 +89,6 @@ TOOL_RECALL = { }, } -TOOL_LEARN = { - "type": "function", - "function": { - "name": "learn", - "description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')", - }, - "description": { - "type": "string", - "description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')", - }, - }, - "required": ["name", "description"], - }, - }, -} - TOOL_EXPAND = { "type": "function", "function": { @@ -121,7 +130,12 @@ TOOL_DONE_ANSWER = { "items": {"type": "string"}, "description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)", }, - "model_ids": { + "reflection_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of reflection IDs that support your answer", + }, + "mental_model_ids": { "type": "array", "items": {"type": "string"}, "description": "Array of mental model IDs that support your answer", @@ -143,8 +157,6 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict: Args: directive_rules: List of directive rule strings """ - from typing import Any, cast - # Build rules list for description rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules)) @@ -169,7 +181,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict: "items": {"type": "string"}, "description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)", }, - "model_ids": { + "reflection_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of reflection IDs that support your answer", + }, + "mental_model_ids": { "type": "array", "items": {"type": "string"}, "description": "Array of mental model IDs that support your answer", @@ -185,29 +202,28 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict: } -def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]: +def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]: """ Get the list of tools for the reflect agent. + The tools support a hierarchical retrieval strategy: + 1. search_reflections - User-curated summaries (try first) + 2. search_mental_models - Consolidated knowledge with freshness + 3. recall - Raw facts as ground truth + Args: - enable_learn: Whether to include the learn tool directive_rules: Optional list of directive rule strings. If provided, the done() tool will require directive compliance confirmation. Returns: List of tool definitions in OpenAI format """ - tools = [] - - # Include mental model tools for lookup - tools.append(TOOL_LIST_MENTAL_MODELS) - tools.append(TOOL_GET_MENTAL_MODEL) - tools.append(TOOL_RECALL) - - if enable_learn: - tools.append(TOOL_LEARN) - - tools.append(TOOL_EXPAND) + tools = [ + TOOL_SEARCH_REFLECTIONS, + TOOL_SEARCH_MENTAL_MODELS, + TOOL_RECALL, + TOOL_EXPAND, + ] # Use directive-aware done tool if directives are present if directive_rules: diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py index 42fa4cff..12c827ee 100644 --- a/hindsight-api/hindsight_api/engine/response_models.py +++ b/hindsight-api/hindsight_api/engine/response_models.py @@ -11,7 +11,7 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field # Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated) -VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"]) +VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "mental_model"]) class LLMToolCall(BaseModel): @@ -166,6 +166,28 @@ class ChunkInfo(BaseModel): truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits") +class MentalModelResult(BaseModel): + """A mental model result from recall.""" + + id: str = Field(description="Unique mental model ID") + text: str = Field(description="The mental model text") + proof_count: int = Field(description="Number of facts supporting this mental model") + relevance: float = Field(default=0.0, description="Relevance score to the query") + tags: list[str] | None = Field(default=None, description="Tags for visibility scoping") + source_memory_ids: list[str] = Field( + default_factory=list, description="IDs of facts that contribute to this mental model" + ) + + +class ReflectionResult(BaseModel): + """A reflection result from recall.""" + + id: str = Field(description="Unique reflection ID") + name: str = Field(description="Human-readable name") + content: str = Field(description="The synthesized content") + relevance: float = Field(default=0.0, description="Relevance score to the query") + + class RecallResult(BaseModel): """ Result from a recall operation. @@ -229,6 +251,7 @@ class ReflectResult(BaseModel): ], "experience": [], "opinion": [], + "mental-models": [], }, "new_opinions": ["Machine learning has great potential in healthcare"], "structured_output": {"summary": "ML in healthcare", "confidence": 0.9}, @@ -239,7 +262,7 @@ class ReflectResult(BaseModel): text: str = Field(description="The formulated answer text") based_on: dict[str, list[MemoryFact]] = Field( - description="Facts used to formulate the answer, organized by type (world, experience, opinion)" + description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental-models)" ) new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection") structured_output: dict[str, Any] | None = Field( @@ -258,10 +281,6 @@ class ReflectResult(BaseModel): default_factory=list, description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.", ) - mental_models: list[MentalModelRef] = Field( - default_factory=list, - description="Mental models accessed during reflection, including directives (subtype='directive').", - ) directives_applied: list[DirectiveRef] = Field( default_factory=list, description="Directive mental models that were applied during this reflection.", diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py index 205f9791..b798ae67 100644 --- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py +++ b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py @@ -114,11 +114,8 @@ class CausalRelation(BaseModel): """Causal relationship from this fact to a previous fact (stored format).""" target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).") - relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field( - description="How this fact relates to the target: " - "'caused_by' = this fact was caused by the target, " - "'enabled_by' = this fact was enabled by the target, " - "'prevented_by' = this fact was prevented by the target" + relation_type: Literal["caused_by"] = Field( + description="How this fact relates to the target: 'caused_by' = this fact was caused by the target" ) strength: float = Field( description="Strength of relationship (0.0 to 1.0)", @@ -141,11 +138,8 @@ class FactCausalRelation(BaseModel): "MUST be less than this fact's position in the list. " "Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4." ) - relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field( - description="How this fact relates to the target fact: " - "'caused_by' = this fact was caused by the target fact, " - "'enabled_by' = this fact was enabled by the target fact, " - "'prevented_by' = this fact was blocked/prevented by the target fact" + relation_type: Literal["caused_by"] = Field( + description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact" ) strength: float = Field( description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate", @@ -662,7 +656,7 @@ CAUSAL RELATIONSHIPS ══════════════════════════════════════════════════════════════════════════ Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index. -Types: "caused_by", "enabled_by", "prevented_by" +Type: "caused_by" (this fact was caused by the target fact) Example: "Lost job → couldn't pay rent → moved apartment" - Fact 0: Lost job, causal_relations: null @@ -823,7 +817,8 @@ Text: # Critical field: fact_type # LLM uses "assistant" but we convert to "experience" for storage - fact_type = llm_fact.get("fact_type") + original_fact_type = llm_fact.get("fact_type") + fact_type = original_fact_type # Convert "assistant" → "experience" for storage if fact_type == "assistant": @@ -840,7 +835,10 @@ Text: else: # Default to 'world' if we can't determine fact_type = "world" - logger.warning(f"Fact {i}: defaulting to fact_type='world'") + logger.warning( + f"Fact {i}: defaulting to fact_type='world' " + f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})" + ) # Get fact_kind for temporal handling (but don't store it) fact_kind = llm_fact.get("fact_kind", "conversation") diff --git a/hindsight-api/hindsight_api/engine/retain/link_utils.py b/hindsight-api/hindsight_api/engine/retain/link_utils.py index 19c5ccc9..79dce42e 100644 --- a/hindsight-api/hindsight_api/engine/retain/link_utils.py +++ b/hindsight-api/hindsight_api/engine/retain/link_utils.py @@ -754,17 +754,14 @@ async def create_causal_links_batch( causal_relations_per_fact: List of causal relations for each fact. Each element is a list of dicts with: - target_fact_index: Index into unit_ids for the target fact - - relation_type: "causes", "caused_by", "enables", or "prevents" + - relation_type: "caused_by" - strength: Float in [0.0, 1.0] representing relationship strength Returns: Number of causal links created - Causal link types: - - "causes": This fact directly causes the target fact (forward causation) - - "caused_by": This fact was caused by the target fact (backward causation) - - "enables": This fact enables/allows the target fact (enablement) - - "prevents": This fact prevents/blocks the target fact (prevention) + Causal link type: + - "caused_by": This fact was caused by the target fact """ if not unit_ids or not causal_relations_per_fact: return 0 @@ -787,8 +784,8 @@ async def create_causal_links_batch( relation_type = relation["relation_type"] strength = relation.get("strength", 1.0) - # Validate relation_type - must match database constraint - valid_types = {"causes", "caused_by", "enables", "prevents"} + # Validate relation_type - only "caused_by" is supported (DB constraint) + valid_types = {"caused_by"} if relation_type not in valid_types: logger.error( f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) " diff --git a/hindsight-api/hindsight_api/engine/retain/types.py b/hindsight-api/hindsight_api/engine/retain/types.py index 528a2d02..19157dee 100644 --- a/hindsight-api/hindsight_api/engine/retain/types.py +++ b/hindsight-api/hindsight_api/engine/retain/types.py @@ -86,10 +86,10 @@ class CausalRelation: """ Causal relationship between facts. - Represents how one fact causes, enables, or prevents another. + Represents how one fact was caused by another. """ - relation_type: str # "causes", "enables", "prevents", "caused_by" + relation_type: str # "caused_by" target_fact_index: int # Index of the target fact in the batch strength: float = 1.0 # Strength of the causal relationship diff --git a/hindsight-api/hindsight_api/engine/search/tracer.py b/hindsight-api/hindsight_api/engine/search/tracer.py index c2d436b1..19247998 100644 --- a/hindsight-api/hindsight_api/engine/search/tracer.py +++ b/hindsight-api/hindsight_api/engine/search/tracer.py @@ -330,8 +330,8 @@ class SearchTracer: RetrievalResult( rank=rank, node_id=doc_id, - text=data.get("text", ""), - context=data.get("context", ""), + text=data.get("text") or "", + context=data.get("context") or "", event_date=data.get("event_date"), fact_type=data.get("fact_type") or fact_type, score=score, diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py index 87a271e3..fd84a272 100644 --- a/hindsight-api/hindsight_api/extensions/__init__.py +++ b/hindsight-api/hindsight_api/extensions/__init__.py @@ -27,8 +27,6 @@ from hindsight_api.extensions.operation_validator import ( RecallResult, ReflectContext, ReflectResultContext, - RefreshMentalModelContext, - RefreshMentalModelResult, RetainContext, RetainResult, ValidationResult, @@ -56,8 +54,6 @@ __all__ = [ "RecallResult", "ReflectContext", "ReflectResultContext", - "RefreshMentalModelContext", - "RefreshMentalModelResult", "RetainContext", "RetainResult", "ValidationResult", diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py index 8dd88eaa..a1dec0eb 100644 --- a/hindsight-api/hindsight_api/extensions/operation_validator.py +++ b/hindsight-api/hindsight_api/extensions/operation_validator.py @@ -97,18 +97,6 @@ class ReflectContext: context: str | None = None -@dataclass -class RefreshMentalModelContext: - """Context for a refresh mental model operation validation (pre-operation). - - Contains ALL user-provided parameters for the refresh mental model operation. - """ - - bank_id: str - model_id: str - request_context: "RequestContext" - - # ============================================================================= # Post-operation Contexts (includes results) # ============================================================================= @@ -176,27 +164,6 @@ class ReflectResultContext: error: str | None = None -@dataclass -class RefreshMentalModelResult: - """Result context for post-refresh-mental-model hook. - - Contains the operation parameters and the result including token usage. - """ - - bank_id: str - model_id: str - request_context: "RequestContext" - # Result - model_name: str | None = None - observations_count: int = 0 - input_tokens: int = 0 - output_tokens: int = 0 - total_tokens: int = 0 - duration_ms: int = 0 - success: bool = True - error: str | None = None - - class OperationValidatorExtension(Extension, ABC): """ Validates and hooks into retain/recall/reflect operations. @@ -298,25 +265,6 @@ class OperationValidatorExtension(Extension, ABC): """ ... - @abstractmethod - async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult: - """ - Validate a refresh mental model operation before execution. - - Called before the refresh mental model operation is processed. - Return ValidationResult.reject() to prevent the operation from executing. - - Args: - ctx: Context containing all user-provided parameters: - - bank_id: Bank identifier - - model_id: Mental model ID to refresh - - request_context: Request context with auth info - - Returns: - ValidationResult indicating whether the operation is allowed. - """ - ... - # ========================================================================= # Post-operation hooks (optional - override to implement) # ========================================================================= @@ -377,28 +325,3 @@ class OperationValidatorExtension(Extension, ABC): - error: Error message (if failed) """ pass - - async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None: - """ - Called after a refresh mental model operation completes (success or failure). - - Override this method to implement post-operation logic such as: - - Token usage tracking and billing - - Audit logging - - Metrics collection - - Args: - result: Result context containing: - - bank_id: Bank identifier - - model_id: Mental model ID - - request_context: Request context with auth info - - model_name: Name of the mental model (if success) - - observations_count: Number of observations generated - - input_tokens: Number of input tokens used - - output_tokens: Number of output tokens used - - total_tokens: Total tokens used (input + output) - - duration_ms: Total operation duration in milliseconds - - success: Whether the operation succeeded - - error: Error message (if failed) - """ - pass diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index 44a2872b..728dba9e 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -212,6 +212,9 @@ def main(): retain_extract_causal_links=config.retain_extract_causal_links, retain_extraction_mode=config.retain_extraction_mode, retain_observations_async=config.retain_observations_async, + enable_mental_models=config.enable_mental_models, + consolidation_similarity_threshold=config.consolidation_similarity_threshold, + consolidation_batch_size=config.consolidation_batch_size, skip_llm_verification=config.skip_llm_verification, lazy_reranker=config.lazy_reranker, run_migrations_on_startup=config.run_migrations_on_startup, diff --git a/hindsight-api/hindsight_api/worker/poller.py b/hindsight-api/hindsight_api/worker/poller.py index e85f8a18..52cbecbc 100644 --- a/hindsight-api/hindsight_api/worker/poller.py +++ b/hindsight-api/hindsight_api/worker/poller.py @@ -8,6 +8,7 @@ FOR UPDATE SKIP LOCKED for safe concurrent claiming. import asyncio import json import logging +import time import traceback from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -17,6 +18,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Progress logging interval in seconds +PROGRESS_LOG_INTERVAL = 30 + def fq_table(table: str, schema: str | None = None) -> str: """Get fully-qualified table name with optional schema prefix.""" @@ -66,6 +70,9 @@ class WorkerPoller: self._current_tasks: set[asyncio.Task] = set() self._in_flight_count = 0 self._in_flight_lock = asyncio.Lock() + self._last_progress_log = 0.0 + self._tasks_completed_since_log = 0 + self._active_banks: set[str] = set() async def claim_batch(self) -> list[tuple[str, dict[str, Any]]]: """ @@ -73,6 +80,9 @@ class WorkerPoller: Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers. + For consolidation tasks specifically, skips pending tasks if there's already + a processing consolidation for the same bank (to avoid duplicate work). + Returns: List of tuples (operation_id, task_dict) """ @@ -81,11 +91,24 @@ class WorkerPoller: async with self._pool.acquire() as conn: async with conn.transaction(): # Select and lock pending tasks + # For consolidation: skip if same bank already has one processing rows = await conn.fetch( f""" SELECT operation_id, task_payload - FROM {table} + FROM {table} AS pending WHERE status = 'pending' AND task_payload IS NOT NULL + AND ( + -- Non-consolidation tasks: always claimable + operation_type != 'consolidation' + OR + -- Consolidation: only if no other consolidation processing for same bank + NOT EXISTS ( + SELECT 1 FROM {table} AS processing + WHERE processing.bank_id = pending.bank_id + AND processing.operation_type = 'consolidation' + AND processing.status = 'processing' + ) + ) ORDER BY created_at LIMIT $1 FOR UPDATE SKIP LOCKED @@ -188,6 +211,34 @@ class WorkerPoller: logger.error(f"Task {operation_id} failed: {e}") await self._retry_or_fail(operation_id, error_msg) + async def recover_own_tasks(self) -> int: + """ + Recover tasks that were assigned to this worker but not completed. + + This handles the case where a worker crashes while processing tasks. + On startup, we reset any tasks stuck in 'processing' for this worker_id + back to 'pending' so they can be picked up again. + + Returns: + Number of tasks recovered + """ + table = fq_table("async_operations", self._schema) + + result = await self._pool.execute( + f""" + UPDATE {table} + SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now() + WHERE status = 'processing' AND worker_id = $1 + """, + self._worker_id, + ) + + # Parse "UPDATE N" to get count + count = int(result.split()[-1]) if result else 0 + if count > 0: + logger.info(f"Worker {self._worker_id} recovered {count} stale tasks from previous run") + return count + async def run(self): """ Main polling loop. @@ -195,6 +246,9 @@ class WorkerPoller: Continuously polls for pending tasks, claims them, and executes them until shutdown is signaled. """ + # Recover any tasks from a previous crash before starting + await self.recover_own_tasks() + logger.info(f"Worker {self._worker_id} starting polling loop") while not self._shutdown.is_set(): @@ -234,6 +288,9 @@ class WorkerPoller: except asyncio.TimeoutError: pass # Normal timeout, continue polling + # Log progress stats periodically + await self._log_progress_if_due() + except asyncio.CancelledError: logger.info(f"Worker {self._worker_id} polling loop cancelled") break @@ -270,6 +327,72 @@ class WorkerPoller: logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s") + async def _log_progress_if_due(self): + """Log progress stats every PROGRESS_LOG_INTERVAL seconds.""" + now = time.time() + if now - self._last_progress_log < PROGRESS_LOG_INTERVAL: + return + + self._last_progress_log = now + + try: + table = fq_table("async_operations", self._schema) + async with self._pool.acquire() as conn: + # Get global stats by status + stats = await conn.fetch( + f""" + SELECT status, COUNT(*) as count + FROM {table} + WHERE created_at > now() - interval '24 hours' + GROUP BY status + """ + ) + + # Get currently processing tasks grouped by type and bank + processing = await conn.fetch( + f""" + SELECT operation_type, bank_id, COUNT(*) as count + FROM {table} + WHERE status = 'processing' + GROUP BY operation_type, bank_id + """ + ) + + # Build stats dict + status_counts = {row["status"]: row["count"] for row in stats} + pending = status_counts.get("pending", 0) + processing_count = status_counts.get("processing", 0) + completed = status_counts.get("completed", 0) + failed = status_counts.get("failed", 0) + + # Build processing breakdown + processing_info = [] + banks_working = set() + for row in processing: + op_type = row["operation_type"] + bank_id = row["bank_id"] + count = row["count"] + banks_working.add(bank_id) + processing_info.append(f"{op_type}:{bank_id}({count})") + + # Format log + async with self._in_flight_lock: + in_flight = self._in_flight_count + + processing_str = ", ".join(processing_info[:10]) if processing_info else "none" + if len(processing_info) > 10: + processing_str += f" +{len(processing_info) - 10} more" + + logger.info( + f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | " + f"global: pending={pending} processing={processing_count} " + f"completed_24h={completed} failed_24h={failed} | " + f"active: {processing_str}" + ) + + except Exception as e: + logger.debug(f"Failed to log progress stats: {e}") + @property def worker_id(self) -> str: """Get the worker ID.""" diff --git a/hindsight-api/tests/test_consolidation.py b/hindsight-api/tests/test_consolidation.py new file mode 100644 index 00000000..0535b0d4 --- /dev/null +++ b/hindsight-api/tests/test_consolidation.py @@ -0,0 +1,1587 @@ +"""Integration tests for the consolidation engine. + +These tests exercise the real consolidation implementation with actual database operations. +Note: Consolidation runs automatically after retain via SyncTaskBackend in tests. +""" + +import uuid +from unittest.mock import patch + +import pytest + +from hindsight_api.engine.consolidation.consolidator import run_consolidation_job +from hindsight_api.engine.memory_engine import MemoryEngine +from hindsight_api.engine.reflect.tools import ( + tool_recall, + tool_search_mental_models, + tool_search_reflections, +) + + +@pytest.fixture(autouse=True) +def enable_mental_models(): + """Enable mental models for all tests in this module.""" + from hindsight_api.config import get_config + + config = get_config() + original_value = config.enable_mental_models + config.enable_mental_models = True + yield + config.enable_mental_models = original_value + + +class TestConsolidationIntegration: + """Integration tests for consolidation with real database. + + These tests verify that consolidation creates mental models correctly. + Since we use SyncTaskBackend in tests, consolidation runs synchronously + after retain completes. + """ + + @pytest.mark.asyncio + async def test_consolidation_creates_mental_model_after_retain( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation creates a mental model after retain.""" + bank_id = f"test-consolidation-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation runs automatically after + await memory.retain_async( + bank_id=bank_id, + content="Peter loves hiking in the mountains every weekend.", + request_context=request_context, + ) + + # Verify mental model exists in memory_units + # (consolidation already ran as part of retain via SyncTaskBackend) + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, proof_count, fact_type + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + # Mental model may or may not be created depending on LLM relevance judgment + # The important thing is no errors occurred + if mental_models: + mm = mental_models[0] + assert mm["proof_count"] >= 1 + assert mm["fact_type"] == "mental_model" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_processes_multiple_memories( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation processes multiple related memories.""" + bank_id = f"test-consolidation-multi-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain first memory + await memory.retain_async( + bank_id=bank_id, + content="Peter enjoys hiking on mountain trails.", + request_context=request_context, + ) + + # Retain a second related memory + await memory.retain_async( + bank_id=bank_id, + content="Peter went hiking in the Alps last weekend and loved it.", + request_context=request_context, + ) + + # Check mental models after both retains + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, proof_count + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY proof_count DESC + """, + bank_id, + ) + + # Should have at least one mental model + # If the LLM determined both memories support the same model, + # proof_count might be > 1 + if mental_models: + # Verify structure is correct + assert all(mm["text"] for mm in mental_models) + assert all(mm["proof_count"] >= 1 for mm in mental_models) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_no_new_memories(self, memory: MemoryEngine, request_context): + """Test that consolidation handles case when no new memories exist.""" + bank_id = f"test-consolidation-empty-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Run consolidation without any memories + result = await run_consolidation_job( + memory_engine=memory, + bank_id=bank_id, + request_context=request_context, + ) + + assert result["status"] == "no_new_memories" + assert result["memories_processed"] == 0 + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_respects_last_consolidated_at( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation only processes memories created after last_consolidated_at.""" + bank_id = f"test-consolidation-timestamp-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation runs automatically + await memory.retain_async( + bank_id=bank_id, + content="Alice works at a technology company.", + request_context=request_context, + ) + + # Run consolidation again - should have no new memories + result = await run_consolidation_job( + memory_engine=memory, + bank_id=bank_id, + request_context=request_context, + ) + + # Should report no new memories since consolidation already ran + assert result["status"] == "no_new_memories" + assert result["memories_processed"] == 0 + + # Add a new memory + await memory.retain_async( + bank_id=bank_id, + content="Alice got promoted to senior engineer.", + request_context=request_context, + ) + + # Run consolidation again - should also have no new memories + # because consolidation ran automatically after the second retain + result = await run_consolidation_job( + memory_engine=memory, + bank_id=bank_id, + request_context=request_context, + ) + + assert result["status"] == "no_new_memories" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_copies_entity_links(self, memory: MemoryEngine, request_context): + """Test that mental models inherit entity links from source memories.""" + bank_id = f"test-consolidation-entities-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory with a named entity + await memory.retain_async( + bank_id=bank_id, + content="John Smith is the CEO of Acme Corporation.", + request_context=request_context, + ) + + # Check mental model and its entity links + async with memory._pool.acquire() as conn: + mental_model = await conn.fetchrow( + """ + SELECT id + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + LIMIT 1 + """, + bank_id, + ) + + if mental_model: + # Check if entity links were copied + entity_links = await conn.fetch( + """ + SELECT entity_id + FROM unit_entities + WHERE unit_id = $1 + """, + mental_model["id"], + ) + # Mental model should have inherited entity links from source memory + # (may be empty if no entities were extracted, which is fine) + assert entity_links is not None + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_mental_models_included_in_recall( + self, memory: MemoryEngine, request_context + ): + """Test that mental models created by consolidation are returned in recall.""" + bank_id = f"test-consolidation-recall-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation runs automatically + await memory.retain_async( + bank_id=bank_id, + content="Sarah is an expert Python programmer who specializes in machine learning.", + request_context=request_context, + ) + + # Recall with mental models included + recall_result = await memory.recall_async( + bank_id=bank_id, + query="What does Sarah do?", + fact_type=["world", "experience", "mental_model"], + request_context=request_context, + ) + + # Mental models come back as regular results with fact_type='mental_model' + assert hasattr(recall_result, "results") + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_creates_memory_links(self, memory: MemoryEngine, request_context): + """Test that mental models get bidirectional links to their source memories.""" + bank_id = f"test-consolidation-links-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation runs automatically + await memory.retain_async( + bank_id=bank_id, + content="Maria works as a software engineer at Microsoft.", + request_context=request_context, + ) + + # Check memory_links between mental model and source memory + async with memory._pool.acquire() as conn: + mental_model = await conn.fetchrow( + """ + SELECT id, source_memory_ids + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + LIMIT 1 + """, + bank_id, + ) + + if mental_model and mental_model["source_memory_ids"]: + source_memory_id = mental_model["source_memory_ids"][0] + + # Check that bidirectional links exist + link_from_memory = await conn.fetchrow( + """ + SELECT * FROM memory_links + WHERE from_unit_id = $1 AND to_unit_id = $2 + """, + source_memory_id, + mental_model["id"], + ) + link_to_memory = await conn.fetchrow( + """ + SELECT * FROM memory_links + WHERE from_unit_id = $1 AND to_unit_id = $2 + """, + mental_model["id"], + source_memory_id, + ) + + # Both directions should have links + assert link_from_memory is not None, "Expected link from source memory to mental model" + assert link_to_memory is not None, "Expected link from mental model to source memory" + assert link_from_memory["link_type"] == "semantic" + assert link_to_memory["link_type"] == "semantic" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_merges_only_redundant_facts( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation only merges truly redundant facts. + + Mental models should be fine-grained (almost 1:1 with memories). + Only merge when facts are truly redundant (saying the same thing differently) + or when one directly updates another (e.g., location change). + + Given: + - "Nicolò lives in Italy" + - "Nicolò moved to the US recently" (updates the living location) + + The second fact should UPDATE the first, not create a separate model. + But unrelated facts like "Nicolò works at Vectorize" should stay separate. + """ + bank_id = f"test-consolidation-merge-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory about living location + await memory.retain_async( + bank_id=bank_id, + content="Nicolò lives in Italy.", + request_context=request_context, + ) + + # Retain an unrelated memory (different topic - should NOT merge) + await memory.retain_async( + bank_id=bank_id, + content="Nicolò works at Vectorize as an engineer.", + request_context=request_context, + ) + + # Check mental models - should have 2 separate models + async with memory._pool.acquire() as conn: + mm_before = await conn.fetch( + """ + SELECT id, text FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + # Add a memory that UPDATES the living location (should merge with first) + await memory.retain_async( + bank_id=bank_id, + content="Nicolò recently moved to the United States.", + request_context=request_context, + ) + + # Check mental models after consolidation + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, proof_count, source_memory_ids + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY created_at + """, + bank_id, + ) + + # Key assertions: + # 1. Consolidation ran without errors + # 2. Mental models exist + assert len(mental_models) >= 1, "Expected at least one mental model" + + # The work-related fact should remain separate from location facts + # (LLM behavior varies, so we check structure rather than exact count) + for mm in mental_models: + assert mm["text"], "Mental model should have text" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_keeps_different_people_separate( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation NEVER merges facts about different people. + + Each person's facts should stay in separate mental models. + """ + bank_id = f"test-consolidation-people-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Add facts about different people + await memory.retain_async( + bank_id=bank_id, + content="John lives in New York.", + request_context=request_context, + ) + await memory.retain_async( + bank_id=bank_id, + content="Mary lives in Boston.", + request_context=request_context, + ) + await memory.retain_async( + bank_id=bank_id, + content="Bob works at Google.", + request_context=request_context, + ) + + # Check mental models - should have separate models for each person + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, source_memory_ids + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + # Should have multiple mental models (one per person/fact) + # Not everything merged into one + assert len(mental_models) >= 2, ( + f"Expected multiple mental models for different people, got {len(mental_models)}" + ) + + # No single mental model should mention multiple different people + # (This is a structural check - each model should be focused) + for mm in mental_models: + text = mm["text"].lower() + people_mentioned = sum([ + 1 for name in ["john", "mary", "bob"] + if name in text + ]) + assert people_mentioned <= 1, ( + f"Mental model should not merge different people: {mm['text']}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_merges_contradictions( + self, memory: MemoryEngine, request_context + ): + """Test that contradictions about the same topic are merged with history. + + When facts contradict each other (same person, same topic, opposite info), + they should be merged into ONE mental model that captures the change. + + Example: + - "Nicolò loves pizza" + - "Nicolò hates pizza" + → Should become: "Nicolò used to love pizza but now hates it" (or similar) + """ + bank_id = f"test-consolidation-contradict-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Add initial fact + await memory.retain_async( + bank_id=bank_id, + content="Nicolò loves pizza.", + request_context=request_context, + ) + + # Check we have one mental model + async with memory._pool.acquire() as conn: + mm_before = await conn.fetch( + """ + SELECT id, text FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + count_before = len(mm_before) + + # Add contradicting fact (same person, same topic, opposite sentiment) + await memory.retain_async( + bank_id=bank_id, + content="Nicolò hates pizza.", + request_context=request_context, + ) + + # Check mental models after consolidation + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, source_memory_ids, history + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + # Key assertion: Should NOT have more mental models than before + # The contradiction should be merged, not create a new model + assert len(mental_models) <= count_before, ( + f"Contradiction should merge, not create new model. " + f"Before: {count_before}, After: {len(mental_models)}. " + f"Models: {[mm['text'] for mm in mental_models]}" + ) + + # The merged model should capture both sentiments or the change + if mental_models: + merged_text = mental_models[0]["text"].lower() + # Should mention the change or both states + has_history = ( + ("used to" in merged_text or "now" in merged_text or "but" in merged_text) + or ("love" in merged_text and "hate" in merged_text) + or (len(mental_models[0]["source_memory_ids"] or []) > 1) + ) + assert has_history, ( + f"Merged model should capture the change. Got: {mental_models[0]['text']}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestConsolidationDisabled: + """Test consolidation when disabled via config.""" + + @pytest.mark.asyncio + async def test_consolidation_returns_disabled_status( + self, memory: MemoryEngine, request_context + ): + """Test that consolidation returns disabled status when enable_mental_models is False.""" + from unittest.mock import patch + + bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Disable mental models via config + with patch("hindsight_api.config.get_config") as mock_config: + mock_config.return_value.enable_mental_models = False + + result = await run_consolidation_job( + memory_engine=memory, + bank_id=bank_id, + request_context=request_context, + ) + + assert result["status"] == "disabled" + assert result["bank_id"] == bank_id + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestRecallMentalModelFactType: + """Test recall with mental_model as a fact type.""" + + @pytest.mark.asyncio + async def test_recall_with_mental_model_fact_type( + self, memory: MemoryEngine, request_context + ): + """Test that mental_model can be used as a fact type in recall. + + When mental_model is in the types list, the recall should: + 1. Return mental models in the results field with fact_type='mental_model' + 2. Not raise validation errors for None context fields + """ + bank_id = f"test-recall-mm-type-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation runs automatically + await memory.retain_async( + bank_id=bank_id, + content="Alex is a data scientist who specializes in deep learning and neural networks.", + request_context=request_context, + ) + + # Recall with mental_model in types + recall_result = await memory.recall_async( + bank_id=bank_id, + query="What does Alex do?", + fact_type=["mental_model"], + request_context=request_context, + ) + + # Mental models come back as regular results with fact_type='mental_model' + assert recall_result is not None + assert recall_result.results is not None + # Check that results include mental models + if recall_result.results: + for mm in recall_result.results: + assert mm.id is not None + assert mm.text is not None + assert mm.fact_type == "mental_model" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_recall_with_mixed_fact_types_including_mental_model( + self, memory: MemoryEngine, request_context + ): + """Test recall with mental_model alongside world and experience types.""" + bank_id = f"test-recall-mixed-types-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain memories - consolidation runs automatically + await memory.retain_async( + bank_id=bank_id, + content="Jordan is a professional musician who plays guitar in a rock band.", + request_context=request_context, + ) + + # Recall with all types including mental_model + recall_result = await memory.recall_async( + bank_id=bank_id, + query="What does Jordan do?", + fact_type=["world", "experience", "mental_model"], + enable_trace=True, + request_context=request_context, + ) + + # Should return results without errors + assert recall_result is not None + # Should have results from world/experience facts + assert recall_result.results is not None + # Mental models come back as regular results with fact_type='mental_model' + # when mental_model is included in fact_type parameter + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_recall_mental_model_only_with_trace( + self, memory: MemoryEngine, request_context + ): + """Test that recall with only mental_model type and trace enabled works. + + This specifically tests the tracer handling of mental models with None context. + """ + bank_id = f"test-recall-mm-trace-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain memory - consolidation creates mental model + await memory.retain_async( + bank_id=bank_id, + content="Chris works as a product manager at a startup focused on AI applications.", + request_context=request_context, + ) + + # Recall with mental_model only and trace enabled + # This tests the fix for the None context validation error + recall_result = await memory.recall_async( + bank_id=bank_id, + query="Where does Chris work?", + fact_type=["mental_model"], + enable_trace=True, + request_context=request_context, + ) + + # Should complete without validation errors + assert recall_result is not None + # Trace should be populated + assert recall_result.trace is not None or recall_result.mental_models is not None + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestConsolidationTagRouting: + """Test tag routing during consolidation. + + Tag routing rules: + - Same scope (tags match): update existing mental model + - Fact scoped, model global (untagged): update global (it absorbs all) + - Different scopes (non-overlapping tags): create untagged cross-scope insight + - No match: create with fact's tags + """ + + async def _retain_with_tags( + self, + memory: MemoryEngine, + bank_id: str, + content: str, + tags: list[str], + request_context, + ): + """Helper to retain content with tags using retain_batch_async.""" + await memory.retain_batch_async( + bank_id=bank_id, + contents=[{"content": content}], + document_tags=tags, + request_context=request_context, + ) + + @pytest.mark.asyncio + async def test_same_scope_updates_model( + self, memory: MemoryEngine, request_context + ): + """Test that a tagged fact updates a mental model with the same tags. + + Given: + - Memory with tags=['alice']: "Alice likes coffee" + - New memory with tags=['alice']: "Alice prefers espresso" + + Expected: + - Mental model with tags=['alice'] is updated to reflect both facts + """ + bank_id = f"test-tag-same-scope-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain first memory with tags + await self._retain_with_tags( + memory, bank_id, "Alice likes coffee.", ["alice"], request_context + ) + + # Check mental model has correct tags + async with memory._pool.acquire() as conn: + mm_before = await conn.fetch( + """ + SELECT id, text, tags FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + count_before = len(mm_before) + if mm_before: + assert "alice" in (mm_before[0]["tags"] or []), ( + f"Expected mental model to have 'alice' tag, got: {mm_before[0]['tags']}" + ) + + # Retain related memory with same tags + await self._retain_with_tags( + memory, bank_id, "Alice prefers espresso over regular coffee.", ["alice"], request_context + ) + + # Check mental models - should NOT have increased (same scope update) + async with memory._pool.acquire() as conn: + mm_after = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + # Count of mental models should stay same or decrease (merge) + assert len(mm_after) <= count_before + 1, ( + f"Same scope fact should update existing model, not create new. " + f"Before: {count_before}, After: {len(mm_after)}" + ) + + # The model(s) should still have alice tag + for mm in mm_after: + if "coffee" in mm["text"].lower() or "espresso" in mm["text"].lower(): + assert "alice" in (mm["tags"] or []), ( + f"Updated model should keep 'alice' tag: {mm['text']}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_scoped_fact_updates_global_model( + self, memory: MemoryEngine, request_context + ): + """Test that a scoped fact can update an untagged (global) mental model. + + Given: + - Untagged memory: "Pizza is a popular food" + - New memory with tags=['history']: "Pizza originated in Naples" + + Expected: + - The global mental model is updated (global absorbs all scopes) + """ + bank_id = f"test-tag-global-absorb-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain untagged (global) memory + await memory.retain_async( + bank_id=bank_id, + content="Pizza is a popular Italian food.", + request_context=request_context, + ) + + # Check untagged mental model exists + async with memory._pool.acquire() as conn: + mm_before = await conn.fetch( + """ + SELECT id, text, tags FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + count_before = len(mm_before) + # Should be untagged or have empty tags + if mm_before: + assert not mm_before[0]["tags"] or len(mm_before[0]["tags"]) == 0, ( + f"Expected untagged model, got: {mm_before[0]['tags']}" + ) + + # Retain scoped memory that relates to the global topic + await self._retain_with_tags( + memory, bank_id, "Pizza originated in Naples.", ["history"], request_context + ) + + # Check - global model should be updated OR new scoped model created + async with memory._pool.acquire() as conn: + mm_after = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY created_at + """, + bank_id, + ) + + # At least one model should exist + assert len(mm_after) >= 1, "Expected at least one mental model" + + # Check that global model was updated (source_memory_ids increased) + # OR new model was created with appropriate tags + global_models = [m for m in mm_after if not m["tags"] or len(m["tags"]) == 0] + scoped_models = [m for m in mm_after if m["tags"] and len(m["tags"]) > 0] + + # Either global was updated or scoped was created + assert len(global_models) >= 1 or len(scoped_models) >= 1, ( + "Expected either global model update or scoped model creation" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_cross_scope_creates_untagged( + self, memory: MemoryEngine, request_context + ): + """Test that cross-scope related facts create untagged (global) insights. + + Given: + - Memory with tags=['alice']: "Alice recommends the Thai restaurant" + - Memory with tags=['bob']: "Bob tried the Thai restaurant Alice mentioned" + + Expected: + - A new untagged mental model capturing the cross-scope insight + """ + bank_id = f"test-tag-cross-scope-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain Alice's scoped memory + await self._retain_with_tags( + memory, bank_id, + "Alice recommends the Thai restaurant on Main Street.", + ["alice"], request_context + ) + + # Check Alice's mental model exists with correct tags + async with memory._pool.acquire() as conn: + mm_alice = await conn.fetch( + """ + SELECT id, text, tags FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + count_before = len(mm_alice) + + # Retain Bob's memory that relates to Alice's topic (cross-scope) + await self._retain_with_tags( + memory, bank_id, + "Bob visited the Thai restaurant on Main Street and loved it.", + ["bob"], request_context + ) + + # Check mental models + async with memory._pool.acquire() as conn: + mm_after = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY created_at + """, + bank_id, + ) + + # Should have multiple models (alice's, bob's, potentially global) + assert len(mm_after) >= 2, ( + f"Expected at least 2 mental models for different scopes, got {len(mm_after)}" + ) + + # Check we have models with different tags (alice, bob, or untagged) + tag_sets = [frozenset(m["tags"] or []) for m in mm_after] + + # Should NOT merge alice and bob into same model + models_with_both = [ + m for m in mm_after + if m["tags"] and "alice" in m["tags"] and "bob" in m["tags"] + ] + assert len(models_with_both) == 0, ( + "Should not merge different scopes into one model with both tags" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_no_match_creates_with_fact_tags( + self, memory: MemoryEngine, request_context + ): + """Test that a new fact with no matching models creates a model with fact's tags. + + Given: + - Empty bank + - Memory with tags=['project_x']: "Project X uses Python" + + Expected: + - Mental model created with tags=['project_x'] + """ + bank_id = f"test-tag-new-scoped-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain tagged memory (no existing mental models) + await self._retain_with_tags( + memory, bank_id, + "Project X uses Python for its backend services.", + ["project_x"], request_context + ) + + # Check mental model was created with correct tags + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, tags FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + assert len(mental_models) >= 1, "Expected mental model to be created" + + # The model should have the fact's tags + mm = mental_models[0] + assert mm["tags"] is not None, "Mental model should have tags" + assert "project_x" in mm["tags"], ( + f"Mental model should have 'project_x' tag, got: {mm['tags']}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_untagged_fact_can_update_scoped_model( + self, memory: MemoryEngine, request_context + ): + """Test that an untagged fact can update a scoped mental model. + + Given: + - Memory with tags=['alice']: "Alice works on machine learning" + - Untagged memory: "Machine learning involves neural networks" + + Expected: + - The scoped model may be updated with the global insight + - OR a global model is created + """ + bank_id = f"test-tag-untagged-update-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain scoped memory + await self._retain_with_tags( + memory, bank_id, + "Alice works on machine learning projects.", + ["alice"], request_context + ) + + # Retain untagged memory on same topic + await memory.retain_async( + bank_id=bank_id, + content="Machine learning involves training neural networks.", + request_context=request_context, + ) + + # Check mental models + async with memory._pool.acquire() as conn: + mental_models = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY created_at + """, + bank_id, + ) + + # Should have at least one model + assert len(mental_models) >= 1, "Expected at least one mental model" + + # Either alice's model was updated OR a global model was created + # This is valid LLM behavior - just verify no errors and structure is correct + for mm in mental_models: + assert mm["text"], "Mental model should have text" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_tag_filtering_in_recall( + self, memory: MemoryEngine, request_context + ): + """Test that mental models respect tag filtering during recall. + + Mental models should be filtered by tags just like memories. + """ + bank_id = f"test-tag-recall-filter-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain memories with different tags + await self._retain_with_tags( + memory, bank_id, + "Alice works as a software engineer.", + ["alice"], request_context + ) + await self._retain_with_tags( + memory, bank_id, + "Bob works as a product manager.", + ["bob"], request_context + ) + + # Recall with alice tag only + recall_result = await memory.recall_async( + bank_id=bank_id, + query="What does everyone do for work?", + tags=["alice"], + tags_match="any_strict", # Only alice's data + fact_type=["world", "experience", "mental_model"], + request_context=request_context, + ) + + # Results should only include alice-tagged content + # Mental models are now regular results with fact_type='mental_model' + mental_models = [r for r in recall_result.results if r.fact_type == "mental_model"] + for mm in mental_models: + # Mental model should be alice-scoped or global (untagged) + # Not bob-scoped + mm_tags = mm.tags or [] + assert "bob" not in mm_tags, ( + f"Recall with tags=['alice'] should not return bob's models: {mm.text}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_multiple_actions_from_single_fact( + self, memory: MemoryEngine, request_context + ): + """Test that one fact can trigger multiple consolidation actions. + + Given: + - Global model: "Coffee is a popular beverage" + - Alice's model: "Alice drinks coffee every morning" + - New fact with tags=['alice']: "Alice switched to decaf coffee" + + Expected: + - Update Alice's scoped model (same scope) + - Potentially update global model too (global absorbs all) + """ + bank_id = f"test-tag-multi-action-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create global model + await memory.retain_async( + bank_id=bank_id, + content="Coffee is a popular beverage worldwide.", + request_context=request_context, + ) + + # Create alice's scoped model + await self._retain_with_tags( + memory, bank_id, + "Alice drinks coffee every morning.", + ["alice"], request_context + ) + + # Check models before + async with memory._pool.acquire() as conn: + mm_before = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + count_before = len(mm_before) + + # Add fact that could relate to both + await self._retain_with_tags( + memory, bank_id, + "Alice switched to decaf coffee for health reasons.", + ["alice"], request_context + ) + + # Check models after + async with memory._pool.acquire() as conn: + mm_after = await conn.fetch( + """ + SELECT id, text, tags, source_memory_ids, proof_count FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + ORDER BY created_at + """, + bank_id, + ) + + # Should have processed without errors + assert len(mm_after) >= 1, "Expected at least one mental model" + + # Check that consolidation worked (either updates or maintains structure) + # The key is no errors and proper tag handling + for mm in mm_after: + assert mm["text"], "Mental model should have text" + # Tags should be consistent (not mixing alice and bob, etc.) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_inherits_dates_from_source_memory( + self, memory: MemoryEngine, request_context + ): + """Test that mental models inherit occurred_start and event_date from source memories. + + When a mental model is created, it should inherit the temporal information + from the source memory that triggered its creation, not use the current time. + """ + from datetime import datetime, timezone + + bank_id = f"test-consolidation-dates-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create a specific date in the past for testing + past_date = datetime(2023, 6, 15, 10, 30, 0, tzinfo=timezone.utc) + + # First, create a memory unit directly with a specific date + async with memory._pool.acquire() as conn: + memory_id = uuid.uuid4() + await conn.execute( + """ + INSERT INTO memory_units ( + id, bank_id, text, fact_type, occurred_start, event_date, created_at + ) + VALUES ($1, $2, $3, 'experience', $4, $4, now()) + """, + memory_id, + bank_id, + "Sarah went to Paris for vacation and loved the Eiffel Tower.", + past_date, + ) + + # Run consolidation manually + from hindsight_api.engine.consolidation.consolidator import run_consolidation_job + + result = await run_consolidation_job( + memory_engine=memory, + bank_id=bank_id, + request_context=request_context, + ) + + # Verify consolidation processed the memory + assert result["status"] == "completed" + assert result["memories_processed"] >= 1 + + # Check that mental model inherited the date from source memory + async with memory._pool.acquire() as conn: + mental_model = await conn.fetchrow( + """ + SELECT id, text, occurred_start, event_date, source_memory_ids + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + LIMIT 1 + """, + bank_id, + ) + + if mental_model: + # Mental model should have inherited the date from the source memory + mm_occurred = mental_model["occurred_start"] + mm_event_date = mental_model["event_date"] + + # Dates should match the source memory's date (2023-06-15), not today + assert mm_occurred is not None, "Mental model should have occurred_start" + assert mm_event_date is not None, "Mental model should have event_date" + + # The date should be from 2023, not today + assert mm_occurred.year == 2023, ( + f"Expected occurred_start year 2023, got {mm_occurred.year}. " + "Mental model should inherit date from source memory." + ) + assert mm_occurred.month == 6, f"Expected month 6, got {mm_occurred.month}" + assert mm_occurred.day == 15, f"Expected day 15, got {mm_occurred.day}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestMentalModelDrillDown: + """Test that reflect agent can drill down from mental models to source memories.""" + + @pytest.mark.asyncio + async def test_search_mental_models_returns_source_memory_ids( + self, memory: MemoryEngine, request_context + ): + """Test that search_mental_models returns source_memory_ids for drill-down. + + This verifies the agent can: + 1. Find a mental model + 2. Access its source_memory_ids + 3. Use those IDs to expand/recall for more details + """ + from hindsight_api.engine.reflect.tools import tool_search_mental_models, tool_expand + + bank_id = f"test-mm-drilldown-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Store memories with specific details that get summarized in mental model + await memory.retain_async( + bank_id=bank_id, + content="Sarah works at TechCorp as a senior software engineer since March 2020.", + request_context=request_context, + ) + await memory.retain_async( + bank_id=bank_id, + content="Sarah's employee ID at TechCorp is EMP-12345.", + request_context=request_context, + ) + + # Search for mental models + result = await tool_search_mental_models( + memory_engine=memory, + bank_id=bank_id, + query="Sarah TechCorp", + request_context=request_context, + ) + + assert result["count"] > 0, "Expected at least one mental model" + + # Verify source_memory_ids and proof_count are present + mm = result["mental_models"][0] + assert "source_memory_ids" in mm, "Mental model should have source_memory_ids" + assert "proof_count" in mm, "Mental model should have proof_count" + assert mm["proof_count"] >= 1, "proof_count should be at least 1" + + # If source_memory_ids exist, verify they can be used with expand + if mm["source_memory_ids"]: + assert len(mm["source_memory_ids"]) >= 1, "Should have at least one source memory" + + # Use expand tool to get source memory details + async with memory._pool.acquire() as conn: + expand_result = await tool_expand( + conn=conn, + bank_id=bank_id, + memory_ids=mm["source_memory_ids"][:2], # Take first 2 + depth="chunk", + ) + + assert "results" in expand_result + assert len(expand_result["results"]) > 0, "Expand should return source memories" + + # Verify we get the original detailed information + all_text = " ".join(r["memory"]["text"] for r in expand_result["results"] if "memory" in r) + # The expanded memories should contain details not necessarily in the mental model + assert "Sarah" in all_text or "TechCorp" in all_text, ( + f"Expanded memories should contain source details. Got: {all_text}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_mental_model_source_ids_match_contributing_memories( + self, memory: MemoryEngine, request_context + ): + """Test that source_memory_ids actually point to the memories that built the mental model.""" + bank_id = f"test-mm-source-ids-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Store two related memories + await memory.retain_async( + bank_id=bank_id, + content="Project Phoenix was started by the engineering team in January 2024.", + request_context=request_context, + ) + await memory.retain_async( + bank_id=bank_id, + content="Project Phoenix achieved 99.9% uptime in its first quarter.", + request_context=request_context, + ) + + # Get the mental model with source_memory_ids + async with memory._pool.acquire() as conn: + mm_rows = await conn.fetch( + """ + SELECT id, text, proof_count, source_memory_ids + FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + + if mm_rows: + mm = mm_rows[0] + source_ids = mm["source_memory_ids"] or [] + + # Verify source_memory_ids point to actual memories + if source_ids: + async with memory._pool.acquire() as conn: + source_memories = await conn.fetch( + """ + SELECT id, text FROM memory_units + WHERE id = ANY($1) AND fact_type IN ('world', 'experience') + """, + source_ids, + ) + + # Should have found the source memories + assert len(source_memories) >= 1, ( + f"source_memory_ids should point to valid memories. " + f"IDs: {source_ids}, Found: {len(source_memories)}" + ) + + # The source memories should contain our original content + source_texts = [m["text"].lower() for m in source_memories] + has_phoenix = any("phoenix" in t for t in source_texts) + assert has_phoenix, f"Source memories should contain original content. Got: {source_texts}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestHierarchicalRetrieval: + """Test the reflect agent's hierarchical retrieval tools. + + The hierarchy is: + 1. search_reflections - User-curated summaries (highest quality) + 2. search_mental_models - Auto-consolidated knowledge + 3. recall - Raw facts as ground truth + + When a reflection matches the query, it should be used first. + """ + + @pytest.mark.asyncio + async def test_reflection_takes_priority_over_mental_model( + self, memory: MemoryEngine, request_context + ): + """Test that reflections are found and would be used before mental models. + + Given: + - A memory about "John's favorite color is blue" + - A mental model created from that memory (via consolidation) + - A reflection manually created about John + + When searching, the reflection should be found first. + """ + bank_id = f"test-hierarchy-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation creates a mental model + await memory.retain_async( + bank_id=bank_id, + content="John's favorite color is blue and he likes painting.", + request_context=request_context, + ) + + # Verify mental model was created + async with memory._pool.acquire() as conn: + mm_count = await conn.fetchval( + """ + SELECT COUNT(*) FROM memory_units + WHERE bank_id = $1 AND fact_type = 'mental_model' + """, + bank_id, + ) + assert mm_count >= 1, "Consolidation should have created a mental model" + + # Create a reflection about John (higher quality, user-curated) + reflection = await memory.create_reflection( + bank_id=bank_id, + name="John's Preferences", + source_query="What are John's preferences?", + content="John is an artist who loves the color blue. He has been painting for 10 years and prefers watercolors.", + tags=[], + request_context=request_context, + ) + assert reflection["id"] is not None + + # Search reflections - should find our reflection + async with memory._pool.acquire() as conn: + query_embedding = memory.embeddings.encode(["What does John like?"])[0] + reflection_result = await tool_search_reflections( + conn=conn, + bank_id=bank_id, + query="What does John like?", + query_embedding=query_embedding, + max_results=5, + ) + + # Reflection should be found + assert reflection_result["count"] >= 1, "Reflection should be found" + found_reflection = reflection_result["reflections"][0] + assert "John" in found_reflection["content"] or "blue" in found_reflection["content"] + + # Search mental models - should also find something + mm_result = await tool_search_mental_models( + memory_engine=memory, + bank_id=bank_id, + query="What does John like?", + request_context=request_context, + max_tokens=5000, + ) + assert mm_result["count"] >= 1, "Mental model should also be found" + + # Verify the reflection has higher quality content (more detail) + reflection_content = found_reflection["content"] + mm_content = mm_result["mental_models"][0]["text"] + + # The reflection should contain the richer, user-curated content + assert "watercolors" in reflection_content or "10 years" in reflection_content, ( + f"Reflection should have the rich user-curated content. Got: {reflection_content}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_fallback_to_mental_model_when_no_reflection( + self, memory: MemoryEngine, request_context + ): + """Test that mental models are used when no reflection matches. + + Given: + - A memory about "Sarah works at Google" + - A mental model created from that memory + - NO reflection about Sarah + + When searching, mental models should provide the information. + """ + bank_id = f"test-hierarchy-fallback-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain a memory - consolidation creates a mental model + await memory.retain_async( + bank_id=bank_id, + content="Sarah works at Google as a software engineer.", + request_context=request_context, + ) + + # Search reflections - should find nothing + async with memory._pool.acquire() as conn: + query_embedding = memory.embeddings.encode(["Where does Sarah work?"])[0] + reflection_result = await tool_search_reflections( + conn=conn, + bank_id=bank_id, + query="Where does Sarah work?", + query_embedding=query_embedding, + max_results=5, + ) + + # No reflections exist + assert reflection_result["count"] == 0, "No reflections should exist" + + # Search mental models - should find the consolidated knowledge + mm_result = await tool_search_mental_models( + memory_engine=memory, + bank_id=bank_id, + query="Where does Sarah work?", + request_context=request_context, + max_tokens=5000, + ) + + # Mental model should be found + assert mm_result["count"] >= 1, "Mental model should be found when no reflection exists" + mm_text = mm_result["mental_models"][0]["text"].lower() + assert "sarah" in mm_text or "google" in mm_text, ( + f"Mental model should contain info about Sarah. Got: {mm_text}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_fallback_to_recall_for_fresh_data( + self, memory: MemoryEngine, request_context + ): + """Test that recall provides raw facts when needed for verification. + + This tests the drill-down capability: when mental models are stale or + need verification, recall provides the original source facts. + """ + bank_id = f"test-hierarchy-recall-{uuid.uuid4().hex[:8]}" + + # Create the bank + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Retain some specific memories + await memory.retain_async( + bank_id=bank_id, + content="The quarterly revenue was $1.5M in Q3 2024.", + request_context=request_context, + ) + await memory.retain_async( + bank_id=bank_id, + content="The quarterly revenue was $2.1M in Q4 2024.", + request_context=request_context, + ) + + # Use recall to get the raw facts + recall_result = await tool_recall( + memory_engine=memory, + bank_id=bank_id, + query="What was the quarterly revenue?", + request_context=request_context, + max_tokens=2048, + max_results=10, + ) + + # Should have raw facts with specific numbers + assert recall_result["count"] >= 1, "Recall should find the raw facts" + + # Check that we get the actual numbers from the original memories + all_memory_text = " ".join([m["text"] for m in recall_result["memories"]]) + assert "$1.5M" in all_memory_text or "$2.1M" in all_memory_text, ( + f"Recall should return raw facts with specific data. Got: {all_memory_text}" + ) + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_emergent_filtering.py b/hindsight-api/tests/test_emergent_filtering.py deleted file mode 100644 index a26d7723..00000000 --- a/hindsight-api/tests/test_emergent_filtering.py +++ /dev/null @@ -1,516 +0,0 @@ -"""Tests for emergent entity filtering.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock - -from hindsight_api.engine.mental_models.emergent import ( - build_mission_filter_prompt, - evaluate_emergent_models, - filter_candidates_by_mission, - MissionFilterResponse, - MissionFilterCandidate, -) -from hindsight_api.engine.mental_models.models import EmergentCandidate - - -class TestBuildMissionFilterPrompt: - """Test prompt building for mission filtering.""" - - def test_prompt_contains_mission(self): - """Test that prompt includes the mission.""" - candidates = [ - EmergentCandidate( - name="Alice", - detection_method="named_entity_extraction", - mention_count=10, - ) - ] - prompt = build_mission_filter_prompt("Be a PM for engineering team", candidates) - assert "Be a PM for engineering team" in prompt - - def test_prompt_contains_candidates(self): - """Test that prompt includes all candidates.""" - candidates = [ - EmergentCandidate( - name="Alice Chen", - detection_method="named_entity_extraction", - mention_count=10, - ), - EmergentCandidate( - name="Project Phoenix", - detection_method="named_entity_extraction", - mention_count=5, - ), - ] - prompt = build_mission_filter_prompt("Track projects", candidates) - assert "Alice Chen" in prompt - assert "Project Phoenix" in prompt - - def test_prompt_contains_rejection_guidance(self): - """Test that prompt contains guidance to reject generic entities.""" - candidates = [ - EmergentCandidate( - name="test", - detection_method="named_entity_extraction", - mention_count=1, - ) - ] - prompt = build_mission_filter_prompt("Test mission", candidates) - - # Should contain rejection guidance for generic terms - assert "promote=false" in prompt - assert "kids" in prompt # Example of generic term to reject - assert "community" in prompt # Example of abstract concept to reject - assert "motivation" in prompt # Example of abstract concept to reject - - -class TestFilterCandidatesByMission: - """Test the filter_candidates_by_mission function.""" - - @pytest.fixture - def mock_llm_config(self): - """Create a mock LLM config.""" - config = MagicMock() - config.call = AsyncMock() - return config - - async def test_empty_candidates(self, mock_llm_config): - """Test with empty candidate list.""" - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Test mission", - candidates=[], - ) - assert result == [] - mock_llm_config.call.assert_not_called() - - async def test_no_mission_keeps_all(self, mock_llm_config): - """Test that no mission keeps all candidates (skips filtering).""" - candidates = [ - EmergentCandidate( - name="Alice", - detection_method="named_entity_extraction", - mention_count=10, - ) - ] - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="", # Empty mission - candidates=candidates, - ) - assert len(result) == 1 - assert result[0].name == "Alice" - mock_llm_config.call.assert_not_called() - - async def test_filters_by_promote_flag(self, mock_llm_config): - """Test that candidates are filtered by promote flag.""" - candidates = [ - EmergentCandidate( - name="Alice Chen", - detection_method="named_entity_extraction", - mention_count=10, - ), - EmergentCandidate( - name="community", - detection_method="named_entity_extraction", - mention_count=5, - ), - ] - - # Mock LLM response - Alice is promoted, community is not - mock_llm_config.call.return_value = MissionFilterResponse( - candidates=[ - MissionFilterCandidate(name="Alice Chen", promote=True, reason="Specific person"), - MissionFilterCandidate(name="community", promote=False, reason="Generic abstract concept"), - ] - ) - - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Be a PM for engineering team", - candidates=candidates, - ) - - assert len(result) == 1 - assert result[0].name == "Alice Chen" - - async def test_rejects_generic_entities(self, mock_llm_config): - """Test that generic entities are rejected.""" - # These are all generic/abstract terms that should be rejected - generic_names = [ - "user", "support", "community", "family", "motivation", - "photo", "gratitude", "difference", "volunteering", - "kids", "veterans", "impact", "kindness", "encouragement", - "education", "nature", "joy", "positivity", "inspiration", - "help", "commitment", "passion", "energy", "connection", - ] - candidates = [ - EmergentCandidate( - name=name, - detection_method="named_entity_extraction", - mention_count=10, - ) - for name in generic_names - ] - - # Add some valid candidates - valid_candidates = [ - EmergentCandidate( - name="John", - detection_method="named_entity_extraction", - mention_count=10, - ), - EmergentCandidate( - name="Maria", - detection_method="named_entity_extraction", - mention_count=8, - ), - EmergentCandidate( - name="Max", - detection_method="named_entity_extraction", - mention_count=6, - ), - ] - candidates.extend(valid_candidates) - - # Mock LLM response - reject all generic, promote only specific names - response_candidates = [ - MissionFilterCandidate(name=name, promote=False, reason="Generic/abstract term") - for name in generic_names - ] - response_candidates.extend([ - MissionFilterCandidate(name=c.name, promote=True, reason="Specific person name") - for c in valid_candidates - ]) - - mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates) - - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Be a health coach", - candidates=candidates, - ) - - # Should only have John, Maria, and Max - result_names = {c.name for c in result} - assert result_names == {"John", "Maria", "Max"} - - async def test_accepts_specific_named_entities(self, mock_llm_config): - """Test that specific named entities are accepted.""" - # These should all be accepted - valid_names = [ - "Alice Chen", # Full name - "Dr. Smith", # Title + name - "John", # First name (when it's clearly a person) - "Google", # Organization - "Frontend Team", # Named team - "Project Phoenix", # Named project - "NYC Office", # Named place - "Q4 Planning", # Named event - "Sprint 23 Review", # Named meeting - ] - candidates = [ - EmergentCandidate( - name=name, - detection_method="named_entity_extraction", - mention_count=10, - ) - for name in valid_names - ] - - # Mock LLM response - promote all - response_candidates = [ - MissionFilterCandidate(name=name, promote=True, reason="Specific named entity") - for name in valid_names - ] - mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates) - - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Be a PM for engineering team", - candidates=candidates, - ) - - # Should have all valid names - result_names = {c.name for c in result} - assert result_names == set(valid_names) - - async def test_llm_error_rejects_all_candidates(self, mock_llm_config): - """Test that LLM errors result in rejecting all candidates (fail-safe).""" - candidates = [ - EmergentCandidate( - name="Alice", - detection_method="named_entity_extraction", - mention_count=10, - ) - ] - - mock_llm_config.call.side_effect = Exception("LLM error") - - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Test mission", - candidates=candidates, - ) - - # Should reject all candidates on error (fail-safe) - assert len(result) == 0 - - async def test_missing_candidate_in_response_is_rejected(self, mock_llm_config): - """Test that candidates not in LLM response are rejected by default.""" - candidates = [ - EmergentCandidate( - name="Alice", - detection_method="named_entity_extraction", - mention_count=10, - ), - EmergentCandidate( - name="Bob", - detection_method="named_entity_extraction", - mention_count=5, - ), - ] - - # Mock LLM response - only includes Alice, not Bob - mock_llm_config.call.return_value = MissionFilterResponse( - candidates=[ - MissionFilterCandidate(name="Alice", promote=True, reason="Specific person"), - ] - ) - - result = await filter_candidates_by_mission( - llm_config=mock_llm_config, - mission="Test mission", - candidates=candidates, - ) - - # Only Alice should be in result (Bob was missing from response, so rejected) - assert len(result) == 1 - assert result[0].name == "Alice" - - -class TestEvaluateEmergentModels: - """Test the evaluate_emergent_models function for cleanup of existing models.""" - - @pytest.fixture - def mock_llm_config(self): - """Create a mock LLM config.""" - config = MagicMock() - config.call = AsyncMock() - return config - - async def test_empty_models(self, mock_llm_config): - """Test with empty model list.""" - result = await evaluate_emergent_models( - llm_config=mock_llm_config, - models=[], - ) - assert result == [] - mock_llm_config.call.assert_not_called() - - async def test_removes_generic_models(self, mock_llm_config): - """Test that generic/abstract models are marked for removal.""" - models = [ - {"id": "id-kids", "name": "kids"}, - {"id": "id-community", "name": "community"}, - {"id": "id-motivation", "name": "motivation"}, - {"id": "id-john", "name": "John"}, - {"id": "id-maria", "name": "Maria"}, - ] - - # Mock LLM response - reject generic, keep specific names - mock_llm_config.call.return_value = MissionFilterResponse( - candidates=[ - MissionFilterCandidate(name="kids", promote=False, reason="Generic category"), - MissionFilterCandidate(name="community", promote=False, reason="Abstract concept"), - MissionFilterCandidate(name="motivation", promote=False, reason="Abstract concept"), - MissionFilterCandidate(name="John", promote=True, reason="Person name"), - MissionFilterCandidate(name="Maria", promote=True, reason="Person name"), - ] - ) - - result = await evaluate_emergent_models( - llm_config=mock_llm_config, - models=models, - ) - - # Should return IDs of generic models to remove - assert set(result) == {"id-kids", "id-community", "id-motivation"} - - async def test_keeps_specific_named_models(self, mock_llm_config): - """Test that specific named models are kept.""" - models = [ - {"id": "id-john", "name": "John"}, - {"id": "id-google", "name": "Google"}, - {"id": "id-project", "name": "Project Phoenix"}, - ] - - # Mock LLM response - keep all - mock_llm_config.call.return_value = MissionFilterResponse( - candidates=[ - MissionFilterCandidate(name="John", promote=True, reason="Person name"), - MissionFilterCandidate(name="Google", promote=True, reason="Organization"), - MissionFilterCandidate(name="Project Phoenix", promote=True, reason="Named project"), - ] - ) - - result = await evaluate_emergent_models( - llm_config=mock_llm_config, - models=models, - ) - - # No models should be removed - assert result == [] - - async def test_llm_error_keeps_all_models(self, mock_llm_config): - """Test that LLM errors result in keeping all models (safe default).""" - models = [ - {"id": "id-kids", "name": "kids"}, - {"id": "id-john", "name": "John"}, - ] - - mock_llm_config.call.side_effect = Exception("LLM error") - - result = await evaluate_emergent_models( - llm_config=mock_llm_config, - models=models, - ) - - # Should keep all models on error (return empty removal list) - assert result == [] - - async def test_missing_model_in_response_is_removed(self, mock_llm_config): - """Test that models not in LLM response are marked for removal.""" - models = [ - {"id": "id-alice", "name": "Alice"}, - {"id": "id-bob", "name": "Bob"}, - ] - - # Mock LLM response - only includes Alice - mock_llm_config.call.return_value = MissionFilterResponse( - candidates=[ - MissionFilterCandidate(name="Alice", promote=True, reason="Person name"), - ] - ) - - result = await evaluate_emergent_models( - llm_config=mock_llm_config, - models=models, - ) - - # Bob should be marked for removal (missing from response) - assert result == ["id-bob"] - - -class TestRemovedEntitiesNotRepromoted: - """Test that entities removed by evaluation are not re-promoted. - - This tests the fix for a bug where: - 1. evaluate_emergent_models returns model IDs to remove (e.g., 'entity-maya') - 2. We delete those models - 3. detect_entity_candidates finds the same entities (now eligible since model was deleted) - 4. filter_candidates_by_goal approves them (different LLM call) - 5. BUG: We were re-promoting the same entities we just removed - - The fix tracks removed entity_ids and excludes them from promotion. - """ - - async def test_removed_entity_ids_excluded_from_promotion(self): - """Test that entities whose models were removed are not re-promoted.""" - from hindsight_api.engine.mental_models.models import EmergentCandidate - - # Simulate the scenario from the bug: - # - existing_emergent has model 'entity-maya' with entity_id='uuid-maya' - # - evaluate_emergent_models says to remove 'entity-maya' - # - detect_entity_candidates returns 'Maya' with entity_id='uuid-maya' (now eligible) - # - filter_candidates_by_goal says to promote 'Maya' - # - But we should NOT promote because we just removed it - - existing_emergent = [ - {"id": "entity-maya", "name": "Maya", "entity_id": "uuid-maya"}, - {"id": "entity-alex", "name": "Alex", "entity_id": "uuid-alex"}, - {"id": "entity-john", "name": "John", "entity_id": "uuid-john"}, # This one will be kept - ] - - # Models to remove (evaluate_emergent_models would return these) - models_to_remove = ["entity-maya", "entity-alex"] - - # Build model_id -> entity_id mapping (this is what the fix does) - model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent} - - # Track removed entity_ids - removed_entity_ids: set[str] = set() - for model_id in models_to_remove: - entity_id = model_to_entity.get(model_id) - if entity_id: - removed_entity_ids.add(str(entity_id)) - - # Verify we tracked the right entity_ids - assert removed_entity_ids == {"uuid-maya", "uuid-alex"} - - # Now simulate candidates that were detected (includes removed entities) - candidates = [ - EmergentCandidate( - name="Maya", entity_id="uuid-maya", detection_method="named_entity", mention_count=10 - ), - EmergentCandidate( - name="Alex", entity_id="uuid-alex", detection_method="named_entity", mention_count=8 - ), - EmergentCandidate( - name="NewPerson", entity_id="uuid-new", detection_method="named_entity", mention_count=5 - ), - ] - - # Filter out candidates whose entity was just removed (the fix) - filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids] - - # Only NewPerson should remain - Maya and Alex were removed and should not be re-promoted - assert len(filtered_candidates) == 1 - assert filtered_candidates[0].name == "NewPerson" - assert filtered_candidates[0].entity_id == "uuid-new" - - async def test_candidates_without_matching_removal_are_kept(self): - """Test that candidates not in the removed set are still promoted.""" - from hindsight_api.engine.mental_models.models import EmergentCandidate - - # No models removed - removed_entity_ids: set[str] = set() - - candidates = [ - EmergentCandidate( - name="Alice", entity_id="uuid-alice", detection_method="named_entity", mention_count=10 - ), - EmergentCandidate( - name="Bob", entity_id="uuid-bob", detection_method="named_entity", mention_count=8 - ), - ] - - # Filter (should keep all since nothing was removed) - filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids] - - assert len(filtered_candidates) == 2 - assert {c.name for c in filtered_candidates} == {"Alice", "Bob"} - - async def test_partial_removal_keeps_other_candidates(self): - """Test that only removed entities are excluded, others pass through.""" - from hindsight_api.engine.mental_models.models import EmergentCandidate - - # Only one entity removed - removed_entity_ids = {"uuid-removed"} - - candidates = [ - EmergentCandidate( - name="Removed", entity_id="uuid-removed", detection_method="named_entity", mention_count=10 - ), - EmergentCandidate( - name="Kept1", entity_id="uuid-kept1", detection_method="named_entity", mention_count=8 - ), - EmergentCandidate( - name="Kept2", entity_id="uuid-kept2", detection_method="named_entity", mention_count=5 - ), - ] - - filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids] - - assert len(filtered_candidates) == 2 - assert {c.name for c in filtered_candidates} == {"Kept1", "Kept2"} diff --git a/hindsight-api/tests/test_extensions.py b/hindsight-api/tests/test_extensions.py index 1b7bf103..88a2f5d3 100644 --- a/hindsight-api/tests/test_extensions.py +++ b/hindsight-api/tests/test_extensions.py @@ -17,8 +17,6 @@ from hindsight_api.extensions import ( RecallResult, ReflectContext, ReflectResultContext, - RefreshMentalModelContext, - RefreshMentalModelResult, RequestContext, RetainContext, RetainResult, @@ -95,7 +93,6 @@ class RateLimitingValidator(OperationValidatorExtension): self.retain_counts: dict[str, int] = defaultdict(int) self.recall_counts: dict[str, int] = defaultdict(int) self.reflect_counts: dict[str, int] = defaultdict(int) - self.refresh_mental_model_counts: dict[str, int] = defaultdict(int) async def validate_retain(self, ctx: RetainContext) -> ValidationResult: self.retain_counts[ctx.bank_id] += 1 @@ -121,16 +118,6 @@ class RateLimitingValidator(OperationValidatorExtension): ) return ValidationResult.accept() - async def validate_refresh_mental_model( - self, ctx: RefreshMentalModelContext - ) -> ValidationResult: - self.refresh_mental_model_counts[ctx.bank_id] += 1 - if self.refresh_mental_model_counts[ctx.bank_id] > self.max_attempts: - return ValidationResult.reject( - f"Refresh mental model limit exceeded for bank {ctx.bank_id}" - ) - return ValidationResult.accept() - class TrackingValidator(OperationValidatorExtension): """ @@ -145,12 +132,10 @@ class TrackingValidator(OperationValidatorExtension): self.pre_retain_calls: list[RetainContext] = [] self.pre_recall_calls: list[RecallContext] = [] self.pre_reflect_calls: list[ReflectContext] = [] - self.pre_refresh_mental_model_calls: list[RefreshMentalModelContext] = [] # Post-hook tracking self.post_retain_calls: list[RetainResult] = [] self.post_recall_calls: list[RecallResult] = [] self.post_reflect_calls: list[ReflectResultContext] = [] - self.post_refresh_mental_model_calls: list[RefreshMentalModelResult] = [] async def validate_retain(self, ctx: RetainContext) -> ValidationResult: self.pre_retain_calls.append(ctx) @@ -164,12 +149,6 @@ class TrackingValidator(OperationValidatorExtension): self.pre_reflect_calls.append(ctx) return ValidationResult.accept() - async def validate_refresh_mental_model( - self, ctx: RefreshMentalModelContext - ) -> ValidationResult: - self.pre_refresh_mental_model_calls.append(ctx) - return ValidationResult.accept() - async def on_retain_complete(self, result: RetainResult) -> None: self.post_retain_calls.append(result) @@ -179,11 +158,6 @@ class TrackingValidator(OperationValidatorExtension): async def on_reflect_complete(self, result: ReflectResultContext) -> None: self.post_reflect_calls.append(result) - async def on_refresh_mental_model_complete( - self, result: RefreshMentalModelResult - ) -> None: - self.post_refresh_mental_model_calls.append(result) - class TestMemoryEngineValidation: """Tests for validation integration with MemoryEngine. @@ -541,105 +515,6 @@ class TestOperationHooksParameters: assert len(validator.pre_recall_calls) == 1 assert len(validator.post_recall_calls) == 1 - @pytest.mark.asyncio - async def test_refresh_mental_model_pre_hook_receives_all_parameters( - self, memory_with_tracking_validator - ): - """Pre-refresh-mental-model hook receives all user-provided parameters.""" - import uuid - - memory, validator = memory_with_tracking_validator - bank_id = f"test-refresh-mm-params-{uuid.uuid4().hex[:8]}" - ctx = RequestContext(api_key="test-key") - - # Create bank first (get_bank_profile auto-creates if needed) - await memory.get_bank_profile(bank_id, request_context=ctx) - - # Create a pinned mental model - model = await memory.create_mental_model( - bank_id=bank_id, - name="Test Model", - description="Test description", - subtype="pinned", - request_context=ctx, - ) - - assert model is not None - model_id = model["id"] - - # Attempt to refresh (may not actually refresh if no data, but hook should be called) - try: - await memory.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=ctx, - ) - except Exception: - pass # May fail if no data - - # Check pre-hook was called - assert len(validator.pre_refresh_mental_model_calls) == 1 - pre_ctx = validator.pre_refresh_mental_model_calls[0] - assert pre_ctx.bank_id == bank_id - assert pre_ctx.model_id == model_id - assert pre_ctx.request_context == ctx - - @pytest.mark.asyncio - async def test_refresh_mental_model_post_hook_receives_token_usage( - self, memory_with_tracking_validator - ): - """Post-refresh-mental-model hook receives token usage information.""" - import uuid - - memory, validator = memory_with_tracking_validator - bank_id = f"test-refresh-mm-tokens-{uuid.uuid4().hex[:8]}" - ctx = RequestContext(api_key="test-key") - - # Store some content first - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Alice is a software engineer who works on machine learning."}, - {"content": "Alice enjoys hiking and outdoor activities on weekends."}, - {"content": "Alice has been working at the company for 5 years."}, - ], - request_context=ctx, - ) - - # Create a pinned mental model - model = await memory.create_mental_model( - bank_id=bank_id, - name="Alice Profile", - description="Profile of Alice including work and hobbies", - subtype="pinned", - request_context=ctx, - ) - - if model: - model_id = model["id"] - - # Refresh the mental model - result = await memory.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=ctx, - ) - - # Check post-hook was called with token usage - if validator.post_refresh_mental_model_calls: - post_result = validator.post_refresh_mental_model_calls[0] - assert post_result.bank_id == bank_id - assert post_result.model_id == model_id - assert post_result.request_context == ctx - assert post_result.success is True - assert post_result.error is None - - # Token usage should be populated (may be 0 if refresh was skipped) - assert post_result.total_tokens >= 0 - assert post_result.input_tokens >= 0 - assert post_result.output_tokens >= 0 - assert post_result.duration_ms >= 0 - class TestTenantExtension: """Tests for TenantExtension and ApiKeyTenantExtension.""" diff --git a/hindsight-api/tests/test_llm_tools.py b/hindsight-api/tests/test_llm_tools.py index 6947eb2e..400c52a4 100644 --- a/hindsight-api/tests/test_llm_tools.py +++ b/hindsight-api/tests/test_llm_tools.py @@ -241,24 +241,27 @@ class TestReflectToolSchemas: tools = get_reflect_tools() tool_names = [t["function"]["name"] for t in tools] - assert "list_mental_models" in tool_names - assert "get_mental_model" in tool_names + assert "search_reflections" in tool_names + assert "search_mental_models" in tool_names assert "recall" in tool_names - assert "learn" in tool_names assert "expand" in tool_names assert "done" in tool_names - def test_get_reflect_tools_without_learn(self): - """Test getting reflect tools without learn.""" + def test_get_reflect_tools_with_directives(self): + """Test getting reflect tools with directive rules.""" from hindsight_api.engine.reflect.tools_schema import get_reflect_tools - tools = get_reflect_tools(enable_learn=False) + tools = get_reflect_tools(directive_rules=["Always respond in French"]) tool_names = [t["function"]["name"] for t in tools] - assert "learn" not in tool_names assert "recall" in tool_names assert "done" in tool_names + # Done tool should have directive_compliance field when directives are present + done_tool = next(t for t in tools if t["function"]["name"] == "done") + params = done_tool["function"]["parameters"]["properties"] + assert "directive_compliance" in params + def test_get_reflect_tools_answer_mode(self): """Test getting reflect tools with answer output mode.""" from hindsight_api.engine.reflect.tools_schema import get_reflect_tools @@ -270,7 +273,8 @@ class TestReflectToolSchemas: assert "answer" in params assert "memory_ids" in params - assert "model_ids" in params + assert "mental_model_ids" in params + assert "reflection_ids" in params class TestLLMToolCallResult: diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py index 7faccae7..0923fad0 100644 --- a/hindsight-api/tests/test_main_module.py +++ b/hindsight-api/tests/test_main_module.py @@ -363,7 +363,6 @@ from hindsight_api.extensions import ( RetainContext, RecallContext, ReflectContext, - RefreshMentalModelContext, ) @@ -395,6 +394,3 @@ class MockOperationValidator(OperationValidatorExtension): async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: return ValidationResult.accept() - - async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult: - return ValidationResult.accept() diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py index 14f95240..c391c34e 100644 --- a/hindsight-api/tests/test_mental_models.py +++ b/hindsight-api/tests/test_mental_models.py @@ -1,4 +1,8 @@ -"""Tests for mental model functionality (v4 system).""" +"""Tests for directive functionality. + +Directives are hard rules injected into prompts. +They are stored in the 'directives' table. +""" import uuid @@ -8,20 +12,16 @@ from hindsight_api.engine.memory_engine import MemoryEngine @pytest.fixture -async def memory_with_mission(memory: MemoryEngine, request_context): - """Memory engine with a bank that has a mission set. +async def memory_with_bank(memory: MemoryEngine, request_context): + """Memory engine with a bank that has some data. Uses a unique bank_id to avoid conflicts between parallel tests. """ # Use unique bank_id to avoid conflicts between parallel tests - bank_id = f"test-mental-models-{uuid.uuid4().hex[:8]}" + bank_id = f"test-directives-{uuid.uuid4().hex[:8]}" - # Set up the bank with a mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Be a PM for the engineering team", - request_context=request_context, - ) + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) # Add some test data await memory.retain_batch_async( @@ -30,8 +30,6 @@ async def memory_with_mission(memory: MemoryEngine, request_context): {"content": "The team has daily standups at 9am where everyone shares their progress."}, {"content": "Alice is the frontend engineer and specializes in React."}, {"content": "Bob is the backend engineer and owns the API services."}, - {"content": "Sprint retrospectives happen every two weeks to discuss improvements."}, - {"content": "John is the tech lead and makes final decisions on architecture."}, ], request_context=request_context, ) @@ -70,340 +68,257 @@ class TestBankMission: await memory.delete_bank(bank_id, request_context=request_context) -class TestRefreshMentalModels: - """Test the main refresh_mental_models flow.""" +class TestDirectives: + """Test directive functionality.""" - async def test_refresh_creates_structural_models(self, memory_with_mission, request_context): - """Test that refresh creates structural models from the mission.""" - memory, bank_id = memory_with_mission - - # Refresh mental models (async - returns operation_id) - result = await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # Check that we got an operation ID back - assert "operation_id" in result - assert result["status"] == "queued" - - # Wait for background task to complete - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - assert len(models) > 0 - - # Check that structural models were created - structural_models = [m for m in models if m["subtype"] == "structural"] - assert len(structural_models) > 0 - - # Check that models have the expected structure - for model in models: - assert "id" in model - assert "name" in model - assert "description" in model - assert model["subtype"] in ["structural", "emergent"] - - async def test_refresh_without_mission_fails(self, memory: MemoryEngine, request_context): - """Test that refresh fails when no mission is set.""" - bank_id = f"test-no-mission-refresh-{uuid.uuid4().hex[:8]}" - - # Add some data but don't set a mission - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Alice is the frontend engineer."}, - {"content": "Bob is the backend engineer."}, - ], - request_context=request_context, - ) - - # Wait for any background tasks from retain to complete - await memory.wait_for_background_tasks() - - # Refresh mental models should fail without a mission - with pytest.raises(ValueError) as exc_info: - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - assert "no mission is set" in str(exc_info.value).lower() - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - -class TestMentalModelCRUD: - """Test basic CRUD operations for mental models.""" - - async def test_list_mental_models(self, memory_with_mission, request_context): - """Test listing mental models.""" - memory, bank_id = memory_with_mission - - # Refresh to create models (async) - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # List all models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - assert len(models) > 0 - - # Test filtering by subtype - structural_models = await memory.list_mental_models( - bank_id=bank_id, - subtype="structural", - request_context=request_context, - ) - - assert all(m["subtype"] == "structural" for m in structural_models) - - async def test_get_mental_model(self, memory_with_mission, request_context): - """Test getting a mental model by ID.""" - memory, bank_id = memory_with_mission - - # Refresh to create models (async) - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # Get one by ID - model_id = models[0]["id"] - model = await memory.get_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - assert model is not None - assert model["id"] == model_id - - # Test non-existent - not_found = await memory.get_mental_model( - bank_id=bank_id, - model_id="non-existent", - request_context=request_context, - ) - assert not_found is None - - async def test_delete_mental_model(self, memory_with_mission, request_context): - """Test deleting a mental model.""" - memory, bank_id = memory_with_mission - - # Refresh to create models (async) - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # Delete one - model_id = models[0]["id"] - deleted = await memory.delete_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - assert deleted is True - - # Verify it's gone - model = await memory.get_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - assert model is None - - # Delete non-existent returns False - deleted_again = await memory.delete_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - assert deleted_again is False - - async def test_create_pinned_mental_model(self, memory: MemoryEngine, request_context): - """Test creating a pinned mental model.""" - bank_id = f"test-pinned-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists by getting its profile (auto-creates if needed) - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create a pinned mental model - model = await memory.create_mental_model( - bank_id=bank_id, - name="Product Roadmap", - description="Key product priorities and upcoming features", - tags=["project-x"], - request_context=request_context, - ) - - assert model["name"] == "Product Roadmap" - assert model["description"] == "Key product priorities and upcoming features" - assert model["subtype"] == "pinned" - assert model["tags"] == ["project-x"] - assert model["id"] == "pinned-product-roadmap" - - # Verify it can be retrieved - retrieved = await memory.get_mental_model( - bank_id=bank_id, - model_id=model["id"], - request_context=request_context, - ) - assert retrieved is not None - assert retrieved["subtype"] == "pinned" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_create_pinned_model_duplicate_fails(self, memory: MemoryEngine, request_context): - """Test that creating a duplicate pinned model fails.""" - bank_id = f"test-pinned-dup-{uuid.uuid4().hex[:8]}" + async def test_create_directive(self, memory: MemoryEngine, request_context): + """Test creating a directive.""" + bank_id = f"test-directive-{uuid.uuid4().hex[:8]}" # Ensure bank exists await memory.get_bank_profile(bank_id, request_context=request_context) - # Create first model - await memory.create_mental_model( + # Create a directive + directive = await memory.create_directive( bank_id=bank_id, - name="Test Model", - description="First model", + name="Competitor Policy", + content="Never mention competitor product names directly. If asked about competitors, redirect to our features.", request_context=request_context, ) - # Try to create duplicate - with pytest.raises(ValueError) as exc_info: - await memory.create_mental_model( - bank_id=bank_id, - name="Test Model", - description="Second model", - request_context=request_context, - ) - - assert "already exists" in str(exc_info.value).lower() + assert directive["name"] == "Competitor Policy" + assert "Never mention competitor" in directive["content"] + assert directive["is_active"] is True + assert directive["priority"] == 0 # Cleanup await memory.delete_bank(bank_id, request_context=request_context) - async def test_pinned_models_survive_refresh(self, memory: MemoryEngine, request_context): - """Test that pinned models are not deleted during refresh.""" - bank_id = f"test-pinned-refresh-{uuid.uuid4().hex[:8]}" + async def test_directive_crud(self, memory: MemoryEngine, request_context): + """Test basic CRUD operations for directives.""" + bank_id = f"test-directive-crud-{uuid.uuid4().hex[:8]}" - # Set a mission - await memory.set_bank_mission( + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create + directive = await memory.create_directive( bank_id=bank_id, - mission="Track customer feedback", + name="Test Directive", + content="Follow this rule", request_context=request_context, ) + directive_id = directive["id"] - # Create a pinned model - pinned_model = await memory.create_mental_model( + # Read + retrieved = await memory.get_directive( bank_id=bank_id, - name="Key Customers", - description="Important customers to track", - request_context=request_context, - ) - - # Refresh mental models - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Verify pinned model still exists - retrieved = await memory.get_mental_model( - bank_id=bank_id, - model_id=pinned_model["id"], + directive_id=directive_id, request_context=request_context, ) assert retrieved is not None - assert retrieved["subtype"] == "pinned" - assert retrieved["name"] == "Key Customers" + assert retrieved["name"] == "Test Directive" + assert retrieved["content"] == "Follow this rule" + + # List + directives = await memory.list_directives( + bank_id=bank_id, + request_context=request_context, + ) + assert len(directives) == 1 + assert directives[0]["id"] == directive_id + + # Update + updated = await memory.update_directive( + bank_id=bank_id, + directive_id=directive_id, + content="Updated rule content", + request_context=request_context, + ) + assert updated["content"] == "Updated rule content" + + # Delete + deleted = await memory.delete_directive( + bank_id=bank_id, + directive_id=directive_id, + request_context=request_context, + ) + assert deleted is True + + # Verify deletion + retrieved_after = await memory.get_directive( + bank_id=bank_id, + directive_id=directive_id, + request_context=request_context, + ) + assert retrieved_after is None + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_priority(self, memory: MemoryEngine, request_context): + """Test that directive priority works correctly.""" + bank_id = f"test-directive-priority-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create directives with different priorities + await memory.create_directive( + bank_id=bank_id, + name="Low Priority", + content="Low priority rule", + priority=1, + request_context=request_context, + ) + + await memory.create_directive( + bank_id=bank_id, + name="High Priority", + content="High priority rule", + priority=10, + request_context=request_context, + ) + + # List should order by priority (desc) + directives = await memory.list_directives( + bank_id=bank_id, + request_context=request_context, + ) + assert len(directives) == 2 + assert directives[0]["name"] == "High Priority" + assert directives[1]["name"] == "Low Priority" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_is_active(self, memory: MemoryEngine, request_context): + """Test that inactive directives are filtered by default.""" + bank_id = f"test-directive-active-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create active and inactive directives + await memory.create_directive( + bank_id=bank_id, + name="Active Rule", + content="This is active", + is_active=True, + request_context=request_context, + ) + + await memory.create_directive( + bank_id=bank_id, + name="Inactive Rule", + content="This is inactive", + is_active=False, + request_context=request_context, + ) + + # List active only (default) + active_directives = await memory.list_directives( + bank_id=bank_id, + active_only=True, + request_context=request_context, + ) + assert len(active_directives) == 1 + assert active_directives[0]["name"] == "Active Rule" + + # List all + all_directives = await memory.list_directives( + bank_id=bank_id, + active_only=False, + request_context=request_context, + ) + assert len(all_directives) == 2 # Cleanup await memory.delete_bank(bank_id, request_context=request_context) -class TestMentalModelRefresh: - """Test mental model summary refresh functionality.""" +class TestDirectiveTags: + """Test tags functionality for directives.""" - async def test_refresh_creates_models_with_summaries(self, memory_with_mission, request_context): - """Test that refresh_mental_models creates models and generates summaries.""" - memory, bank_id = memory_with_mission + async def test_directive_with_tags(self, memory: MemoryEngine, request_context): + """Test creating a directive with tags.""" + bank_id = f"test-directive-tags-{uuid.uuid4().hex[:8]}" - # Refresh mental models (async - creates models and generates summaries) - result = await memory.refresh_mental_models( + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create a directive with tags + directive = await memory.create_directive( bank_id=bank_id, + name="Tagged Rule", + content="Follow this rule", + tags=["project-a", "team-x"], request_context=request_context, ) - assert "operation_id" in result - assert result["status"] == "queued" + assert directive["tags"] == ["project-a", "team-x"] - # Wait for background task to complete (includes summary generation) - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( + # Retrieve and verify tags + retrieved = await memory.get_directive( bank_id=bank_id, + directive_id=directive["id"], + request_context=request_context, + ) + assert retrieved["tags"] == ["project-a", "team-x"] + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_list_directives_by_tags(self, memory: MemoryEngine, request_context): + """Test listing directives filtered by tags.""" + bank_id = f"test-directive-tags-list-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create directives with different tags + await memory.create_directive( + bank_id=bank_id, + name="Rule A", + content="Rule for project A", + tags=["project-a"], request_context=request_context, ) - assert len(models) > 0 - - # After async refresh completes, models should have summaries generated - for model in models: - assert "id" in model - assert "name" in model - # Summaries should be generated now (unless no relevant facts found) - # We don't strictly assert on summary presence since it depends on data - - async def test_refresh_nonexistent_mental_model(self, memory: MemoryEngine, request_context): - """Test refreshing a non-existent mental model returns None.""" - bank_id = f"test-refresh-noexist-{uuid.uuid4().hex[:8]}" - - result = await memory.refresh_mental_model( + await memory.create_directive( bank_id=bank_id, - model_id="does-not-exist", + name="Rule B", + content="Rule for project B", + tags=["project-b"], request_context=request_context, ) - assert result is None + # List all + all_directives = await memory.list_directives( + bank_id=bank_id, + request_context=request_context, + ) + assert len(all_directives) == 2 + + # Filter by project-a tag + filtered = await memory.list_directives( + bank_id=bank_id, + tags=["project-a"], + request_context=request_context, + ) + assert len(filtered) == 1 + assert filtered[0]["name"] == "Rule A" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) class TestReflect: - """Test reflect endpoint with mental models.""" + """Test reflect endpoint.""" - async def test_reflect_basic(self, memory_with_mission, request_context): - """Test basic reflect query - reflect works even without mental models.""" - memory, bank_id = memory_with_mission + async def test_reflect_basic(self, memory_with_bank, request_context): + """Test basic reflect query works.""" + memory, bank_id = memory_with_bank # Run a reflect query result = await memory.reflect_async( @@ -416,582 +331,6 @@ class TestReflect: assert len(result.text) > 0 -class TestMentalModelLearnTool: - """Test mental model learn tool - creates placeholders with background generation.""" - - async def test_learn_creates_placeholder(self, memory: MemoryEngine, request_context): - """Test that learn tool creates a placeholder mental model without observations.""" - bank_id = f"test-source-facts-{uuid.uuid4().hex[:8]}" - - # Add some test data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Alice is the team lead."}, - {"content": "Bob is the engineer."}, - ], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Directly use the learn tool to create a mental model placeholder - from hindsight_api.engine.reflect.models import MentalModelInput - from hindsight_api.engine.reflect.tools import tool_learn - - input_model = MentalModelInput( - name="Team Members", - description="Key team members and their roles", - ) - - pool = await memory._get_pool() - async with pool.acquire() as conn: - result = await tool_learn(conn, bank_id, input_model) - - assert result["status"] == "created" - assert result["model_id"] == "team-members" - assert result["name"] == "Team Members" - assert result["pending_generation"] is True - - # Verify placeholder was stored in database with empty observations - pool = await memory._get_pool() - async with pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT subtype, name, description, observations FROM mental_models WHERE id = $1 AND bank_id = $2", - result["model_id"], - bank_id, - ) - - assert row is not None - assert row["subtype"] == "learned" - assert row["name"] == "Team Members" - assert row["description"] == "Key team members and their roles" - # Observations should be empty - will be generated in background - observations_data = row["observations"] - # Handle both string and dict representations - if isinstance(observations_data, str): - import json - observations_data = json.loads(observations_data) if observations_data else {} - assert observations_data == {} or observations_data is None - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_learn_update_description(self, memory: MemoryEngine, request_context): - """Test that updating a mental model updates the description.""" - bank_id = f"test-merge-facts-{uuid.uuid4().hex[:8]}" - - # Create bank by retaining some data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Test data"}], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - from hindsight_api.engine.reflect.models import MentalModelInput - from hindsight_api.engine.reflect.tools import tool_learn - - # First create a placeholder - input_model = MentalModelInput( - name="Team Members", - description="Initial description", - ) - - pool = await memory._get_pool() - async with pool.acquire() as conn: - result1 = await tool_learn(conn, bank_id, input_model) - - assert result1["status"] == "created" - assert result1["pending_generation"] is True - - # Now update with new description - input_model2 = MentalModelInput( - name="Team Members", # Same name = same ID - description="Updated description with more context", - ) - - pool = await memory._get_pool() - async with pool.acquire() as conn: - result2 = await tool_learn(conn, bank_id, input_model2) - - assert result2["status"] == "updated" - assert result2["model_id"] == "team-members" - - # Verify description was updated in database - pool = await memory._get_pool() - async with pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT description FROM mental_models WHERE id = $1 AND bank_id = $2", - result1["model_id"], - bank_id, - ) - - assert row is not None - assert row["description"] == "Updated description with more context" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - -class TestMentalModelTags: - """Test mental model tags functionality.""" - - @pytest.fixture - async def memory_with_mission_and_tags(self, memory: MemoryEngine, request_context): - """Memory engine with a bank that has a mission set and tagged content.""" - bank_id = f"test-mm-tags-{uuid.uuid4().hex[:8]}" - - # Set up the bank with a mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Be a PM for the engineering team", - request_context=request_context, - ) - - # Add some test data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Alice is the frontend engineer."}, - {"content": "Bob is the backend engineer."}, - ], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - yield memory, bank_id - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_refresh_creates_models_with_tags(self, memory_with_mission_and_tags, request_context): - """Test that refresh_mental_models creates models with specified tags.""" - memory, bank_id = memory_with_mission_and_tags - - # Refresh mental models with tags - result = await memory.refresh_mental_models( - bank_id=bank_id, - tags=["project-alpha", "sprint-1"], - request_context=request_context, - ) - - assert "operation_id" in result - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - assert len(models) > 0 - - # All models should have the tags we specified - for model in models: - assert "tags" in model - assert "project-alpha" in model["tags"] - assert "sprint-1" in model["tags"] - - async def test_list_mental_models_filters_by_tags(self, memory_with_mission_and_tags, request_context): - """Test that list_mental_models correctly filters by tags.""" - memory, bank_id = memory_with_mission_and_tags - - # Create models with different tags - await memory.refresh_mental_models( - bank_id=bank_id, - tags=["project-alpha"], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Get all models - all_models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - assert len(all_models) > 0 - - # Filter by tags - should return models with matching tags - filtered_models = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-alpha"], - request_context=request_context, - ) - assert len(filtered_models) == len(all_models) # All models have this tag - - # Filter by non-existent tag - should only return untagged models (none here) - # But since all models have tags, and the filter includes untagged, - # we need to test with a mix - empty_filtered = await memory.list_mental_models( - bank_id=bank_id, - tags=["non-existent-tag"], - request_context=request_context, - ) - # Should return empty since no models are untagged and none match - # Actually, the logic includes untagged models, so let's verify the behavior - # All our models have tags, so only checking for non-existent tag - # should return nothing (since none match and none are untagged) - - async def test_untagged_models_included_in_filter(self, memory: MemoryEngine, request_context): - """Test that untagged mental models are always included when filtering.""" - bank_id = f"test-untagged-{uuid.uuid4().hex[:8]}" - - # Set up bank with mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Track projects", - request_context=request_context, - ) - - # Add some data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Project Alpha is important."}], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # First refresh without tags (creates untagged models) - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, # No tags - ) - await memory.wait_for_background_tasks() - - # Get all models (should be untagged) - all_models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - if len(all_models) > 0: - # Verify models are untagged - for model in all_models: - assert model.get("tags", []) == [] - - # Filter by any tag - untagged models should still be included - filtered_models = await memory.list_mental_models( - bank_id=bank_id, - tags=["some-tag"], - request_context=request_context, - ) - # Untagged models should be included in the results - assert len(filtered_models) == len(all_models) - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_tags_match_any(self, memory: MemoryEngine, request_context): - """Test tags_match='any' returns models with at least one matching tag.""" - bank_id = f"test-tags-any-{uuid.uuid4().hex[:8]}" - - # Set up bank with mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Track projects", - request_context=request_context, - ) - - # Add data and create models with tags - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Alice works on frontend."}], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - await memory.refresh_mental_models( - bank_id=bank_id, - tags=["tag-a", "tag-b"], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Filter with tags_match='any' - should match if any tag matches - models = await memory.list_mental_models( - bank_id=bank_id, - tags=["tag-a", "tag-c"], # tag-a matches, tag-c doesn't - tags_match="any", - request_context=request_context, - ) - - # Models with tag-a should be included - for model in models: - if model.get("tags"): - # At least one of the filter tags should be in the model tags - # OR model is untagged - assert ( - any(t in model["tags"] for t in ["tag-a", "tag-c"]) - or model["tags"] == [] - ) - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_reflect_with_tags_filter(self, memory_with_mission_and_tags, request_context): - """Test that reflect filters memories by tags.""" - memory, bank_id = memory_with_mission_and_tags - - # Create mental models with tags - await memory.refresh_mental_models( - bank_id=bank_id, - tags=["project-x"], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Reflect with matching tags - result = await memory.reflect_async( - bank_id=bank_id, - query="Who are the engineers?", - tags=["project-x"], - request_context=request_context, - ) - - assert result.text is not None - assert len(result.text) > 0 - - # Reflect with non-matching tags - should still work - result2 = await memory.reflect_async( - bank_id=bank_id, - query="Who are the engineers?", - tags=["different-project"], - request_context=request_context, - ) - - assert result2.text is not None - - async def test_mental_model_response_includes_tags(self, memory_with_mission_and_tags, request_context): - """Test that mental model responses include the tags field.""" - memory, bank_id = memory_with_mission_and_tags - - # Create models with tags - await memory.refresh_mental_models( - bank_id=bank_id, - tags=["test-tag"], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Get models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # Verify tags field is present in response - for model in models: - assert "tags" in model - assert isinstance(model["tags"], list) - - # Get single model - if models: - model = await memory.get_mental_model( - bank_id=bank_id, - model_id=models[0]["id"], - request_context=request_context, - ) - assert "tags" in model - assert isinstance(model["tags"], list) - - -class TestDirectives: - """Test directive mental model functionality.""" - - async def test_create_directive(self, memory: MemoryEngine, request_context): - """Test creating a directive mental model with user-provided observations.""" - bank_id = f"test-directive-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create a directive with observations - model = await memory.create_mental_model( - bank_id=bank_id, - name="Competitor Policy", - description="Rules about mentioning competitors", - subtype="directive", - observations=[ - {"title": "Never mention", "content": "Never mention competitor product names directly"}, - {"title": "Redirect", "content": "If asked about competitors, redirect to our features"}, - ], - request_context=request_context, - ) - - assert model["name"] == "Competitor Policy" - assert model["description"] == "Rules about mentioning competitors" - assert model["subtype"] == "directive" - assert len(model["observations"]) == 2 - assert model["observations"][0].title == "Never mention" - assert model["observations"][0].content == "Never mention competitor product names directly" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_directive_included_in_list(self, memory: MemoryEngine, request_context): - """Test that directives are included in list_mental_models for admin visibility.""" - bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}" - - # Set up bank with mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Test mission", - request_context=request_context, - ) - - # Create a directive - directive = await memory.create_mental_model( - bank_id=bank_id, - name="Test Directive", - description="A test directive", - subtype="directive", - observations=[{"title": "Rule", "content": "Follow this rule"}], - request_context=request_context, - ) - - # Create a pinned model - pinned = await memory.create_mental_model( - bank_id=bank_id, - name="Test Pinned", - description="A test pinned model", - request_context=request_context, - ) - - # List without subtype filter - both should appear - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # Both should appear (directives included in API listing for admin visibility) - model_ids = [m["id"] for m in models] - assert pinned["id"] in model_ids - assert directive["id"] in model_ids - - # List with directive subtype filter - should find only directive - directives = await memory.list_mental_models( - bank_id=bank_id, - subtype="directive", - request_context=request_context, - ) - assert len(directives) == 1 - assert directives[0]["id"] == directive["id"] - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context): - """Test that getting a directive returns its user-provided observations.""" - bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create a directive with observations - created = await memory.create_mental_model( - bank_id=bank_id, - name="Meeting Rules", - description="Rules for scheduling meetings", - subtype="directive", - observations=[ - {"title": "No mornings", "content": "Never schedule meetings before noon"}, - {"title": "Max duration", "content": "Meetings should be 30 minutes max"}, - ], - request_context=request_context, - ) - - # Get the directive - retrieved = await memory.get_mental_model( - bank_id=bank_id, - model_id=created["id"], - request_context=request_context, - ) - - assert retrieved is not None - assert retrieved["subtype"] == "directive" - assert len(retrieved["observations"]) == 2 - assert retrieved["observations"][0].title == "No mornings" - assert retrieved["observations"][1].title == "Max duration" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context): - """Test that directives are not modified during refresh_mental_models.""" - bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}" - - # Set up bank with mission - await memory.set_bank_mission( - bank_id=bank_id, - mission="Test mission", - request_context=request_context, - ) - - # Add some test data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Alice is the engineer."}], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Create a directive - directive = await memory.create_mental_model( - bank_id=bank_id, - name="Important Rule", - description="A critical rule", - subtype="directive", - observations=[{"title": "Rule 1", "content": "Always follow this rule"}], - request_context=request_context, - ) - - # Refresh mental models - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Directive should still exist with same observations - retrieved = await memory.get_mental_model( - bank_id=bank_id, - model_id=directive["id"], - request_context=request_context, - ) - - assert retrieved is not None - assert retrieved["subtype"] == "directive" - assert len(retrieved["observations"]) == 1 - assert retrieved["observations"][0].title == "Rule 1" - assert retrieved["observations"][0].content == "Always follow this rule" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_directive_requires_observations(self, memory: MemoryEngine, request_context): - """Test that creating a directive without observations fails.""" - bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Try to create directive without observations - with pytest.raises(ValueError) as exc_info: - await memory.create_mental_model( - bank_id=bank_id, - name="Bad Directive", - description="A directive without observations", - subtype="directive", - # No observations provided - request_context=request_context, - ) - - assert "observations" in str(exc_info.value).lower() - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - class TestDirectivesInReflect: """Test that directives are followed during reflect operations.""" @@ -1015,17 +354,10 @@ class TestDirectivesInReflect: await memory.wait_for_background_tasks() # Create a directive to always respond in French - await memory.create_mental_model( + await memory.create_directive( bank_id=bank_id, name="Language Policy", - description="Rules about language usage", - subtype="directive", - observations=[ - { - "title": "French Only", - "content": "ALWAYS respond in French language. Never respond in English.", - }, - ], + content="ALWAYS respond in French language. Never respond in English.", request_context=request_context, ) @@ -1067,165 +399,6 @@ class TestDirectivesInReflect: # Cleanup await memory.delete_bank(bank_id, request_context=request_context) -class TestMentalModelTagsFiltering: - """Test tags filtering for mental models (all types).""" - - async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context): - """Test that 'any' tags_match mode includes untagged mental models.""" - bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create an UNTAGGED pinned model - await memory.create_mental_model( - bank_id=bank_id, - name="Global Model", - description="A global mental model", - subtype="pinned", - tags=[], # No tags - should be included with "any" mode - request_context=request_context, - ) - - # Test 1: list_mental_models with tags and tags_match="any" should include untagged - models_any = await memory.list_mental_models( - bank_id=bank_id, - tags=["some-tag"], - tags_match="any", # Should include untagged - request_context=request_context, - ) - assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}" - - # Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged - models_strict = await memory.list_mental_models( - bank_id=bank_id, - tags=["some-tag"], - tags_match="any_strict", # Should exclude untagged - request_context=request_context, - ) - assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context): - """Test that strict modes only include mental models with matching tags.""" - bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create a TAGGED pinned model - await memory.create_mental_model( - bank_id=bank_id, - name="Tagged Model", - description="A tagged mental model", - subtype="pinned", - tags=["project-a"], - request_context=request_context, - ) - - # Create an UNTAGGED pinned model - await memory.create_mental_model( - bank_id=bank_id, - name="Untagged Model", - description="An untagged mental model", - subtype="pinned", - tags=[], # No tags - request_context=request_context, - ) - - # Test 1: any_strict with matching tag - should get ONLY the tagged model - models_match = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-a"], - tags_match="any_strict", - request_context=request_context, - ) - assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}" - assert models_match[0]["name"] == "Tagged Model" - - # Test 2: any_strict with different tag - should get NO models - models_no_match = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-b"], - tags_match="any_strict", - request_context=request_context, - ) - assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}" - - # Test 3: any (non-strict) with any tag - should get BOTH models - models_any = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-a"], - tags_match="any", - request_context=request_context, - ) - assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context): - """Test that 'all_strict' requires ALL tags to be present.""" - bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}" - - # Ensure bank exists - await memory.get_bank_profile(bank_id, request_context=request_context) - - # Create a model with multiple tags - await memory.create_mental_model( - bank_id=bank_id, - name="Multi-Tag Model", - description="Has project-a and project-b tags", - subtype="pinned", - tags=["project-a", "project-b"], - request_context=request_context, - ) - - # Create a model with only one tag - await memory.create_mental_model( - bank_id=bank_id, - name="Single-Tag Model", - description="Has only project-a tag", - subtype="pinned", - tags=["project-a"], - request_context=request_context, - ) - - # Test 1: all_strict with both tags - should get ONLY the multi-tag model - models_all = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-a", "project-b"], - tags_match="all_strict", - request_context=request_context, - ) - assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}" - assert models_all[0]["name"] == "Multi-Tag Model" - - # Test 2: all (non-strict) with both tags - should include untagged too - # Add an untagged model - await memory.create_mental_model( - bank_id=bank_id, - name="Untagged Model", - description="No tags", - subtype="pinned", - tags=[], - request_context=request_context, - ) - - models_all_non_strict = await memory.list_mental_models( - bank_id=bank_id, - tags=["project-a", "project-b"], - tags_match="all", - request_context=request_context, - ) - # Should get Multi-Tag Model + Untagged Model - assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - class TestDirectivesPromptInjection: """Test that directives are properly injected into the system prompt.""" @@ -1237,43 +410,24 @@ class TestDirectivesPromptInjection: result = build_directives_section([]) assert result == "" - def test_build_directives_section_with_observations(self): - """Test that directives with observations are formatted correctly.""" + def test_build_directives_section_with_content(self): + """Test that directives with content are formatted correctly.""" from hindsight_api.engine.reflect.prompts import build_directives_section directives = [ { "name": "Competitor Policy", - "observations": [ - {"title": "Never mention", "content": "Never mention competitor names"}, - {"title": "Redirect", "content": "Redirect to our features"}, - ], + "content": "Never mention competitor names. Redirect to our features.", } ] result = build_directives_section(directives) assert "## DIRECTIVES (MANDATORY)" in result - assert "**Never mention**: Never mention competitor names" in result - assert "**Redirect**: Redirect to our features" in result + assert "Competitor Policy" in result + assert "Never mention competitor names" in result assert "NEVER violate these directives" in result - def test_build_directives_section_fallback_to_description(self): - """Test that directives without observations fall back to description.""" - from hindsight_api.engine.reflect.prompts import build_directives_section - - directives = [ - { - "name": "Simple Rule", - "description": "Just a simple rule description", - "observations": [], - } - ] - - result = build_directives_section(directives) - - assert "**Simple Rule**: Just a simple rule description" in result - def test_system_prompt_includes_directives(self): """Test that build_system_prompt_for_tools includes directives.""" from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools @@ -1282,7 +436,7 @@ class TestDirectivesPromptInjection: directives = [ { "name": "Test Directive", - "observations": [{"title": "Rule", "content": "Follow this rule"}], + "content": "Follow this rule", } ] @@ -1292,143 +446,8 @@ class TestDirectivesPromptInjection: ) assert "## DIRECTIVES (MANDATORY)" in prompt - assert "**Rule**: Follow this rule" in prompt + assert "Follow this rule" in prompt # Directives should appear before CRITICAL RULES directives_pos = prompt.find("## DIRECTIVES") critical_rules_pos = prompt.find("## CRITICAL RULES") assert directives_pos < critical_rules_pos - - -class TestMentalModelVersioning: - """Test mental model versioning functionality.""" - - async def test_refresh_creates_version(self, memory_with_mission, request_context): - """Test that refreshing a mental model creates a version entry.""" - memory, bank_id = memory_with_mission - - # First create a mental model via refresh_mental_models - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Get the created models - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - assert len(models) > 0 - - model_id = models[0]["id"] - - # Refresh the specific model to trigger versioning - result = await memory.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - assert result is not None - # Version should be incremented - assert result.get("version", 0) >= 1 - - # Check version history - versions = await memory.get_mental_model_versions( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - assert len(versions) >= 1 - assert versions[0]["version"] >= 1 - assert "created_at" in versions[0] - assert "observation_count" in versions[0] - - async def test_get_specific_version(self, memory_with_mission, request_context): - """Test retrieving a specific version of a mental model.""" - memory, bank_id = memory_with_mission - - # Create and refresh a mental model - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - assert len(models) > 0 - - model_id = models[0]["id"] - - # Refresh to create version - await memory.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - # Get versions - versions = await memory.get_mental_model_versions( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - assert len(versions) >= 1 - - # Get specific version - version_num = versions[0]["version"] - version_data = await memory.get_mental_model_version( - bank_id=bank_id, - model_id=model_id, - version=version_num, - request_context=request_context, - ) - - assert version_data is not None - assert version_data["version"] == version_num - assert "observations" in version_data - - async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context): - """Test that old versions are cleaned up when max is exceeded.""" - memory, bank_id = memory_with_mission - - # Create a mental model - await memory.refresh_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - assert len(models) > 0 - - model_id = models[0]["id"] - - # Refresh multiple times to create versions - for _ in range(3): - await memory.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - # Get versions - should have multiple but within max limit - versions = await memory.get_mental_model_versions( - bank_id=bank_id, - model_id=model_id, - request_context=request_context, - ) - - # Should have versions (exact count depends on config, but at least some) - assert len(versions) >= 1 - # Versions should be in descending order - if len(versions) > 1: - assert versions[0]["version"] > versions[1]["version"] - diff --git a/hindsight-api/tests/test_observation_trends.py b/hindsight-api/tests/test_observation_trends.py deleted file mode 100644 index 7510ae1b..00000000 --- a/hindsight-api/tests/test_observation_trends.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for observation trend computation and evidence-grounded models.""" - -from datetime import datetime, timedelta, timezone - -import pytest - -from hindsight_api.engine.reflect.observations import ( - CandidateObservation, - Observation, - ObservationEvidence, - Trend, - compute_trend, - verify_evidence_quotes, -) - - -class TestComputeTrend: - """Tests for the compute_trend function.""" - - def test_empty_evidence_returns_stale(self): - """No evidence should return STALE trend.""" - trend = compute_trend([]) - assert trend == Trend.STALE - - def test_all_recent_evidence_returns_new(self): - """All evidence within recent window (30 days) should return NEW trend. - - Scenario: User just started using the app and mentioned they like coffee twice. - Both mentions are within the last 2 weeks, so this is a NEW observation. - """ - now = datetime.now(timezone.utc) - evidence = [ - ObservationEvidence( - memory_id="mem-coffee-morning", - quote="I always start my day with a large black coffee", - relevance="Shows preference for coffee and morning routine", - timestamp=now - timedelta(days=5), - ), - ObservationEvidence( - memory_id="mem-coffee-meeting", - quote="grabbed coffee before the standup meeting", - relevance="Confirms regular coffee consumption", - timestamp=now - timedelta(days=10), - ), - ] - - trend = compute_trend(evidence, now=now) - assert trend == Trend.NEW - - def test_no_recent_evidence_returns_stale(self): - """No evidence in recent window should return STALE trend. - - Scenario: User mentioned running 3 months ago but hasn't mentioned it since. - The observation about running as a hobby may no longer be accurate. - """ - now = datetime.now(timezone.utc) - evidence = [ - ObservationEvidence( - memory_id="mem-running-march", - quote="training for a half marathon in the spring", - relevance="Shows interest in running", - timestamp=now - timedelta(days=60), - ), - ObservationEvidence( - memory_id="mem-running-feb", - quote="went for a 10k run this morning", - relevance="Active runner", - timestamp=now - timedelta(days=100), - ), - ] - - trend = compute_trend(evidence, now=now) - assert trend == Trend.STALE - - def test_stable_evidence_distribution(self): - """Evidence spread evenly across time should return STABLE trend. - - Scenario: User has consistently mentioned working remotely over 4 months. - Evidence is well-distributed, indicating a stable, ongoing preference. - """ - now = datetime.now(timezone.utc) - evidence = [ - # Recent (within 30 days) - ObservationEvidence( - memory_id="mem-remote-jan", - quote="working from my home office today", - relevance="Current remote work", - timestamp=now - timedelta(days=5), - ), - ObservationEvidence( - memory_id="mem-remote-dec", - quote="the flexibility of remote work is great", - relevance="Values remote work", - timestamp=now - timedelta(days=15), - ), - # Middle period (30-90 days) - ObservationEvidence( - memory_id="mem-remote-nov", - quote="set up a standing desk at home", - relevance="Invested in home office", - timestamp=now - timedelta(days=45), - ), - ObservationEvidence( - memory_id="mem-remote-oct", - quote="prefer async communication over meetings", - relevance="Remote work style preference", - timestamp=now - timedelta(days=60), - ), - # Older (90+ days) - ObservationEvidence( - memory_id="mem-remote-sep", - quote="switched to fully remote last quarter", - relevance="Original transition to remote", - timestamp=now - timedelta(days=100), - ), - ObservationEvidence( - memory_id="mem-remote-aug", - quote="negotiated remote work in my new contract", - relevance="Intentional choice for remote", - timestamp=now - timedelta(days=120), - ), - ] - - trend = compute_trend(evidence, now=now) - assert trend == Trend.STABLE - - def test_strengthening_trend(self): - """Much more recent evidence than older should return STRENGTHENING trend. - - Scenario: User has been increasingly talking about learning Python recently - after mentioning it once months ago. Interest appears to be growing. - """ - now = datetime.now(timezone.utc) - evidence = [ - # Lots of recent evidence - actively learning - ObservationEvidence( - memory_id="mem-python-project", - quote="finished my first Python project - a web scraper", - relevance="Completed Python project", - timestamp=now - timedelta(days=2), - ), - ObservationEvidence( - memory_id="mem-python-course", - quote="halfway through the Python bootcamp", - relevance="Active learning", - timestamp=now - timedelta(days=5), - ), - ObservationEvidence( - memory_id="mem-python-book", - quote="reading Fluent Python, it's excellent", - relevance="Deepening knowledge", - timestamp=now - timedelta(days=10), - ), - ObservationEvidence( - memory_id="mem-python-practice", - quote="solved 50 LeetCode problems in Python", - relevance="Practicing skills", - timestamp=now - timedelta(days=15), - ), - ObservationEvidence( - memory_id="mem-python-ide", - quote="set up VS Code with all the Python extensions", - relevance="Setting up environment", - timestamp=now - timedelta(days=20), - ), - # Only one old mention - initial interest - ObservationEvidence( - memory_id="mem-python-start", - quote="thinking about learning Python someday", - relevance="Initial interest", - timestamp=now - timedelta(days=100), - ), - ] - - trend = compute_trend(evidence, now=now) - assert trend == Trend.STRENGTHENING - - def test_weakening_trend(self): - """Much less recent evidence than older should return WEAKENING trend. - - Scenario: User was very active in a book club last year but mentions - have tapered off. The observation about being a book club member - may be becoming less relevant. - """ - now = datetime.now(timezone.utc) - evidence = [ - # Only one recent mention - ObservationEvidence( - memory_id="mem-book-recent", - quote="haven't had time for book club lately", - relevance="Reduced participation", - timestamp=now - timedelta(days=10), - ), - # Lots of older evidence - was very active - ObservationEvidence( - memory_id="mem-book-aug", - quote="hosting book club at my place next week", - relevance="Active organizer", - timestamp=now - timedelta(days=40), - ), - ObservationEvidence( - memory_id="mem-book-july", - quote="leading the discussion on 1984", - relevance="Active participant", - timestamp=now - timedelta(days=50), - ), - ObservationEvidence( - memory_id="mem-book-june", - quote="we picked The Midnight Library for June", - relevance="Regular member", - timestamp=now - timedelta(days=60), - ), - ObservationEvidence( - memory_id="mem-book-may", - quote="book club was amazing tonight", - relevance="Enthusiastic member", - timestamp=now - timedelta(days=100), - ), - ObservationEvidence( - memory_id="mem-book-april", - quote="joined a new book club in my neighborhood", - relevance="Started participation", - timestamp=now - timedelta(days=110), - ), - ObservationEvidence( - memory_id="mem-book-march", - quote="excited to finally join a book club", - relevance="Initial enthusiasm", - timestamp=now - timedelta(days=120), - ), - ] - - trend = compute_trend(evidence, now=now) - assert trend == Trend.WEAKENING - - -class TestObservationModel: - """Tests for the Observation model.""" - - def test_observation_computed_trend(self): - """Observation should have computed trend property based on evidence.""" - now = datetime.now(timezone.utc) - obs = Observation( - title="Morning meeting preference", - content="Prefers morning meetings over afternoon ones", - evidence=[ - ObservationEvidence( - memory_id="mem-morning-standup", - quote="I'm most productive in morning meetings", - relevance="Direct preference statement", - timestamp=now - timedelta(days=5), - ), - ], - created_at=now, - ) - - assert obs.trend == Trend.NEW - assert obs.evidence_count == 1 - - def test_observation_evidence_span(self): - """Observation should compute evidence span correctly. - - The span shows the date range of supporting evidence, helping - understand how long this pattern has been observed. - """ - now = datetime.now(timezone.utc) - old_time = now - timedelta(days=100) - recent_time = now - timedelta(days=5) - - obs = Observation( - title="Values work-life balance", - content="Values work-life balance highly", - evidence=[ - ObservationEvidence( - memory_id="mem-balance-old", - quote="turned down a promotion because of the hours", - relevance="Prioritized balance over advancement", - timestamp=old_time, - ), - ObservationEvidence( - memory_id="mem-balance-recent", - quote="always log off by 6pm no matter what", - relevance="Maintains boundaries", - timestamp=recent_time, - ), - ], - created_at=now, - ) - - evidence_span = obs.evidence_span - assert evidence_span["from"] == old_time.isoformat() - assert evidence_span["to"] == recent_time.isoformat() - - def test_observation_empty_evidence_span(self): - """Observation with no evidence should have null span.""" - obs = Observation( - title="Test observation", - content="Test observation without evidence", - evidence=[], - ) - - evidence_span = obs.evidence_span - assert evidence_span["from"] is None - assert evidence_span["to"] is None - - -class TestVerifyEvidenceQuotes: - """Tests for evidence quote verification. - - This ensures the LLM isn't hallucinating quotes - every quote - must actually appear in the source memory. - """ - - def test_valid_quotes(self): - """Should return True when quotes exist in their source memories.""" - obs = Observation( - title="Enjoys hiking", - content="Enjoys hiking on weekends", - evidence=[ - ObservationEvidence( - memory_id="mem-hiking-trip", - quote="went hiking at Mount Tam", - relevance="Shows hiking activity", - timestamp=datetime.now(timezone.utc), - ), - ], - ) - - memories = { - "mem-hiking-trip": "Had a great Saturday - went hiking at Mount Tam with friends and saw amazing views." - } - is_valid, errors = verify_evidence_quotes(obs, memories) - - assert is_valid is True - assert len(errors) == 0 - - def test_invalid_quote(self): - """Should return False when quote doesn't exist in memory. - - This catches LLM hallucinations where it fabricates quotes. - """ - obs = Observation( - title="Loves spicy food", - content="Loves spicy food", - evidence=[ - ObservationEvidence( - memory_id="mem-dinner", - quote="I love extra hot salsa", - relevance="Shows spicy food preference", - timestamp=datetime.now(timezone.utc), - ), - ], - ) - - memories = {"mem-dinner": "Had tacos for dinner. The guacamole was really fresh."} - is_valid, errors = verify_evidence_quotes(obs, memories) - - assert is_valid is False - assert len(errors) == 1 - assert "Quote not found" in errors[0] - - def test_missing_memory(self): - """Should return False when referenced memory doesn't exist. - - This catches cases where the LLM references a memory ID that - was never actually retrieved. - """ - obs = Observation( - title="Has a dog named Max", - content="Has a dog named Max", - evidence=[ - ObservationEvidence( - memory_id="mem-pet-story", - quote="took Max to the vet", - relevance="Shows pet ownership", - timestamp=datetime.now(timezone.utc), - ), - ], - ) - - memories = {"mem-different-id": "Some unrelated memory content"} - is_valid, errors = verify_evidence_quotes(obs, memories) - - assert is_valid is False - assert len(errors) == 1 - assert "not found" in errors[0] - - -class TestCandidateObservation: - """Tests for candidate observation model. - - Candidates are generated in the SEED phase and validated - before becoming full observations. - """ - - def test_create_candidate(self): - """Should create candidate with content and seed memories.""" - candidate = CandidateObservation( - content="User prefers async communication over meetings", - seed_memory_ids=["mem-slack-pref", "mem-meeting-decline"], - ) - - assert candidate.content == "User prefers async communication over meetings" - assert len(candidate.seed_memory_ids) == 2 - assert "mem-slack-pref" in candidate.seed_memory_ids diff --git a/hindsight-api/tests/test_reflect_agent.py b/hindsight-api/tests/test_reflect_agent.py index 132022f8..5ebd79ef 100644 --- a/hindsight-api/tests/test_reflect_agent.py +++ b/hindsight-api/tests/test_reflect_agent.py @@ -1,1150 +1,284 @@ -"""Tests for the reflect agent and its tools.""" +""" +Tests for the reflect agent with mocked LLM outputs. -import uuid -from unittest.mock import AsyncMock, MagicMock +These tests verify: +1. Tool name normalization for various LLM output formats +2. Recovery from unknown tool calls +3. Recovery from tool execution errors +""" import pytest +from unittest.mock import AsyncMock, MagicMock, patch -from hindsight_api.engine.reflect.agent import run_reflect_agent -from hindsight_api.engine.reflect.models import ( - AnswerSection, - MentalModelInput, - MentalModelObservation, - ReflectAction, - ReflectActionBatch, - ReflectAgentResult, -) -from hindsight_api.engine.reflect.tools import ( - generate_model_id, - tool_expand, - tool_learn, - tool_lookup, - tool_recall, +from hindsight_api.engine.reflect.agent import ( + _normalize_tool_name, + _is_done_tool, + run_reflect_agent, ) from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult -class TestGenerateModelId: - """Test model ID generation.""" - - def test_basic_name(self): - """Test simple name conversion.""" - assert generate_model_id("My Model") == "my-model" - - def test_special_characters(self): - """Test name with special characters.""" - assert generate_model_id("Alice's Project (2024)") == "alice-s-project-2024" - - def test_truncation(self): - """Test long name truncation.""" - long_name = "A" * 100 - result = generate_model_id(long_name) - assert len(result) <= 50 - - def test_leading_trailing_hyphens(self): - """Test that leading/trailing hyphens are stripped.""" - assert generate_model_id("--Test--") == "test" - - -class TestToolLookup: - """Test the lookup tool.""" - - @pytest.fixture - def mock_conn(self): - """Create a mock database connection.""" - conn = AsyncMock() - return conn - - async def test_list_all_models(self, mock_conn): - """Test listing all mental models (compact: id, name, description only).""" - mock_conn.fetch.return_value = [ - { - "id": "model-1", - "subtype": "learned", - "name": "Model 1", - "description": "First model", - }, - { - "id": "model-2", - "subtype": "structural", - "name": "Model 2", - "description": "Second model", - }, - ] - - result = await tool_lookup(mock_conn, "test-bank") - - assert result["count"] == 2 - assert len(result["models"]) == 2 - assert result["models"][0]["id"] == "model-1" - assert result["models"][0]["name"] == "Model 1" - assert "observation_titles" not in result["models"][0] # No observation_titles in list view - assert result["models"][1]["id"] == "model-2" - - async def test_get_specific_model(self, mock_conn): - """Test getting a specific mental model.""" - mock_conn.fetchrow.return_value = { - "id": "model-1", - "subtype": "learned", - "name": "Model 1", - "description": "First model", - "observations": { - "observations": [ - { - "title": "Overview", - "content": "Full summary of model 1", - "evidence": [ - {"memory_id": "mem-1", "quote": "quote 1", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"}, - {"memory_id": "mem-2", "quote": "quote 2", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"}, - ], - "created_at": "2024-01-01T00:00:00Z", - } - ] - }, - "entity_id": None, - "last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"), - } - - result = await tool_lookup(mock_conn, "test-bank", "model-1") - - assert result["found"] is True - assert result["model"]["id"] == "model-1" - assert len(result["model"]["observations"]) == 1 - # Observations are now Observation objects - obs = result["model"]["observations"][0] - assert obs.content == "Full summary of model 1" - assert obs.title == "Overview" - assert len(obs.evidence) == 2 - assert obs.evidence[0].memory_id == "mem-1" - - async def test_model_not_found(self, mock_conn): - """Test looking up non-existent model.""" - mock_conn.fetchrow.return_value = None - - result = await tool_lookup(mock_conn, "test-bank", "non-existent") - - assert result["found"] is False - assert result["model_id"] == "non-existent" - - -class TestToolLearn: - """Test the learn tool. - - The learn tool creates placeholder mental models with name/description only. - Actual content is generated in the background via refresh. - """ - - @pytest.fixture - def mock_conn(self): - """Create a mock database connection.""" - conn = AsyncMock() - return conn - - async def test_create_new_model(self, mock_conn): - """Test creating a new mental model placeholder.""" - mock_conn.fetchrow.return_value = None # Model doesn't exist - - input_model = MentalModelInput( - name="Test Model", - description="A test model to track important patterns", - ) - - result = await tool_learn(mock_conn, "test-bank", input_model) - - assert result["status"] == "created" - assert result["model_id"] == "test-model" - assert result["name"] == "Test Model" - assert result["pending_generation"] is True - mock_conn.execute.assert_called_once() - - async def test_update_existing_model(self, mock_conn): - """Test updating an existing mental model.""" - mock_conn.fetchrow.return_value = {"id": "test-model"} # Model exists - - input_model = MentalModelInput( - name="Test Model", - description="Updated description", - ) - - result = await tool_learn(mock_conn, "test-bank", input_model) - - assert result["status"] == "updated" - assert result["model_id"] == "test-model" - - async def test_learn_with_entity_id(self, mock_conn): - """Test creating model linked to an entity.""" - mock_conn.fetchrow.return_value = None - - entity_uuid = str(uuid.uuid4()) - input_model = MentalModelInput( - name="Entity Model", - description="Model linked to entity", - entity_id=entity_uuid, - ) - - result = await tool_learn(mock_conn, "test-bank", input_model) - - assert result["status"] == "created" - assert result["pending_generation"] is True - # Verify entity_uuid was passed to the execute call - call_args = mock_conn.execute.call_args - assert uuid.UUID(entity_uuid) in call_args[0] - - async def test_learn_creates_empty_observations(self, mock_conn): - """Test that learn creates model with empty observations (content generated later).""" - mock_conn.fetchrow.return_value = None # Model doesn't exist - - input_model = MentalModelInput( - name="Model With Sources", - description="A model to track source facts", - ) - - result = await tool_learn(mock_conn, "test-bank", input_model) - - assert result["status"] == "created" - assert result["pending_generation"] is True - # Verify the INSERT query was called with empty observations - call_args = mock_conn.execute.call_args - # observations should be empty JSON - assert "'{}'::jsonb" in call_args[0][0] - - -class TestToolExpand: - """Test the expand tool.""" - - @pytest.fixture - def mock_conn(self): - """Create a mock database connection.""" - conn = AsyncMock() - return conn - - async def test_empty_memory_ids(self, mock_conn): - """Test expand with empty memory_ids list.""" - result = await tool_expand(mock_conn, "test-bank", [], "chunk") - - assert "error" in result - assert "memory_ids is required" in result["error"] - - async def test_invalid_memory_id(self, mock_conn): - """Test expand with invalid UUID format.""" - result = await tool_expand(mock_conn, "test-bank", ["not-a-uuid"], "chunk") - - assert "error" in result - assert "No valid memory IDs provided" in result["error"] - - async def test_memory_not_found(self, mock_conn): - """Test expand with non-existent memory.""" - mock_conn.fetch.return_value = [] # No memories found - memory_id = str(uuid.uuid4()) - - result = await tool_expand(mock_conn, "test-bank", [memory_id], "chunk") - - assert "results" in result - assert len(result["results"]) == 1 - assert "error" in result["results"][0] - assert "Memory not found" in result["results"][0]["error"] - - async def test_expand_to_chunk(self, mock_conn): - """Test expanding memory to chunk level.""" - memory_id = uuid.uuid4() - # Mock batch fetch for memories - mock_conn.fetch.side_effect = [ - # First call: get memories - [ - { - "id": memory_id, - "text": "Memory text", - "chunk_id": "chunk-1", - "document_id": "doc-1", - "fact_type": "experience", - "context": "some context", - } - ], - # Second call: get chunks - [ - { - "chunk_id": "chunk-1", - "chunk_text": "Full chunk text with more context", - "chunk_index": 0, - "document_id": "doc-1", - } - ], - ] - - result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "chunk") - - assert "results" in result - assert len(result["results"]) == 1 - assert result["results"][0]["memory"]["text"] == "Memory text" - assert result["results"][0]["chunk"]["text"] == "Full chunk text with more context" - assert "document" not in result["results"][0] # depth=chunk doesn't include document - - async def test_expand_to_document(self, mock_conn): - """Test expanding memory to document level.""" - memory_id = uuid.uuid4() - mock_conn.fetch.side_effect = [ - # First call: get memories - [ - { - "id": memory_id, - "text": "Memory text", - "chunk_id": "chunk-1", - "document_id": "doc-1", - "fact_type": "experience", - "context": None, - } - ], - # Second call: get chunks - [ - { - "chunk_id": "chunk-1", - "chunk_text": "Chunk text", - "chunk_index": 0, - "document_id": "doc-1", - } - ], - # Third call: get documents - [ - { - "id": "doc-1", - "original_text": "Full document text here", - "metadata": {"source": "test"}, - "retain_params": {}, - } - ], - ] - - result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "document") - - assert "results" in result - assert len(result["results"]) == 1 - assert "memory" in result["results"][0] - assert "chunk" in result["results"][0] - assert "document" in result["results"][0] - assert result["results"][0]["document"]["full_text"] == "Full document text here" - - async def test_expand_multiple_memories(self, mock_conn): - """Test expanding multiple memories in a single batch.""" - memory_id_1 = uuid.uuid4() - memory_id_2 = uuid.uuid4() - mock_conn.fetch.side_effect = [ - # First call: get memories - [ - { - "id": memory_id_1, - "text": "Memory 1", - "chunk_id": "chunk-1", - "document_id": "doc-1", - "fact_type": "experience", - "context": None, - }, - { - "id": memory_id_2, - "text": "Memory 2", - "chunk_id": "chunk-2", - "document_id": "doc-1", - "fact_type": "world", - "context": None, - }, - ], - # Second call: get chunks - [ - { - "chunk_id": "chunk-1", - "chunk_text": "Chunk 1 text", - "chunk_index": 0, - "document_id": "doc-1", - }, - { - "chunk_id": "chunk-2", - "chunk_text": "Chunk 2 text", - "chunk_index": 1, - "document_id": "doc-1", - }, - ], - ] - - result = await tool_expand(mock_conn, "test-bank", [str(memory_id_1), str(memory_id_2)], "chunk") - - assert "results" in result - assert result["count"] == 2 - assert result["results"][0]["memory"]["text"] == "Memory 1" - assert result["results"][1]["memory"]["text"] == "Memory 2" - - -class TestToolRecall: - """Test the recall tool.""" - - async def test_recall_returns_memories(self): - """Test recall searches and returns memories.""" - mock_engine = AsyncMock() - mock_result = MagicMock() - mock_result.results = [ - MagicMock( - id=uuid.uuid4(), - text="Memory 1", - fact_type="experience", - entities=["Alice"], - occurred_start="2024-01-01", - ), - MagicMock( - id=uuid.uuid4(), - text="Memory 2", - fact_type="world", - entities=None, - occurred_start=None, - ), - ] - mock_engine.recall_async.return_value = mock_result - - mock_request_context = MagicMock() - - result = await tool_recall(mock_engine, "test-bank", "test query", mock_request_context) - - assert result["query"] == "test query" - assert result["count"] == 2 - assert len(result["memories"]) == 2 - assert result["memories"][0]["text"] == "Memory 1" - assert result["memories"][0]["entities"] == ["Alice"] - - # Verify recall_async was called with correct params - mock_engine.recall_async.assert_called_once() - call_kwargs = mock_engine.recall_async.call_args[1] - assert call_kwargs["bank_id"] == "test-bank" - assert call_kwargs["query"] == "test query" - assert call_kwargs["fact_type"] == ["experience", "world"] # No opinions - - -class TestPromptSize: - """Test that prompts stay within reasonable size limits. - - Large prompts cause slow LLM responses (120s+ observed in production). - The agent should not pre-load all mental models; use lookup() instead. - """ - - def test_initial_prompt_is_small(self): - """Verify the initial prompt (no tool history) is reasonably small.""" - from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools - - # Typical bank profile - bank_profile = { - "name": "Test Assistant", - "mission": "A helpful assistant for tracking engineering team activities. Help the team stay organized and informed.", - } - - # First iteration: no context history - context_history: list[dict] = [] - query = "Who should take ownership of storing load test scripts in Git?" - - # No additional context (mental models not pre-loaded) - prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None) - system_prompt = build_system_prompt_for_tools(bank_profile) - - total_chars = len(prompt) + len(system_prompt) - estimated_tokens = total_chars // 4 # Rough estimate - - # Initial prompt should be under 3000 tokens (~12k chars) - # This ensures fast LLM responses on the first iteration - assert total_chars < 12000, f"Initial prompt too large: {total_chars} chars (~{estimated_tokens} tokens)" - assert estimated_tokens < 3000, f"Initial prompt too large: ~{estimated_tokens} tokens" - - def test_prompt_with_tool_history_grows_reasonably(self): - """Verify prompts grow reasonably with tool results.""" - from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools - - bank_profile = { - "name": "Test Assistant", - "mission": "A helpful assistant. Help the team.", - } - - # Simulate recall result with 50 memories (realistic scenario) - recall_result = { - "query": "test query", - "count": 50, - "memories": [ - {"id": f"mem-{i}", "text": f"This is memory number {i} with some content.", "type": "experience"} - for i in range(50) - ], - } - - context_history = [{"tool": "recall", "input": {"query": "test"}, "output": recall_result}] - query = "What do you know about the team?" - - prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None) - system_prompt = build_system_prompt_for_tools(bank_profile) - - total_chars = len(prompt) + len(system_prompt) - estimated_tokens = total_chars // 4 - - # With tool results, prompt should still be manageable (<20k tokens) - assert total_chars < 80000, f"Prompt with tools too large: {total_chars} chars (~{estimated_tokens} tokens)" - - -class TestReflectAgent: - """Test the reflect agent loop with native tool calling.""" +class TestToolNameNormalization: + """Test tool name normalization for various LLM output formats.""" + + def test_normalize_standard_name(self): + """Standard tool names should pass through unchanged.""" + assert _normalize_tool_name("done") == "done" + assert _normalize_tool_name("recall") == "recall" + assert _normalize_tool_name("search_reflections") == "search_reflections" + assert _normalize_tool_name("search_mental_models") == "search_mental_models" + assert _normalize_tool_name("expand") == "expand" + + def test_normalize_functions_prefix(self): + """Tool names with 'functions.' prefix should be normalized.""" + assert _normalize_tool_name("functions.done") == "done" + assert _normalize_tool_name("functions.recall") == "recall" + assert _normalize_tool_name("functions.search_reflections") == "search_reflections" + + def test_normalize_call_equals_prefix(self): + """Tool names with 'call=' prefix should be normalized.""" + assert _normalize_tool_name("call=done") == "done" + assert _normalize_tool_name("call=recall") == "recall" + + def test_normalize_call_equals_functions_prefix(self): + """Tool names with 'call=functions.' prefix should be normalized.""" + assert _normalize_tool_name("call=functions.done") == "done" + assert _normalize_tool_name("call=functions.recall") == "recall" + assert _normalize_tool_name("call=functions.search_mental_models") == "search_mental_models" + + def test_is_done_tool(self): + """Test _is_done_tool helper.""" + # Standard + assert _is_done_tool("done") is True + assert _is_done_tool("recall") is False + + # With prefixes + assert _is_done_tool("functions.done") is True + assert _is_done_tool("call=done") is True + assert _is_done_tool("call=functions.done") is True + + # Not done + assert _is_done_tool("functions.recall") is False + assert _is_done_tool("call=functions.recall") is False + + +class TestReflectAgentMocked: + """Test reflect agent with mocked LLM outputs.""" @pytest.fixture def mock_llm(self): """Create a mock LLM provider.""" - llm = AsyncMock() + llm = MagicMock() + llm.call_with_tools = AsyncMock() + # Also mock call() for final iteration fallback + llm.call = AsyncMock(return_value="Fallback answer from final iteration") return llm @pytest.fixture - def bank_profile(self): - """Create a test bank profile.""" + def mock_functions(self): + """Create mock search/recall functions.""" return { - "name": "Test Assistant", - "mission": "A helpful test assistant. Help with testing.", + "search_reflections_fn": AsyncMock(return_value={"reflections": []}), + "search_mental_models_fn": AsyncMock(return_value={"mental_models": []}), + "recall_fn": AsyncMock(return_value={"memories": [{"id": "mem-1", "content": "test memory"}]}), + "expand_fn": AsyncMock(return_value={"memories": []}), } - @pytest.fixture - def mock_tools(self): - """Create mock tool callbacks.""" - # Include memory IDs in recall results so guardrail passes - memory_id = str(uuid.uuid4()) - return { - "lookup_fn": AsyncMock(return_value={"count": 0, "models": []}), - "recall_fn": AsyncMock(return_value={ - "query": "test", - "count": 1, - "memories": [{"id": memory_id, "text": "Memory", "type": "experience"}] - }), - "learn_fn": AsyncMock(return_value={"status": "created", "model_id": "new-model"}), - "expand_fn": AsyncMock(return_value={ - "results": [{"memory_id": "123", "memory": {"id": "123", "text": "Memory text"}}], - "count": 1 - }), - } + @pytest.mark.asyncio + async def test_handles_functions_prefix_in_done(self, mock_llm, mock_functions): + """Test that 'functions.done' is handled correctly.""" + # First call: LLM calls recall + # Second call: LLM calls functions.done + mock_llm.call_with_tools.side_effect = [ + LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[ + LLMToolCall( + id="2", + name="functions.done", + arguments={"answer": "Test answer", "memory_ids": ["mem-1"]}, + ) + ], + finish_reason="tool_calls", + ), + ] - def _make_tool_result(self, tool_calls: list[dict]) -> LLMToolCallResult: - """Helper to create LLMToolCallResult from tool call dicts.""" - return LLMToolCallResult( - tool_calls=[ - LLMToolCall(id=f"call_{i}", name=tc["name"], arguments=tc.get("arguments", {})) - for i, tc in enumerate(tool_calls) - ], + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, + **mock_functions, + ) + + assert result.text == "Test answer" + assert "mem-1" in result.used_memory_ids + + @pytest.mark.asyncio + async def test_handles_call_equals_functions_prefix(self, mock_llm, mock_functions): + """Test that 'call=functions.done' is handled correctly.""" + mock_llm.call_with_tools.side_effect = [ + LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[ + LLMToolCall( + id="2", + name="call=functions.done", + arguments={"answer": "Test answer", "memory_ids": ["mem-1"]}, + ) + ], + finish_reason="tool_calls", + ), + ] + + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, + **mock_functions, + ) + + assert result.text == "Test answer" + + @pytest.mark.asyncio + async def test_recovery_from_unknown_tool(self, mock_llm, mock_functions): + """Test that LLM can recover after calling an unknown tool.""" + # First call: LLM calls unknown tool + # Second call: LLM calls valid recall after seeing error + # Third call: LLM calls done + mock_llm.call_with_tools.side_effect = [ + LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="invalid_tool", arguments={"foo": "bar"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[ + LLMToolCall( + id="3", + name="done", + arguments={"answer": "Recovered successfully", "memory_ids": ["mem-1"]}, + ) + ], + finish_reason="tool_calls", + ), + ] + + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, + **mock_functions, + ) + + assert result.text == "Recovered successfully" + # Verify the LLM was called 3 times (initial + recovery + done) + assert mock_llm.call_with_tools.call_count == 3 + + @pytest.mark.asyncio + async def test_recovery_from_tool_execution_error(self, mock_llm, mock_functions): + """Test that LLM can recover after a tool execution fails.""" + # Make recall fail the first time, succeed the second time + mock_functions["recall_fn"].side_effect = [ + Exception("Database connection failed"), + {"memories": [{"id": "mem-1", "content": "test memory"}]}, + ] + + mock_llm.call_with_tools.side_effect = [ + LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})], + finish_reason="tool_calls", + ), + # LLM tries again after seeing error + LLMToolCallResult( + tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test retry"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[ + LLMToolCall( + id="3", + name="done", + arguments={"answer": "Recovered from error", "memory_ids": ["mem-1"]}, + ) + ], + finish_reason="tool_calls", + ), + ] + + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, + **mock_functions, + ) + + assert result.text == "Recovered from error" + assert mock_llm.call_with_tools.call_count == 3 + + @pytest.mark.asyncio + async def test_normalizes_tool_names_in_other_tools(self, mock_llm, mock_functions): + """Test that tool names are normalized for all tools, not just done.""" + mock_llm.call_with_tools.side_effect = [ + # LLM calls 'functions.recall' instead of 'recall' + LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="functions.recall", arguments={"query": "test"})], + finish_reason="tool_calls", + ), + LLMToolCallResult( + tool_calls=[ + LLMToolCall( + id="2", + name="done", + arguments={"answer": "Test answer", "memory_ids": ["mem-1"]}, + ) + ], + finish_reason="tool_calls", + ), + ] + + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, + **mock_functions, + ) + + assert result.text == "Test answer" + # Verify recall was actually called (normalization worked) + mock_functions["recall_fn"].assert_called_once() + + @pytest.mark.asyncio + async def test_max_iterations_reached(self, mock_llm, mock_functions): + """Test that agent stops after max iterations even with errors.""" + # LLM keeps calling unknown tools + mock_llm.call_with_tools.return_value = LLMToolCallResult( + tool_calls=[LLMToolCall(id="1", name="unknown_tool", arguments={})], finish_reason="tool_calls", ) - async def test_agent_done_immediately_rejected_by_guardrail(self, mock_llm, bank_profile, mock_tools): - """Test that guardrail rejects done without evidence gathering.""" - # First call: agent tries to return done immediately (rejected by guardrail) - # Second call: agent gathers evidence - # Third call: agent returns done with evidence - mock_llm.call_with_tools.side_effect = [ - self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]), - # After guardrail rejection, agent should gather evidence - self._make_tool_result([{"name": "recall", "arguments": {"query": "test query"}}]), - # Now with evidence, done is accepted - self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]), - ] - result = await run_reflect_agent( llm_config=mock_llm, bank_id="test-bank", - query="What is the answer?", - bank_profile=bank_profile, - **mock_tools, - ) - - assert isinstance(result, ReflectAgentResult) - assert result.text == "The answer is 42." - # 3 iterations: rejected done, recall, accepted done - assert result.iterations == 3 - # Tools called: list_mental_models (auto at start of each iteration) + recall - # The exact count may vary based on implementation - assert result.tools_called >= 1 # At least recall was called - - async def test_agent_calls_tools_then_done(self, mock_llm, bank_profile, mock_tools): - """Test agent that calls tools before completing.""" - # First call: lookup and recall - # Second call: done - mock_llm.call_with_tools.side_effect = [ - self._make_tool_result([ - {"name": "list_mental_models", "arguments": {}}, - {"name": "recall", "arguments": {"query": "test query"}}, - ]), - self._make_tool_result([ - {"name": "done", "arguments": {"answer": "Based on my research, the answer is yes."}}, - ]), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="Is testing important?", - bank_profile=bank_profile, - **mock_tools, - ) - - assert result.text == "Based on my research, the answer is yes." - assert result.iterations == 2 - # Tools called: list_mental_models + recall (+ possibly auto list_mental_models) - assert result.tools_called >= 2 - mock_tools["recall_fn"].assert_called_once_with("test query", 2048) - - async def test_agent_learns_model(self, mock_llm, bank_profile, mock_tools): - """Test agent that creates a mental model placeholder.""" - mock_tools["learn_fn"].return_value = {"status": "created", "model_id": "new-insight", "pending_generation": True} - - mock_llm.call_with_tools.side_effect = [ - # First: gather evidence via recall (required by guardrail) - self._make_tool_result([{"name": "recall", "arguments": {"query": "user preferences"}}]), - # Then: learn from the gathered evidence - self._make_tool_result([{ - "name": "learn", - "arguments": { - "name": "New Insight", - "description": "Track patterns about user preferences and communication style", - } - }]), - # Finally: done with the learning - self._make_tool_result([{"name": "done", "arguments": {"answer": "I've learned something new."}}]), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="What can you learn?", - bank_profile=bank_profile, - **mock_tools, - ) - - assert result.mental_models_created == ["new-insight"] - mock_tools["learn_fn"].assert_called_once() - # Verify the learn_fn was called with name and description only - call_args = mock_tools["learn_fn"].call_args - mental_model_arg = call_args[0][0] - assert mental_model_arg.name == "New Insight" - assert "preferences" in mental_model_arg.description - - async def test_agent_max_iterations_forces_response(self, mock_llm, bank_profile, mock_tools): - """Test that max iterations forces a text response.""" - # Return tools indefinitely, then final plain text call - mock_llm.call_with_tools.side_effect = [ - self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]), - self._make_tool_result([{"name": "recall", "arguments": {"query": "query2"}}]), - ] - # On last iteration, LLM.call is used (not call_with_tools) - mock_llm.call.return_value = "Forced final answer after max iterations." - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="Test question", - bank_profile=bank_profile, + query="test query", + bank_profile={"name": "Test", "mission": "Testing"}, max_iterations=3, - **mock_tools, + **mock_functions, ) - assert result.text == "Forced final answer after max iterations." + # Should have a result even if no memories found + assert result is not None assert result.iterations == 3 - - async def test_agent_handles_tool_error(self, mock_llm, bank_profile, mock_tools): - """Test agent propagates tool execution errors.""" - # Make recall fail - mock_tools["recall_fn"].side_effect = Exception("Database error") - - mock_llm.call_with_tools.side_effect = [ - self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]), - ] - - # Tool errors are now propagated as RuntimeError - with pytest.raises(RuntimeError) as exc_info: - await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="Test question", - bank_profile=bank_profile, - **mock_tools, - ) - - assert "Database error" in str(exc_info.value) - - async def test_agent_parallel_tool_calls(self, mock_llm, bank_profile, mock_tools): - """Test agent executes multiple tools in parallel.""" - mock_llm.call_with_tools.side_effect = [ - self._make_tool_result([ - {"name": "list_mental_models", "arguments": {}}, - {"name": "recall", "arguments": {"query": "query1"}}, - {"name": "recall", "arguments": {"query": "query2"}}, - ]), - self._make_tool_result([{"name": "done", "arguments": {"answer": "Done after parallel calls."}}]), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="Test question", - bank_profile=bank_profile, - **mock_tools, - ) - - # Tools called: list_mental_models + 2x recall (+ possibly auto list_mental_models) - assert result.tools_called >= 3 - # recall should be called twice - assert mock_tools["recall_fn"].call_count == 2 - - async def test_agent_returns_validated_memory_ids(self, mock_llm, bank_profile): - """Test agent returns only validated memory IDs that were actually recalled.""" - memory_id_1 = str(uuid.uuid4()) - memory_id_2 = str(uuid.uuid4()) - - # Mock recall returns these specific memory IDs - mock_recall = AsyncMock( - return_value={ - "query": "test", - "count": 2, - "memories": [ - {"id": memory_id_1, "text": "Memory 1", "type": "experience"}, - {"id": memory_id_2, "text": "Memory 2", "type": "world"}, - ], - } - ) - - mock_llm.call_with_tools.side_effect = [ - LLMToolCallResult( - tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})], - finish_reason="tool_calls", - ), - LLMToolCallResult( - tool_calls=[LLMToolCall( - id="call_1", - name="done", - arguments={"answer": "Based on the evidence...", "memory_ids": [memory_id_1, memory_id_2]} - )], - finish_reason="tool_calls", - ), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="What do we know?", - bank_profile=bank_profile, - lookup_fn=AsyncMock(return_value={"count": 0, "models": []}), - recall_fn=mock_recall, - expand_fn=AsyncMock(return_value={}), - ) - - # Both memory IDs should be in the result (they were recalled) - assert memory_id_1 in result.used_memory_ids - assert memory_id_2 in result.used_memory_ids - assert len(result.used_memory_ids) == 2 - - async def test_agent_filters_hallucinated_memory_ids(self, mock_llm, bank_profile): - """Test agent filters out memory IDs that were not in recall results.""" - valid_memory_id = str(uuid.uuid4()) - hallucinated_memory_id = str(uuid.uuid4()) - - # Mock recall returns only one memory ID - mock_recall = AsyncMock( - return_value={ - "query": "test", - "count": 1, - "memories": [ - {"id": valid_memory_id, "text": "Real memory", "type": "experience"}, - ], - } - ) - - mock_llm.call_with_tools.side_effect = [ - LLMToolCallResult( - tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})], - finish_reason="tool_calls", - ), - LLMToolCallResult( - tool_calls=[LLMToolCall( - id="call_1", - name="done", - arguments={"answer": "Based on evidence...", "memory_ids": [valid_memory_id, hallucinated_memory_id]} - )], - finish_reason="tool_calls", - ), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="What do we know?", - bank_profile=bank_profile, - lookup_fn=AsyncMock(return_value={"count": 0, "models": []}), - recall_fn=mock_recall, - expand_fn=AsyncMock(return_value={}), - ) - - # Only the valid memory ID should be in the result - assert valid_memory_id in result.used_memory_ids - assert hallucinated_memory_id not in result.used_memory_ids - assert len(result.used_memory_ids) == 1 - - async def test_agent_returns_validated_model_ids(self, mock_llm, bank_profile): - """Test agent returns only validated model IDs that were actually looked up.""" - model_id = "team-structure" - hallucinated_model_id = "non-existent-model" - - # Mock lookup returns different results based on input - # - None (or no arg): list_mental_models - returns list of models - # - model_id: get_mental_model - returns specific model with found=True - async def mock_lookup_impl(arg=None): - if arg is None: - return {"count": 1, "models": [{"id": model_id, "name": "Team Structure", "description": "desc"}]} - else: - return {"found": True, "model": {"id": model_id, "name": "Team Structure", "summary": "Full summary"}} - - mock_lookup = AsyncMock(side_effect=mock_lookup_impl) - - mock_llm.call_with_tools.side_effect = [ - LLMToolCallResult( - tool_calls=[ - LLMToolCall(id="call_0", name="list_mental_models", arguments={}), - LLMToolCall(id="call_1", name="get_mental_model", arguments={"model_id": model_id}), - ], - finish_reason="tool_calls", - ), - LLMToolCallResult( - tool_calls=[LLMToolCall( - id="call_2", - name="done", - arguments={"answer": "Based on team structure...", "model_ids": [model_id, hallucinated_model_id]} - )], - finish_reason="tool_calls", - ), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="How is the team organized?", - bank_profile=bank_profile, - lookup_fn=mock_lookup, - recall_fn=AsyncMock(return_value={"query": "test", "count": 0, "memories": []}), - expand_fn=AsyncMock(return_value={}), - ) - - # Only the valid model ID should be in the result - assert model_id in result.used_model_ids - assert hallucinated_model_id not in result.used_model_ids - - async def test_agent_plain_text_answer(self, mock_llm, bank_profile, mock_tools): - """Test agent with plain text answer format.""" - mock_llm.call_with_tools.side_effect = [ - # First: gather evidence via recall (required by guardrail) - LLMToolCallResult( - tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "answer"})], - finish_reason="tool_calls", - ), - # Then: done with plain text answer - LLMToolCallResult( - tool_calls=[LLMToolCall( - id="call_1", - name="done", - arguments={"answer": "The answer is simple and direct."} - )], - finish_reason="tool_calls", - ), - ] - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="What's the answer?", - bank_profile=bank_profile, - **mock_tools, - ) - - assert result.text == "The answer is simple and direct." - - async def test_agent_includes_directives_in_system_prompt(self, mock_llm, bank_profile, mock_tools): - """Test that directives are included in the system prompt.""" - from hindsight_api.engine.reflect.observations import Observation - - # Create directive with Observation objects (new format) - directives = [ - { - "id": "response-rules", - "name": "Response Rules", - "description": "Rules for responses", - "subtype": "directive", - "observations": [ - Observation( - title="No Speculation", - content="Never speculate about information not in the memories.", - evidence=[], - ), - Observation( - title="Be Concise", - content="Always keep responses under 100 words.", - evidence=[], - ), - ], - }, - ] - - # Capture the system prompt - captured_messages = [] - - async def capture_call(*args, **kwargs): - if "messages" in kwargs: - captured_messages.extend(kwargs["messages"]) - return self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]) - - mock_llm.call_with_tools.side_effect = [ - # First: gather evidence (guardrail requirement) - self._make_tool_result([{"name": "recall", "arguments": {"query": "test"}}]), - # Then: done - self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]), - ] - - # Store original to check messages - original_call = mock_llm.call_with_tools - - async def wrapped_call(*args, **kwargs): - if "messages" in kwargs: - captured_messages.extend(kwargs["messages"]) - return await original_call(*args, **kwargs) - - mock_llm.call_with_tools = wrapped_call - - result = await run_reflect_agent( - llm_config=mock_llm, - bank_id="test-bank", - query="What do we know?", - bank_profile=bank_profile, - directives=directives, - **mock_tools, - ) - - # Find the system message - system_messages = [m for m in captured_messages if m.get("role") == "system"] - assert len(system_messages) > 0, "No system message found" - - system_content = system_messages[0]["content"] - - # Verify directives are in the system prompt - assert "DIRECTIVES" in system_content, "Directives section not found in system prompt" - assert "No Speculation" in system_content, "Directive title not found" - assert "Never speculate" in system_content, "Directive content not found" - assert "Be Concise" in system_content, "Second directive title not found" - assert "100 words" in system_content, "Second directive content not found" - assert "NEVER violate these directives" in system_content, "Directive warning not found" - - -class TestDirectivesSection: - """Test the directives section builder.""" - - def test_build_directives_section_with_observation_objects(self): - """Test building directives section with Observation objects.""" - from hindsight_api.engine.reflect.observations import Observation - from hindsight_api.engine.reflect.prompts import build_directives_section - - directives = [ - { - "name": "Safety Rules", - "observations": [ - Observation( - title="No Harmful Content", - content="Never generate harmful or dangerous content.", - evidence=[], - ), - ], - }, - ] - - result = build_directives_section(directives) - - assert "DIRECTIVES" in result - assert "No Harmful Content" in result - assert "Never generate harmful" in result - assert "NEVER violate" in result - - def test_build_directives_section_with_dicts(self): - """Test building directives section with dict observations.""" - from hindsight_api.engine.reflect.prompts import build_directives_section - - directives = [ - { - "name": "Safety Rules", - "observations": [ - { - "title": "No Harmful Content", - "content": "Never generate harmful or dangerous content.", - }, - ], - }, - ] - - result = build_directives_section(directives) - - assert "DIRECTIVES" in result - assert "No Harmful Content" in result - assert "Never generate harmful" in result - - def test_build_directives_section_fallback_to_description(self): - """Test that directives without observations use description.""" - from hindsight_api.engine.reflect.prompts import build_directives_section - - directives = [ - { - "name": "Simple Rule", - "description": "This is a simple rule to follow.", - "observations": [], - }, - ] - - result = build_directives_section(directives) - - assert "Simple Rule" in result - assert "simple rule to follow" in result - - def test_build_directives_section_empty(self): - """Test that empty directives returns empty string.""" - from hindsight_api.engine.reflect.prompts import build_directives_section - - result = build_directives_section([]) - assert result == "" - - result = build_directives_section(None) - assert result == "" - - -@pytest.mark.integration -class TestReflectIntegration: - """Integration tests for reflect with real database. - - These tests require a running database and LLM provider. - Skip with: pytest -m "not integration" - """ - - async def test_reflect_creates_learned_mental_model(self, memory, request_context): - """Test that reflect can create a 'learned' mental model via the agent.""" - bank_id = f"test-reflect-{uuid.uuid4().hex[:8]}" - - # Add some test data - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Alice is the team lead and manages the engineering team."}, - {"content": "The team has weekly planning meetings on Monday."}, - {"content": "Alice prefers asynchronous communication via Slack."}, - ], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Run reflect - this should use the agentic loop - result = await memory.reflect_async( - bank_id=bank_id, - query="What do you know about Alice and how she manages the team?", - request_context=request_context, - ) - - assert result.text is not None - assert len(result.text) > 0 - - # Check if any mental models were created (may or may not happen depending on LLM) - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - # If models were created, they should be 'learned' subtype - for model in models: - if model.get("subtype") == "learned": - # Learned models are created as placeholders pending generation - assert model.get("name") is not None - assert model.get("description") is not None - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_reflect_learn_triggers_background_generation(self, memory, request_context): - """Test that when reflect calls learn, background generation is triggered. - - This test verifies the full flow: - 1. Agent decides to learn something important - 2. learn tool creates a placeholder model - 3. Background generation is automatically triggered - """ - import asyncio - - bank_id = f"test-reflect-learn-{uuid.uuid4().hex[:8]}" - - # Add rich test data that should prompt the agent to learn something - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Bob is the CEO and founder of the company."}, - {"content": "Bob started the company in 2015 after leaving Google."}, - {"content": "Bob holds weekly all-hands meetings every Friday at 3pm."}, - {"content": "Bob's management style is very hands-off and trusts his team."}, - {"content": "Bob prefers face-to-face communication over email."}, - {"content": "Bob has a strong focus on company culture and team building."}, - ], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Run reflect with a query that should prompt learning - result = await memory.reflect_async( - bank_id=bank_id, - query="Tell me everything about Bob's leadership style and how he runs the company. " - "This is important information I'll need to reference frequently.", - request_context=request_context, - ) - - assert result.text is not None - assert len(result.text) > 0 - - # Wait for any background tasks to complete - await memory.wait_for_background_tasks() - # Give a bit more time for async generation - await asyncio.sleep(2) - - # Check if learned models were created - models = await memory.list_mental_models( - bank_id=bank_id, - request_context=request_context, - ) - - learned_models = [m for m in models if m.get("subtype") == "learned"] - - # If learned models were created, verify they have proper structure - for model in learned_models: - assert model.get("name") is not None - assert model.get("description") is not None - # After background generation, the model should have been updated - # (observations may or may not be populated depending on timing) - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - async def test_reflect_excludes_opinions_from_recall(self, memory, request_context): - """Test that reflect's recall tool doesn't return opinions.""" - bank_id = f"test-reflect-no-opinions-{uuid.uuid4().hex[:8]}" - - # Add test data (note: we can't directly add opinions since opinion - # extraction was removed, but we can verify recall behavior) - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "The weather today is sunny and warm."}, - ], - request_context=request_context, - ) - await memory.wait_for_background_tasks() - - # Run recall directly to verify it excludes opinions - recall_result = await memory.recall_async( - bank_id=bank_id, - query="weather", - request_context=request_context, - ) - - # All returned facts should be experience or world, not opinion - for fact in recall_result.results: - assert fact.fact_type in ["experience", "world"] - assert fact.fact_type != "opinion" - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_reflections.py b/hindsight-api/tests/test_reflections.py new file mode 100644 index 00000000..25437e06 --- /dev/null +++ b/hindsight-api/tests/test_reflections.py @@ -0,0 +1,359 @@ +"""Tests for reflections, mental models, and learnings functionality.""" + +import uuid + +import pytest +import pytest_asyncio +import httpx +from hindsight_api.api import create_app +from hindsight_api.engine.memory_engine import MemoryEngine + + +@pytest_asyncio.fixture +async def api_client(memory): + """Create an async test client for the FastAPI app.""" + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture +def test_bank_id(): + """Provide a unique bank ID for this test run.""" + return f"test_reflections_{uuid.uuid4().hex[:8]}" + + +class TestReflectionsCRUD: + """Test reflections CRUD operations via memory engine.""" + + @pytest.mark.asyncio + async def test_create_and_get_reflection(self, memory: MemoryEngine, request_context): + """Test creating and retrieving a reflection.""" + bank_id = f"test-reflection-{uuid.uuid4().hex[:8]}" + + # Create the bank first + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create a reflection + reflection = await memory.create_reflection( + bank_id=bank_id, + name="Team Preferences", + source_query="What are the team's communication preferences?", + content="The team prefers async communication via Slack", + tags=["team"], + request_context=request_context, + ) + + assert reflection["name"] == "Team Preferences" + assert reflection["source_query"] == "What are the team's communication preferences?" + assert reflection["content"] == "The team prefers async communication via Slack" + assert reflection["tags"] == ["team"] + assert "id" in reflection + + # Get the reflection + fetched = await memory.get_reflection( + bank_id=bank_id, + reflection_id=reflection["id"], + request_context=request_context, + ) + + assert fetched["id"] == reflection["id"] + assert fetched["name"] == "Team Preferences" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_list_reflections(self, memory: MemoryEngine, request_context): + """Test listing reflections with filters.""" + bank_id = f"test-reflection-list-{uuid.uuid4().hex[:8]}" + + # Create the bank first + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create multiple reflections + await memory.create_reflection( + bank_id=bank_id, + name="Reflection 1", + source_query="Query 1", + content="Content 1", + tags=["tag1"], + request_context=request_context, + ) + await memory.create_reflection( + bank_id=bank_id, + name="Reflection 2", + source_query="Query 2", + content="Content 2", + tags=["tag2"], + request_context=request_context, + ) + + # List all + all_reflections = await memory.list_reflections( + bank_id=bank_id, + request_context=request_context, + ) + assert len(all_reflections) == 2 + + # List with tag filter + tag1_reflections = await memory.list_reflections( + bank_id=bank_id, + tags=["tag1"], + request_context=request_context, + ) + assert len(tag1_reflections) == 1 + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_reflection(self, memory: MemoryEngine, request_context): + """Test updating a reflection.""" + bank_id = f"test-reflection-update-{uuid.uuid4().hex[:8]}" + + # Create the bank first + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create a reflection + reflection = await memory.create_reflection( + bank_id=bank_id, + name="Original Name", + source_query="Original Query", + content="Original Content", + request_context=request_context, + ) + + # Update the reflection + updated = await memory.update_reflection( + bank_id=bank_id, + reflection_id=reflection["id"], + name="Updated Name", + content="Updated Content", + request_context=request_context, + ) + + assert updated["name"] == "Updated Name" + assert updated["content"] == "Updated Content" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_delete_reflection(self, memory: MemoryEngine, request_context): + """Test deleting a reflection.""" + bank_id = f"test-reflection-delete-{uuid.uuid4().hex[:8]}" + + # Create the bank first + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create a reflection + reflection = await memory.create_reflection( + bank_id=bank_id, + name="To Delete", + source_query="Query", + content="Content", + request_context=request_context, + ) + + # Delete the reflection + await memory.delete_reflection( + bank_id=bank_id, + reflection_id=reflection["id"], + request_context=request_context, + ) + + # Verify deletion - should return None + fetched = await memory.get_reflection( + bank_id=bank_id, + reflection_id=reflection["id"], + request_context=request_context, + ) + assert fetched is None + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestMentalModelsAPI: + """Test mental models API endpoints. + + NOTE: Mental models are now stored in memory_units with fact_type='mental_model' + and accessed via recall with fact_type=["mental_model"]. The old /mental-models + endpoint was removed. These tests are skipped. + """ + + @pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']") + @pytest.mark.asyncio + async def test_list_mental_models_empty(self, api_client, test_bank_id): + """Test listing mental models when none exist.""" + pass + + @pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']") + @pytest.mark.asyncio + async def test_get_mental_model_not_found(self, api_client, test_bank_id): + """Test getting a non-existent mental model.""" + pass + + +class TestReflectionsAPI: + """Test reflections API endpoints.""" + + @pytest.mark.asyncio + async def test_reflections_api_crud(self, api_client, test_bank_id): + """Test full CRUD cycle through API.""" + import asyncio + + # Create bank first via profile endpoint + await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") + + # Create a reflection (async operation) + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/reflections", + json={ + "name": "API Test Reflection", + "source_query": "What is the API test about?", + "content": "This is an API test reflection", + "tags": ["api-test"], + }, + ) + assert response.status_code == 200 + create_result = response.json() + assert "operation_id" in create_result + operation_id = create_result["operation_id"] + + # Wait for the async operation to complete + for _ in range(30): # Wait up to 30 seconds + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}") + if response.status_code == 200: + op_status = response.json() + if op_status.get("status") == "completed": + break + await asyncio.sleep(1) + + # List reflections to get the created reflection + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections") + assert response.status_code == 200 + reflections = response.json()["items"] + assert len(reflections) >= 1 + + # Find our reflection + reflection = next((r for r in reflections if r["name"] == "API Test Reflection"), None) + assert reflection is not None, f"Reflection not found. Items: {reflections}" + reflection_id = reflection["id"] + + # Get the reflection + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}") + assert response.status_code == 200 + assert response.json()["name"] == "API Test Reflection" + + # Update the reflection + response = await api_client.patch( + f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}", + json={"name": "Updated API Test Reflection"}, + ) + assert response.status_code == 200 + assert response.json()["name"] == "Updated API Test Reflection" + + # Delete the reflection + response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}") + assert response.status_code == 200 + + # Verify deletion + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}") + assert response.status_code == 404 + + # Cleanup + await api_client.delete(f"/v1/default/banks/{test_bank_id}") + + +class TestRecallWithMentalModelsAndReflections: + """Test recall integration with mental models and reflections.""" + + @pytest.mark.asyncio + async def test_recall_includes_mental_models(self, api_client, test_bank_id): + """Test that recall can include mental models in the response.""" + # Create bank first via profile endpoint + await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") + + # Note: Mental models are auto-created via consolidation, not manually + # This test just verifies the include parameter works + + # Recall with mental models included + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories/recall", + json={ + "query": "What is machine learning?", + "include": { + "mental_models": {"max_results": 5}, + }, + }, + ) + assert response.status_code == 200 + result = response.json() + + # Should have mental_models field in response (may be empty) + assert "mental_models" in result or result.get("mental_models") is None + + # Cleanup + await api_client.delete(f"/v1/default/banks/{test_bank_id}") + + @pytest.mark.asyncio + async def test_recall_includes_reflections(self, api_client, test_bank_id): + """Test that recall can include reflections in the response.""" + # Create bank first via profile endpoint + await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") + + # Create a reflection first + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/reflections", + json={ + "name": "AI Overview", + "source_query": "What is AI?", + "content": "Artificial intelligence is the simulation of human intelligence", + "tags": [], + }, + ) + assert response.status_code == 200 + + # Recall with reflections included + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories/recall", + json={ + "query": "What is artificial intelligence?", + "include": { + "reflections": {"max_results": 5}, + }, + }, + ) + assert response.status_code == 200 + result = response.json() + + # Should have reflections in response (may be empty if embedding not generated yet) + assert "reflections" in result or result.get("reflections") is None + + # Cleanup + await api_client.delete(f"/v1/default/banks/{test_bank_id}") + + @pytest.mark.asyncio + async def test_recall_without_mental_models_by_default(self, api_client, test_bank_id): + """Test that recall does not include mental models by default.""" + # Create bank first via profile endpoint + await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") + + # Recall without specifying mental models + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories/recall", + json={ + "query": "Test query", + }, + ) + assert response.status_code == 200 + result = response.json() + + # Mental models should not be in response + assert result.get("mental_models") is None + + # Cleanup + await api_client.delete(f"/v1/default/banks/{test_bank_id}") diff --git a/hindsight-api/tests/test_server_module.py b/hindsight-api/tests/test_server_module.py index 0d2d95a2..bc1d29bf 100644 --- a/hindsight-api/tests/test_server_module.py +++ b/hindsight-api/tests/test_server_module.py @@ -257,7 +257,6 @@ from hindsight_api.extensions import ( RetainContext, RecallContext, ReflectContext, - RefreshMentalModelContext, ) @@ -289,6 +288,3 @@ class MockOperationValidator(OperationValidatorExtension): async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: return ValidationResult.accept() - - async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult: - return ValidationResult.accept() diff --git a/hindsight-api/tests/test_sql_schema_safety.py b/hindsight-api/tests/test_sql_schema_safety.py index b7ddd436..70f78681 100644 --- a/hindsight-api/tests/test_sql_schema_safety.py +++ b/hindsight-api/tests/test_sql_schema_safety.py @@ -21,6 +21,8 @@ TABLES = [ "documents", "chunks", "async_operations", + "directives", + "reflections", ] # Files to scan for SQL queries diff --git a/hindsight-api/tests/test_worker.py b/hindsight-api/tests/test_worker.py index f9748678..7302c358 100644 --- a/hindsight-api/tests/test_worker.py +++ b/hindsight-api/tests/test_worker.py @@ -329,6 +329,241 @@ class TestWorkerPoller: assert row["status"] == "failed" assert "Max retries" in row["error_message"] + @pytest.mark.asyncio + async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, clean_operations): + """Test that pending consolidation is skipped if same bank has one processing.""" + from hindsight_api.worker import WorkerPoller + + bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" + + # Create a processing consolidation for bank + processing_op_id = uuid.uuid4() + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker') + """, + processing_op_id, + bank_id, + json.dumps({"type": "consolidation", "bank_id": bank_id}), + ) + + # Create a pending consolidation for same bank (should be skipped) + pending_op_id = uuid.uuid4() + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb) + """, + pending_op_id, + bank_id, + json.dumps({"type": "consolidation", "bank_id": bank_id}), + ) + + # Create a pending consolidation for different bank (should be claimed) + other_bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" + other_op_id = uuid.uuid4() + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb) + """, + other_op_id, + other_bank_id, + json.dumps({"type": "consolidation", "bank_id": other_bank_id}), + ) + + poller = WorkerPoller( + pool=pool, + worker_id="test-worker-1", + executor=lambda x: None, + batch_size=10, + ) + + claimed = await poller.claim_batch() + + # Should only claim the consolidation for the other bank + assert len(claimed) == 1 + claimed_op_id, claimed_payload = claimed[0] + assert claimed_op_id == str(other_op_id) + assert claimed_payload["bank_id"] == other_bank_id + + # Verify the pending consolidation for first bank is still pending + row = await pool.fetchrow( + "SELECT status, worker_id FROM async_operations WHERE operation_id = $1", + pending_op_id, + ) + assert row["status"] == "pending" + assert row["worker_id"] is None + + @pytest.mark.asyncio + async def test_claim_batch_allows_non_consolidation_when_consolidation_processing(self, pool, clean_operations): + """Test that non-consolidation tasks are still claimed even if consolidation is processing.""" + from hindsight_api.worker import WorkerPoller + + bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" + + # Create a processing consolidation for bank + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker') + """, + uuid.uuid4(), + bank_id, + json.dumps({"type": "consolidation", "bank_id": bank_id}), + ) + + # Create a pending retain task for same bank (should be claimed) + retain_op_id = uuid.uuid4() + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'retain', 'pending', $3::jsonb) + """, + retain_op_id, + bank_id, + json.dumps({"type": "batch_retain", "bank_id": bank_id}), + ) + + poller = WorkerPoller( + pool=pool, + worker_id="test-worker-1", + executor=lambda x: None, + batch_size=10, + ) + + claimed = await poller.claim_batch() + + # Should claim the retain task (non-consolidation tasks are unaffected) + assert len(claimed) == 1 + claimed_op_id, _ = claimed[0] + assert claimed_op_id == str(retain_op_id) + + +class TestWorkerRecovery: + """Tests for worker task recovery on startup.""" + + @pytest.mark.asyncio + async def test_recover_own_tasks_resets_processing_to_pending(self, pool, clean_operations): + """Test that recover_own_tasks resets processing tasks back to pending.""" + from hindsight_api.worker import WorkerPoller + + # Create tasks that were being processed by this worker (simulating a crash) + bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" + worker_id = "crashed-worker" + task_ids = [] + + for i in range(3): + op_id = uuid.uuid4() + task_ids.append(op_id) + payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id}) + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at) + VALUES ($1, $2, 'test', 'processing', $3::jsonb, $4, now()) + """, + op_id, + bank_id, + payload, + worker_id, + ) + + # Create poller with same worker_id and call recover + poller = WorkerPoller( + pool=pool, + worker_id=worker_id, + executor=lambda x: None, + ) + + recovered_count = await poller.recover_own_tasks() + assert recovered_count == 3 + + # Verify all tasks are back to pending with no worker assigned + rows = await pool.fetch( + "SELECT status, worker_id, claimed_at FROM async_operations WHERE bank_id = $1", + bank_id, + ) + for row in rows: + assert row["status"] == "pending" + assert row["worker_id"] is None + assert row["claimed_at"] is None + + @pytest.mark.asyncio + async def test_recover_own_tasks_does_not_affect_other_workers(self, pool, clean_operations): + """Test that recover_own_tasks only affects tasks from the same worker_id.""" + from hindsight_api.worker import WorkerPoller + + bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" + + # Create tasks for worker-1 (the one that will recover) + for i in range(2): + op_id = uuid.uuid4() + payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id}) + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-1') + """, + op_id, + bank_id, + payload, + ) + + # Create tasks for worker-2 (should not be affected) + for i in range(2): + op_id = uuid.uuid4() + payload = json.dumps({"type": "test_task", "index": i + 10, "bank_id": bank_id}) + await pool.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-2') + """, + op_id, + bank_id, + payload, + ) + + # Worker-1 recovers its tasks + poller = WorkerPoller( + pool=pool, + worker_id="worker-1", + executor=lambda x: None, + ) + + recovered_count = await poller.recover_own_tasks() + assert recovered_count == 2 + + # Verify worker-1 tasks are released + worker1_rows = await pool.fetch( + "SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id IS NULL", + bank_id, + ) + assert len(worker1_rows) == 2 + + # Verify worker-2 tasks are unaffected + worker2_rows = await pool.fetch( + "SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id = 'worker-2'", + bank_id, + ) + assert len(worker2_rows) == 2 + for row in worker2_rows: + assert row["status"] == "processing" + + @pytest.mark.asyncio + async def test_recover_own_tasks_returns_zero_when_no_stale_tasks(self, pool, clean_operations): + """Test that recover_own_tasks returns 0 when there are no stale tasks.""" + from hindsight_api.worker import WorkerPoller + + poller = WorkerPoller( + pool=pool, + worker_id="fresh-worker", + executor=lambda x: None, + ) + + recovered_count = await poller.recover_own_tasks() + assert recovered_count == 0 + class TestConcurrentWorkers: """Tests for concurrent worker task claiming (FOR UPDATE SKIP LOCKED).""" diff --git a/hindsight-cli/smoke-test.sh b/hindsight-cli/smoke-test.sh index 8b4fd171..55efca1b 100755 --- a/hindsight-cli/smoke-test.sh +++ b/hindsight-cli/smoke-test.sh @@ -115,31 +115,10 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED= # Test 14: Clear memories run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1 -# Test 15: Health check -run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1 - -# Test 16: List memories (new command) -run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1 - -# Test 17: List tags -run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1 - -# Test 18: List mental models -run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1 - -# Test 19: Create mental model -run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1 - -# Test 20: List mental models (should have one now) -run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1 - -# Test 21: Bank graph -run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1 - -# Test 22: List operations +# Test 15: List operations run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1 -# Test 23: Delete bank +# Test 16: Delete bank run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1 echo "" diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 78d1b42f..0acbf080 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -173,7 +173,7 @@ impl ApiClient { pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> { self.runtime.block_on(async { loop { - let response = self.client.list_operations(agent_id, None).await?; + let response = self.client.list_operations(agent_id, None, None, None, None).await?; let ops = response.into_inner(); // Find our operation @@ -258,7 +258,7 @@ impl ApiClient { pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result { self.runtime.block_on(async { - let response = self.client.list_operations(agent_id, None).await?; + let response = self.client.list_operations(agent_id, None, None, None, None).await?; let value = response.into_inner(); // Convert to JSON Value first, then parse into our type let json_value = serde_json::to_value(&value)?; @@ -321,144 +321,6 @@ impl ApiClient { // ============================================================================ impl ApiClient { - // --- Mental Model Methods --- - - pub fn list_mental_models( - &self, - bank_id: &str, - subtype: Option<&str>, - tags: Option>, - tags_match: Option<&str>, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let tags_match_enum = match tags_match { - Some("all") => Some(types::TagsMatch::All), - Some("any_strict") => Some(types::TagsMatch::AnyStrict), - Some("all_strict") => Some(types::TagsMatch::AllStrict), - _ => Some(types::TagsMatch::Any), - }; - let response = self.client.list_mental_models( - bank_id, - subtype, - tags.as_ref(), - tags_match_enum, - None, - ).await?; - Ok(response.into_inner()) - }) - } - - pub fn get_mental_model( - &self, - bank_id: &str, - model_id: &str, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.get_mental_model(bank_id, model_id, None).await?; - Ok(response.into_inner()) - }) - } - - pub fn create_mental_model( - &self, - bank_id: &str, - request: &types::CreateMentalModelRequest, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.create_mental_model(bank_id, None, request).await?; - Ok(response.into_inner()) - }) - } - - pub fn delete_mental_model( - &self, - bank_id: &str, - model_id: &str, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.delete_mental_model(bank_id, model_id, None).await?; - Ok(response.into_inner()) - }) - } - - pub fn update_mental_model( - &self, - bank_id: &str, - model_id: &str, - request: &types::UpdateMentalModelRequest, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.update_mental_model(bank_id, model_id, None, request).await?; - Ok(response.into_inner()) - }) - } - - pub fn refresh_mental_models( - &self, - bank_id: &str, - subtype: Option<&str>, - tags: Option>, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let subtype_enum = match subtype { - Some("structural") => Some(types::Subtype::Structural), - Some("emergent") => Some(types::Subtype::Emergent), - Some("pinned") => Some(types::Subtype::Pinned), - Some("learned") => Some(types::Subtype::Learned), - _ => None, - }; - let request = types::RefreshMentalModelsRequest { - subtype: subtype_enum, - tags, - }; - let response = self.client.refresh_mental_models(bank_id, None, &request).await?; - Ok(response.into_inner()) - }) - } - - pub fn refresh_mental_model( - &self, - bank_id: &str, - model_id: &str, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.refresh_mental_model(bank_id, model_id, None).await?; - Ok(response.into_inner()) - }) - } - - pub fn list_mental_model_versions( - &self, - bank_id: &str, - model_id: &str, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.list_mental_model_versions(bank_id, model_id, None).await?; - Ok(response.into_inner()) - }) - } - - pub fn get_mental_model_version( - &self, - bank_id: &str, - model_id: &str, - version: i64, - _verbose: bool, - ) -> Result { - self.runtime.block_on(async { - let response = self.client.get_mental_model_version(bank_id, model_id, version, None).await?; - Ok(response.into_inner()) - }) - } - // --- Memory Methods --- pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result { @@ -574,6 +436,109 @@ impl ApiClient { Ok(response.into_inner()) }) } + + // --- Reflection Methods --- + + pub fn list_reflections(&self, bank_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.list_reflections(bank_id, None, None, None, None, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn get_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_reflection(bank_id, reflection_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn create_reflection( + &self, + bank_id: &str, + request: &types::CreateReflectionRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.create_reflection(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn update_reflection( + &self, + bank_id: &str, + reflection_id: &str, + request: &types::UpdateReflectionRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.update_reflection(bank_id, reflection_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn delete_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.delete_reflection(bank_id, reflection_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn refresh_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.refresh_reflection(bank_id, reflection_id, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Directive Methods --- + + pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_directive(bank_id, directive_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn create_directive( + &self, + bank_id: &str, + request: &types::CreateDirectiveRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.create_directive(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn update_directive( + &self, + bank_id: &str, + directive_id: &str, + request: &types::UpdateDirectiveRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.update_directive(bank_id, directive_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.delete_directive(bank_id, directive_id, None).await?; + Ok(response.into_inner()) + }) + } } // Re-export types from the generated client for use in commands diff --git a/hindsight-cli/src/commands/directive.rs b/hindsight-cli/src/commands/directive.rs new file mode 100644 index 00000000..645b17b3 --- /dev/null +++ b/hindsight-cli/src/commands/directive.rs @@ -0,0 +1,266 @@ +//! Directive commands for managing behavioral rules. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +use hindsight_client::types; + +/// List directives for a bank +pub fn list( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching directives...")) + } else { + None + }; + + let response = client.list_directives(bank_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Directives: {}", bank_id)); + + if result.items.is_empty() { + println!(" {}", ui::dim("No directives found.")); + } else { + for directive in &result.items { + let status = if directive.is_active { + ui::gradient_start("active") + } else { + ui::dim("inactive") + }; + println!( + " {} {} [{}]", + ui::gradient_start(&directive.id), + directive.name, + status + ); + + // Show content preview + let preview: String = directive.content.chars().take(80).collect(); + let ellipsis = if directive.content.len() > 80 { "..." } else { "" }; + println!(" {}{}", ui::dim(&preview), ellipsis); + + println!(); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get a specific directive +pub fn get( + client: &ApiClient, + bank_id: &str, + directive_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching directive...")) + } else { + None + }; + + let response = client.get_directive(bank_id, directive_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(directive) => { + if output_format == OutputFormat::Pretty { + print_directive_detail(&directive); + } else { + output::print_output(&directive, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Create a new directive +pub fn create( + client: &ApiClient, + bank_id: &str, + name: &str, + content: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Creating directive...")) + } else { + None + }; + + let request = types::CreateDirectiveRequest { + name: name.to_string(), + content: content.to_string(), + is_active: true, + priority: 0, + tags: vec![], + }; + + let response = client.create_directive(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(directive) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Directive '{}' created successfully", directive.id)); + println!(); + print_directive_detail(&directive); + } else { + output::print_output(&directive, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Update a directive +pub fn update( + client: &ApiClient, + bank_id: &str, + directive_id: &str, + name: Option, + content: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if name.is_none() && content.is_none() { + anyhow::bail!("At least one of --name or --content must be provided"); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating directive...")) + } else { + None + }; + + let request = types::UpdateDirectiveRequest { + name, + content, + is_active: None, + priority: None, + tags: None, + }; + + let response = client.update_directive(bank_id, directive_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(directive) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Directive '{}' updated successfully", directive_id)); + println!(); + print_directive_detail(&directive); + } else { + output::print_output(&directive, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Delete a directive +pub fn delete( + client: &ApiClient, + bank_id: &str, + directive_id: &str, + yes: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + // Confirmation prompt unless -y flag is used + if !yes && output_format == OutputFormat::Pretty { + let message = format!( + "Are you sure you want to delete directive '{}'? This cannot be undone.", + directive_id + ); + + let confirmed = ui::prompt_confirmation(&message)?; + + if !confirmed { + ui::print_info("Operation cancelled"); + return Ok(()); + } + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Deleting directive...")) + } else { + None + }; + + let response = client.delete_directive(bank_id, directive_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(_) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Directive '{}' deleted successfully", directive_id)); + } else { + println!("{{\"success\": true}}"); + } + Ok(()) + } + Err(e) => Err(e), + } +} + +// Helper function to print directive details +fn print_directive_detail(directive: &types::DirectiveResponse) { + ui::print_section_header(&directive.name); + + println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&directive.id)); + + let status = if directive.is_active { + ui::gradient_start("active") + } else { + ui::dim("inactive") + }; + println!(" {} {}", ui::dim("Status:"), status); + println!(" {} {}", ui::dim("Priority:"), directive.priority); + + if !directive.tags.is_empty() { + println!(" {} {}", ui::dim("Tags:"), directive.tags.join(", ")); + } + + println!(); + println!("{}", ui::gradient_text("─── Content ───")); + println!(); + println!("{}", &directive.content); + println!(); +} diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs deleted file mode 100644 index 384aab19..00000000 --- a/hindsight-cli/src/commands/mental_model.rs +++ /dev/null @@ -1,721 +0,0 @@ -//! Mental model commands for managing structured knowledge containers. - -use anyhow::{Context, Result}; -use std::fs; -use std::path::PathBuf; - -use crate::api::ApiClient; -use crate::output::{self, OutputFormat}; -use crate::ui; - -use hindsight_client::types; -use serde::Deserialize; - -// Local types for serde_json::Value deserialization -#[derive(Debug, Deserialize)] -struct VersionListResponse { - versions: Vec, -} - -#[derive(Debug, Deserialize)] -struct VersionItem { - version: i64, - created_at: String, - observations_count: Option, -} - -#[derive(Debug, Deserialize)] -struct VersionDetailResponse { - version: i64, - created_at: String, - observations: Option>, -} - -#[derive(Debug, Deserialize)] -struct ObservationData { - title: String, - content: String, - trend: Option, - evidence: Option>, -} - -#[derive(Debug, Deserialize)] -struct EvidenceData { - quote: String, -} - -/// List mental models for a bank -pub fn list( - client: &ApiClient, - bank_id: &str, - subtype: Option, - tags: Option>, - tags_match: Option, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching mental models...")) - } else { - None - }; - - let response = client.list_mental_models( - bank_id, - subtype.as_deref(), - tags, - tags_match.as_deref(), - verbose, - ); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_section_header(&format!("Mental Models: {}", bank_id)); - - if result.items.is_empty() { - println!(" {}", ui::dim("No mental models found.")); - } else { - for model in &result.items { - let subtype_str = &model.subtype; - let obs_count = model.observations.len(); - - println!( - " {} {} {}", - ui::gradient_start(&model.id), - ui::dim(&format!("[{}]", subtype_str)), - model.name - ); - - if !model.description.is_empty() { - println!(" {}", ui::dim(&model.description)); - } - - println!( - " {} observations, v{}", - obs_count, - model.version - ); - - // Show freshness status - if let Some(freshness) = &model.freshness { - let status = if freshness.is_up_to_date { - ui::gradient_start("up to date") - } else { - ui::gradient_end("needs refresh") - }; - println!(" {}", status); - } - - println!(); - } - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Get a specific mental model -pub fn get( - client: &ApiClient, - bank_id: &str, - model_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching mental model...")) - } else { - None - }; - - let response = client.get_mental_model(bank_id, model_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(model) => { - if output_format == OutputFormat::Pretty { - print_mental_model_detail(&model); - } else { - output::print_output(&model, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Create a new mental model -pub fn create( - client: &ApiClient, - bank_id: &str, - name: &str, - description: &str, - subtype: Option, - tags: Option>, - observations_file: Option, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Creating mental model...")) - } else { - None - }; - - // Parse observations from file if provided - let observations = if let Some(path) = observations_file { - let content = fs::read_to_string(&path) - .with_context(|| format!("Failed to read observations file: {}", path.display()))?; - let obs: Vec = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse observations JSON from: {}", path.display()))?; - Some(obs) - } else { - None - }; - - let request = types::CreateMentalModelRequest { - name: name.to_string(), - description: description.to_string(), - subtype: subtype.unwrap_or_else(|| "pinned".to_string()), - tags: tags.unwrap_or_default(), - observations, - }; - - let response = client.create_mental_model(bank_id, &request, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(model) => { - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Mental model '{}' created successfully", model.id)); - println!(); - print_mental_model_detail(&model); - } else { - output::print_output(&model, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Delete a mental model -pub fn delete( - client: &ApiClient, - bank_id: &str, - model_id: &str, - yes: bool, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - // Confirmation prompt unless -y flag is used - if !yes && output_format == OutputFormat::Pretty { - let message = format!( - "Are you sure you want to delete mental model '{}'? This cannot be undone.", - model_id - ); - - let confirmed = ui::prompt_confirmation(&message)?; - - if !confirmed { - ui::print_info("Operation cancelled"); - return Ok(()); - } - } - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Deleting mental model...")) - } else { - None - }; - - let response = client.delete_mental_model(bank_id, model_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - ui::print_success(&format!("Mental model '{}' deleted successfully", model_id)); - } else { - ui::print_error("Failed to delete mental model"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Update a mental model's name or description -pub fn update( - client: &ApiClient, - bank_id: &str, - model_id: &str, - name: Option, - description: Option, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - if name.is_none() && description.is_none() { - anyhow::bail!("At least one of --name or --description must be provided"); - } - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Updating mental model...")) - } else { - None - }; - - let request = types::UpdateMentalModelRequest { name, description }; - - let response = client.update_mental_model(bank_id, model_id, &request, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(model) => { - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Mental model '{}' updated successfully", model_id)); - println!(); - print_mental_model_detail(&model); - } else { - output::print_output(&model, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Refresh all mental models (or filtered by subtype) -pub fn refresh_all( - client: &ApiClient, - bank_id: &str, - subtype: Option, - tags: Option>, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Submitting refresh request...")) - } else { - None - }; - - let response = client.refresh_mental_models(bank_id, subtype.as_deref(), tags, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_success("Refresh operation submitted"); - println!(" Operation ID: {}", result.operation_id); - println!(" Status: {}", result.status); - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Refresh a specific mental model -pub fn refresh( - client: &ApiClient, - bank_id: &str, - model_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Submitting refresh request...")) - } else { - None - }; - - let response = client.refresh_mental_model(bank_id, model_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Refresh submitted for model '{}'", model_id)); - println!(" Operation ID: {}", result.operation_id); - println!(" Status: {}", result.status); - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// List version history for a mental model -pub fn versions( - client: &ApiClient, - bank_id: &str, - model_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching versions...")) - } else { - None - }; - - let response = client.list_mental_model_versions(bank_id, model_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(value) => { - if output_format == OutputFormat::Pretty { - let result: VersionListResponse = serde_json::from_value(value) - .with_context(|| "Failed to parse version list response")?; - - ui::print_section_header(&format!("Version History: {}", model_id)); - - if result.versions.is_empty() { - println!(" {}", ui::dim("No versions found.")); - } else { - for version in &result.versions { - let obs_count = version.observations_count.unwrap_or(0); - println!( - " {} v{} - {} observations", - ui::gradient_start(&format!("v{}", version.version)), - version.version, - obs_count - ); - println!(" {}", ui::dim(&version.created_at)); - } - } - } else { - output::print_output(&value, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -/// Get a specific version of a mental model -pub fn version( - client: &ApiClient, - bank_id: &str, - model_id: &str, - version_num: i64, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching version...")) - } else { - None - }; - - let response = client.get_mental_model_version(bank_id, model_id, version_num, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(value) => { - if output_format == OutputFormat::Pretty { - let result: VersionDetailResponse = serde_json::from_value(value) - .with_context(|| "Failed to parse version response")?; - - ui::print_section_header(&format!("{} v{}", model_id, version_num)); - - println!(" {} {}", ui::dim("Created:"), result.created_at); - println!(); - - if let Some(observations) = &result.observations { - if observations.is_empty() { - println!(" {}", ui::dim("No observations in this version.")); - } else { - for (i, obs) in observations.iter().enumerate() { - print_observation_data(i + 1, obs); - } - } - } - } else { - output::print_output(&value, output_format)?; - } - Ok(()) - } - Err(e) => Err(e), - } -} - -// Helper function to print mental model details -fn print_mental_model_detail(model: &types::MentalModelResponse) { - ui::print_section_header(&model.name); - - let subtype_str = &model.subtype; - println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&model.id)); - println!(" {} {}", ui::dim("Subtype:"), subtype_str); - println!(" {} v{}", ui::dim("Version:"), model.version); - - if !model.description.is_empty() { - println!(" {} {}", ui::dim("Description:"), &model.description); - } - - if !model.tags.is_empty() { - println!(" {} {}", ui::dim("Tags:"), model.tags.join(", ")); - } - - // Freshness status - if let Some(freshness) = &model.freshness { - println!(); - println!("{}", ui::gradient_text("─── Freshness ───")); - let status = if freshness.is_up_to_date { - ui::gradient_start("Up to date") - } else { - ui::gradient_end("Needs refresh") - }; - println!(" {} {}", ui::dim("Status:"), status); - - if let Some(last_refresh) = &freshness.last_refresh_at { - println!(" {} {}", ui::dim("Last refresh:"), last_refresh); - } - - if freshness.memories_since_refresh > 0 { - println!(" {} {}", ui::dim("New memories:"), freshness.memories_since_refresh); - } - - if !freshness.reasons.is_empty() { - println!(" {} {}", ui::dim("Reasons:"), freshness.reasons.join(", ")); - } - } - - // Observations - println!(); - println!("{}", ui::gradient_text("─── Observations ───")); - println!(); - - if model.observations.is_empty() { - println!(" {}", ui::dim("No observations yet.")); - } else { - for (i, obs) in model.observations.iter().enumerate() { - print_observation(i + 1, obs); - } - } - - println!(); -} - -fn print_observation(index: usize, obs: &types::MentalModelObservationResponse) { - let trend_str = &obs.trend; - let trend_colored = match trend_str.as_str() { - "strengthening" => ui::gradient_start(trend_str), - "stable" => ui::gradient_mid(trend_str), - "weakening" | "stale" => ui::gradient_end(trend_str), - _ => trend_str.to_string(), - }; - - println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored))); - println!(" {}", obs.content); - - // Show evidence if available - if !obs.evidence.is_empty() { - println!(" {} evidence items:", ui::dim(&obs.evidence.len().to_string())); - for ev in obs.evidence.iter().take(2) { - // Show first 2 evidence items - let quote_preview: String = ev.quote.chars().take(60).collect(); - let ellipsis = if ev.quote.len() > 60 { "..." } else { "" }; - println!(" • \"{}{}\"", quote_preview, ellipsis); - } - if obs.evidence.len() > 2 { - println!(" {} more...", ui::dim(&format!("+ {}", obs.evidence.len() - 2))); - } - } - - println!(); -} - -fn print_observation_data(index: usize, obs: &ObservationData) { - let trend_str = obs.trend.as_deref().unwrap_or("unknown"); - let trend_colored = match trend_str { - "strengthening" => ui::gradient_start(trend_str), - "stable" => ui::gradient_mid(trend_str), - "weakening" | "stale" => ui::gradient_end(trend_str), - _ => trend_str.to_string(), - }; - - println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored))); - println!(" {}", obs.content); - - // Show evidence if available - if let Some(evidence) = &obs.evidence { - if !evidence.is_empty() { - println!(" {} evidence items:", ui::dim(&evidence.len().to_string())); - for ev in evidence.iter().take(2) { - // Show first 2 evidence items - let quote_preview: String = ev.quote.chars().take(60).collect(); - let ellipsis = if ev.quote.len() > 60 { "..." } else { "" }; - println!(" • \"{}{}\"", quote_preview, ellipsis); - } - if evidence.len() > 2 { - println!(" {} more...", ui::dim(&format!("+ {}", evidence.len() - 2))); - } - } - } - - println!(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_observation_input_serialization() { - let obs = types::ObservationInput { - title: "Test observation".to_string(), - content: "Test content".to_string(), - }; - let json = serde_json::to_string(&obs).unwrap(); - assert!(json.contains("Test observation")); - assert!(json.contains("Test content")); - } - - #[test] - fn test_version_list_response_deserialization() { - let json = r#"{ - "versions": [ - {"version": 1, "created_at": "2024-01-10T10:00:00Z", "observations_count": 5}, - {"version": 2, "created_at": "2024-01-15T10:00:00Z", "observations_count": 8} - ] - }"#; - - let value: serde_json::Value = serde_json::from_str(json).unwrap(); - let result: VersionListResponse = serde_json::from_value(value).unwrap(); - - assert_eq!(result.versions.len(), 2); - assert_eq!(result.versions[0].version, 1); - assert_eq!(result.versions[1].version, 2); - assert_eq!(result.versions[1].observations_count, Some(8)); - } - - #[test] - fn test_version_detail_response_deserialization() { - let json = r#"{ - "version": 1, - "created_at": "2024-01-10T10:00:00Z", - "observations": [ - { - "title": "Test observation", - "content": "Test content", - "trend": "stable", - "evidence": [{"quote": "test evidence"}] - } - ] - }"#; - - let value: serde_json::Value = serde_json::from_str(json).unwrap(); - let result: VersionDetailResponse = serde_json::from_value(value).unwrap(); - - assert_eq!(result.created_at, "2024-01-10T10:00:00Z"); - let observations = result.observations.unwrap(); - assert_eq!(observations.len(), 1); - assert_eq!(observations[0].title, "Test observation"); - assert_eq!(observations[0].trend, Some("stable".to_string())); - } - - #[test] - fn test_observation_data_deserialization() { - let json = r#"{ - "title": "Test Title", - "content": "Test Content", - "trend": "strengthening", - "evidence": [ - {"quote": "Evidence 1"}, - {"quote": "Evidence 2"} - ] - }"#; - - let result: ObservationData = serde_json::from_str(json).unwrap(); - - assert_eq!(result.title, "Test Title"); - assert_eq!(result.content, "Test Content"); - assert_eq!(result.trend, Some("strengthening".to_string())); - let evidence = result.evidence.unwrap(); - assert_eq!(evidence.len(), 2); - assert_eq!(evidence[0].quote, "Evidence 1"); - } - - #[test] - fn test_create_mental_model_request() { - let request = types::CreateMentalModelRequest { - name: "Test Model".to_string(), - description: "A test model".to_string(), - subtype: "pinned".to_string(), - tags: vec!["test".to_string()], - observations: None, - }; - - let json = serde_json::to_string(&request).unwrap(); - assert!(json.contains("Test Model")); - assert!(json.contains("pinned")); - assert!(json.contains("test")); - } - - #[test] - fn test_update_mental_model_request() { - let request = types::UpdateMentalModelRequest { - name: Some("Updated Name".to_string()), - description: None, - }; - - let json = serde_json::to_string(&request).unwrap(); - assert!(json.contains("Updated Name")); - } - - #[test] - fn test_async_operation_submit_response_deserialization() { - let json = r#"{ - "operation_id": "op-123", - "status": "pending" - }"#; - - let result: types::AsyncOperationSubmitResponse = serde_json::from_str(json).unwrap(); - - assert_eq!(result.operation_id, "op-123"); - assert_eq!(result.status, "pending"); - } -} diff --git a/hindsight-cli/src/commands/mod.rs b/hindsight-cli/src/commands/mod.rs index e72bb527..a7a63064 100644 --- a/hindsight-cli/src/commands/mod.rs +++ b/hindsight-cli/src/commands/mod.rs @@ -1,10 +1,11 @@ pub mod bank; pub mod chunk; +pub mod directive; pub mod document; pub mod entity; pub mod explore; pub mod health; pub mod memory; -pub mod mental_model; pub mod operation; +pub mod reflection; pub mod tag; diff --git a/hindsight-cli/src/commands/reflection.rs b/hindsight-cli/src/commands/reflection.rs new file mode 100644 index 00000000..96e50554 --- /dev/null +++ b/hindsight-cli/src/commands/reflection.rs @@ -0,0 +1,274 @@ +//! Reflection commands for managing user-curated summaries. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +use hindsight_client::types; + +/// List reflections for a bank +pub fn list( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching reflections...")) + } else { + None + }; + + let response = client.list_reflections(bank_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Reflections: {}", bank_id)); + + if result.items.is_empty() { + println!(" {}", ui::dim("No reflections found.")); + } else { + for reflection in &result.items { + println!( + " {} {}", + ui::gradient_start(&reflection.id), + reflection.name + ); + + // Show content preview + let preview: String = reflection.content.chars().take(80).collect(); + let ellipsis = if reflection.content.len() > 80 { "..." } else { "" }; + println!(" {}{}", ui::dim(&preview), ellipsis); + + println!(); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Get a specific reflection +pub fn get( + client: &ApiClient, + bank_id: &str, + reflection_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching reflection...")) + } else { + None + }; + + let response = client.get_reflection(bank_id, reflection_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(reflection) => { + if output_format == OutputFormat::Pretty { + print_reflection_detail(&reflection); + } else { + output::print_output(&reflection, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Create a new reflection +pub fn create( + client: &ApiClient, + bank_id: &str, + name: &str, + source_query: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Creating reflection...")) + } else { + None + }; + + let request = types::CreateReflectionRequest { + name: name.to_string(), + source_query: source_query.to_string(), + max_tokens: 2048, + tags: vec![], + }; + + let response = client.create_reflection(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(result) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Reflection created, operation_id: {}", result.operation_id)); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Update a reflection +pub fn update( + client: &ApiClient, + bank_id: &str, + reflection_id: &str, + name: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if name.is_none() { + anyhow::bail!("--name must be provided"); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating reflection...")) + } else { + None + }; + + let request = types::UpdateReflectionRequest { name }; + + let response = client.update_reflection(bank_id, reflection_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(reflection) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Reflection '{}' updated successfully", reflection_id)); + println!(); + print_reflection_detail(&reflection); + } else { + output::print_output(&reflection, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Delete a reflection +pub fn delete( + client: &ApiClient, + bank_id: &str, + reflection_id: &str, + yes: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + // Confirmation prompt unless -y flag is used + if !yes && output_format == OutputFormat::Pretty { + let message = format!( + "Are you sure you want to delete reflection '{}'? This cannot be undone.", + reflection_id + ); + + let confirmed = ui::prompt_confirmation(&message)?; + + if !confirmed { + ui::print_info("Operation cancelled"); + return Ok(()); + } + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Deleting reflection...")) + } else { + None + }; + + let response = client.delete_reflection(bank_id, reflection_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(_) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Reflection '{}' deleted successfully", reflection_id)); + } else { + println!("{{\"success\": true}}"); + } + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Refresh a reflection +pub fn refresh( + client: &ApiClient, + bank_id: &str, + reflection_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Refreshing reflection...")) + } else { + None + }; + + let response = client.refresh_reflection(bank_id, reflection_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + match response { + Ok(reflection) => { + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Reflection '{}' refreshed successfully", reflection_id)); + println!(); + print_reflection_detail(&reflection); + } else { + output::print_output(&reflection, output_format)?; + } + Ok(()) + } + Err(e) => Err(e), + } +} + +// Helper function to print reflection details +fn print_reflection_detail(reflection: &types::ReflectionResponse) { + ui::print_section_header(&reflection.name); + + println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&reflection.id)); + println!(" {} {}", ui::dim("Source Query:"), &reflection.source_query); + + println!(); + println!("{}", ui::gradient_text("─── Content ───")); + println!(); + println!("{}", &reflection.content); + println!(); +} diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index 75d190a1..38d2b010 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -75,10 +75,6 @@ enum Commands { #[command(subcommand)] Memory(MemoryCommands), - /// Manage mental models (list, get, create, update, delete, refresh, versions) - #[command(subcommand)] - MentalModel(MentalModelCommands), - /// Manage documents (list, get, delete) #[command(subcommand)] Document(DocumentCommands), @@ -99,6 +95,14 @@ enum Commands { #[command(subcommand)] Operation(OperationCommands), + /// Manage reflections (user-curated summaries) + #[command(subcommand)] + Reflection(ReflectionCommands), + + /// Manage directives (behavioral rules) + #[command(subcommand)] + Directive(DirectiveCommands), + /// Check API health status Health, @@ -504,134 +508,6 @@ enum OperationCommands { }, } -#[derive(Subcommand)] -enum MentalModelCommands { - /// List mental models for a bank - List { - /// Bank ID - bank_id: String, - - /// Filter by subtype (structural, emergent, pinned, learned, directive) - #[arg(long)] - subtype: Option, - - /// Filter by tags - #[arg(long, value_delimiter = ',')] - tags: Option>, - - /// Tag matching mode (any, all, any_strict, all_strict) - #[arg(long, default_value = "any")] - tags_match: Option, - }, - - /// Get a specific mental model - Get { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - }, - - /// Create a new mental model (pinned or directive subtype) - Create { - /// Bank ID - bank_id: String, - - /// Model name - name: String, - - /// Model description - description: String, - - /// Subtype (pinned or directive) - #[arg(long, default_value = "pinned")] - subtype: Option, - - /// Tags for the model - #[arg(long, value_delimiter = ',')] - tags: Option>, - - /// Path to JSON file containing initial observations - #[arg(long)] - observations: Option, - }, - - /// Update a mental model's name or description - Update { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - - /// New name - #[arg(long)] - name: Option, - - /// New description - #[arg(long)] - description: Option, - }, - - /// Delete a mental model - Delete { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - - /// Skip confirmation prompt - #[arg(short = 'y', long)] - yes: bool, - }, - - /// Refresh all mental models (async operation) - RefreshAll { - /// Bank ID - bank_id: String, - - /// Filter by subtype - #[arg(long)] - subtype: Option, - - /// Filter by tags - #[arg(long, value_delimiter = ',')] - tags: Option>, - }, - - /// Refresh a specific mental model (async operation) - Refresh { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - }, - - /// List version history for a mental model - Versions { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - }, - - /// Get a specific version of a mental model - Version { - /// Bank ID - bank_id: String, - - /// Mental model ID - model_id: String, - - /// Version number - version: i64, - }, -} - #[derive(Subcommand)] enum TagCommands { /// List tags in a bank @@ -662,6 +538,131 @@ enum ChunkCommands { }, } +#[derive(Subcommand)] +enum ReflectionCommands { + /// List reflections for a bank + List { + /// Bank ID + bank_id: String, + }, + + /// Get a specific reflection + Get { + /// Bank ID + bank_id: String, + + /// Reflection ID + reflection_id: String, + }, + + /// Create a new reflection + Create { + /// Bank ID + bank_id: String, + + /// Reflection name + name: String, + + /// Source query to generate the reflection from + source_query: String, + }, + + /// Update a reflection + Update { + /// Bank ID + bank_id: String, + + /// Reflection ID + reflection_id: String, + + /// New name + #[arg(long)] + name: Option, + }, + + /// Delete a reflection + Delete { + /// Bank ID + bank_id: String, + + /// Reflection ID + reflection_id: String, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, + + /// Refresh a reflection (re-run the source query) + Refresh { + /// Bank ID + bank_id: String, + + /// Reflection ID + reflection_id: String, + }, +} + +#[derive(Subcommand)] +enum DirectiveCommands { + /// List directives for a bank + List { + /// Bank ID + bank_id: String, + }, + + /// Get a specific directive + Get { + /// Bank ID + bank_id: String, + + /// Directive ID + directive_id: String, + }, + + /// Create a new directive + Create { + /// Bank ID + bank_id: String, + + /// Directive name + name: String, + + /// Directive content (the text to inject into prompts) + content: String, + }, + + /// Update a directive + Update { + /// Bank ID + bank_id: String, + + /// Directive ID + directive_id: String, + + /// New name + #[arg(long)] + name: Option, + + /// New content + #[arg(long)] + content: Option, + }, + + /// Delete a directive + Delete { + /// Bank ID + bank_id: String, + + /// Directive ID + directive_id: String, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, +} + fn main() { if let Err(_) = run() { std::process::exit(1); @@ -763,37 +764,6 @@ fn run() -> Result<()> { } }, - // Mental Model commands - Commands::MentalModel(mm_cmd) => match mm_cmd { - MentalModelCommands::List { bank_id, subtype, tags, tags_match } => { - commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format) - } - MentalModelCommands::Get { bank_id, model_id } => { - commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format) - } - MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => { - commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format) - } - MentalModelCommands::Update { bank_id, model_id, name, description } => { - commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format) - } - MentalModelCommands::Delete { bank_id, model_id, yes } => { - commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format) - } - MentalModelCommands::RefreshAll { bank_id, subtype, tags } => { - commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format) - } - MentalModelCommands::Refresh { bank_id, model_id } => { - commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format) - } - MentalModelCommands::Versions { bank_id, model_id } => { - commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format) - } - MentalModelCommands::Version { bank_id, model_id, version } => { - commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format) - } - }, - // Document commands Commands::Document(doc_cmd) => match doc_cmd { DocumentCommands::List { bank_id, query, limit, offset } => { @@ -846,6 +816,47 @@ fn run() -> Result<()> { commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) } }, + + // Reflection commands + Commands::Reflection(ref_cmd) => match ref_cmd { + ReflectionCommands::List { bank_id } => { + commands::reflection::list(&client, &bank_id, verbose, output_format) + } + ReflectionCommands::Get { bank_id, reflection_id } => { + commands::reflection::get(&client, &bank_id, &reflection_id, verbose, output_format) + } + ReflectionCommands::Create { bank_id, name, source_query } => { + commands::reflection::create(&client, &bank_id, &name, &source_query, verbose, output_format) + } + ReflectionCommands::Update { bank_id, reflection_id, name } => { + commands::reflection::update(&client, &bank_id, &reflection_id, name, verbose, output_format) + } + ReflectionCommands::Delete { bank_id, reflection_id, yes } => { + commands::reflection::delete(&client, &bank_id, &reflection_id, yes, verbose, output_format) + } + ReflectionCommands::Refresh { bank_id, reflection_id } => { + commands::reflection::refresh(&client, &bank_id, &reflection_id, verbose, output_format) + } + }, + + // Directive commands + Commands::Directive(dir_cmd) => match dir_cmd { + DirectiveCommands::List { bank_id } => { + commands::directive::list(&client, &bank_id, verbose, output_format) + } + DirectiveCommands::Get { bank_id, directive_id } => { + commands::directive::get(&client, &bank_id, &directive_id, verbose, output_format) + } + DirectiveCommands::Create { bank_id, name, content } => { + commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format) + } + DirectiveCommands::Update { bank_id, directive_id, name, content } => { + commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format) + } + DirectiveCommands::Delete { bank_id, directive_id, yes } => { + commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format) + } + }, }; // Handle API errors with nice messages diff --git a/hindsight-cli/src/ui.rs b/hindsight-cli/src/ui.rs index 6223ae2e..e4f45f35 100644 --- a/hindsight-cli/src/ui.rs +++ b/hindsight-cli/src/ui.rs @@ -173,7 +173,7 @@ pub fn print_think_response(response: &ReflectResponse) { println!(); if let Some(based_on) = &response.based_on { - let count = based_on.memories.len() + based_on.mental_models.len(); + let count = based_on.memories.len(); if count > 0 { println!("{}", dim(&format!("Based on {} memory units", count))); } diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 1b9dc649..b8bbaefd 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -1,19 +1,19 @@ hindsight_client_api/__init__.py hindsight_client_api/api/__init__.py hindsight_client_api/api/banks_api.py +hindsight_client_api/api/directives_api.py hindsight_client_api/api/documents_api.py hindsight_client_api/api/entities_api.py hindsight_client_api/api/memory_api.py -hindsight_client_api/api/mental_models_api.py hindsight_client_api/api/monitoring_api.py hindsight_client_api/api/operations_api.py +hindsight_client_api/api/reflections_api.py hindsight_client_api/api_client.py hindsight_client_api/api_response.py hindsight_client_api/configuration.py hindsight_client_api/exceptions.py hindsight_client_api/models/__init__.py hindsight_client_api/models/add_background_request.py -hindsight_client_api/models/async_operation_submit_response.py hindsight_client_api/models/background_response.py hindsight_client_api/models/bank_list_item.py hindsight_client_api/models/bank_list_response.py @@ -24,11 +24,15 @@ hindsight_client_api/models/cancel_operation_response.py hindsight_client_api/models/chunk_data.py hindsight_client_api/models/chunk_include_options.py hindsight_client_api/models/chunk_response.py +hindsight_client_api/models/consolidation_response.py hindsight_client_api/models/create_bank_request.py -hindsight_client_api/models/create_mental_model_request.py -hindsight_client_api/models/created_mental_model.py +hindsight_client_api/models/create_directive_request.py +hindsight_client_api/models/create_reflection_request.py +hindsight_client_api/models/create_reflection_response.py hindsight_client_api/models/delete_document_response.py hindsight_client_api/models/delete_response.py +hindsight_client_api/models/directive_list_response.py +hindsight_client_api/models/directive_response.py hindsight_client_api/models/disposition_traits.py hindsight_client_api/models/document_response.py hindsight_client_api/models/entity_detail_response.py @@ -38,6 +42,7 @@ hindsight_client_api/models/entity_list_item.py hindsight_client_api/models/entity_list_response.py hindsight_client_api/models/entity_observation_response.py hindsight_client_api/models/entity_state_response.py +hindsight_client_api/models/features_info.py hindsight_client_api/models/graph_data_response.py hindsight_client_api/models/http_validation_error.py hindsight_client_api/models/include_options.py @@ -45,12 +50,6 @@ hindsight_client_api/models/list_documents_response.py hindsight_client_api/models/list_memory_units_response.py hindsight_client_api/models/list_tags_response.py hindsight_client_api/models/memory_item.py -hindsight_client_api/models/mental_model_freshness_response.py -hindsight_client_api/models/mental_model_list_response.py -hindsight_client_api/models/mental_model_observation_response.py -hindsight_client_api/models/mental_model_response.py -hindsight_client_api/models/observation_evidence_response.py -hindsight_client_api/models/observation_input.py hindsight_client_api/models/operation_response.py hindsight_client_api/models/operation_status_response.py hindsight_client_api/models/operations_list_response.py @@ -66,15 +65,18 @@ hindsight_client_api/models/reflect_request.py hindsight_client_api/models/reflect_response.py hindsight_client_api/models/reflect_tool_call.py hindsight_client_api/models/reflect_trace.py -hindsight_client_api/models/refresh_mental_models_request.py +hindsight_client_api/models/reflection_list_response.py +hindsight_client_api/models/reflection_response.py hindsight_client_api/models/retain_request.py hindsight_client_api/models/retain_response.py hindsight_client_api/models/tag_item.py hindsight_client_api/models/token_usage.py hindsight_client_api/models/tool_calls_include_options.py +hindsight_client_api/models/update_directive_request.py hindsight_client_api/models/update_disposition_request.py -hindsight_client_api/models/update_mental_model_request.py +hindsight_client_api/models/update_reflection_request.py hindsight_client_api/models/validation_error.py hindsight_client_api/models/validation_error_loc_inner.py +hindsight_client_api/models/version_response.py hindsight_client_api/rest.py hindsight_client_api_README.md diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 1269aaad..9cfdd17e 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -10,7 +10,7 @@ from typing import Optional, List, Dict, Any, Literal from datetime import datetime import hindsight_client_api -from hindsight_client_api.api import memory_api, banks_api, mental_models_api +from hindsight_client_api.api import memory_api, banks_api from hindsight_client_api.models import ( recall_request, retain_request, @@ -23,9 +23,6 @@ from hindsight_client_api.models.recall_result import RecallResult from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.models.mental_model_response import MentalModelResponse -from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse -from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse def _run_async(coro): @@ -81,7 +78,6 @@ class Hindsight: self._api_client.set_default_header("Authorization", f"Bearer {api_key}") self._memory_api = memory_api.MemoryApi(self._api_client) self._banks_api = banks_api.BanksApi(self._api_client) - self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client) def __enter__(self): """Context manager entry.""" @@ -356,236 +352,6 @@ class Hindsight: request_obj = create_bank_request.CreateBankRequest(mission=mission) return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj)) - def list_mental_models( - self, - bank_id: str, - subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None, - tags: Optional[List[str]] = None, - tags_match: Optional[Literal["any", "all", "exact"]] = None, - ) -> MentalModelListResponse: - """ - List mental models for a bank. - - Args: - bank_id: The memory bank ID - subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive) - tags: Optional list of tags to filter by - tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact' - - Returns: - MentalModelListResponse with list of mental models - """ - return _run_async(self._mental_models_api.list_mental_models( - bank_id=bank_id, - subtype=subtype, - tags=tags, - tags_match=tags_match, - )) - - def get_mental_model( - self, - bank_id: str, - model_id: str, - ) -> MentalModelResponse: - """ - Get a specific mental model by ID. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID - - Returns: - MentalModelResponse with full mental model details including observations - """ - return _run_async(self._mental_models_api.get_mental_model( - bank_id=bank_id, - model_id=model_id, - )) - - def create_mental_model( - self, - bank_id: str, - name: str, - description: str, - subtype: Literal["pinned", "directive"] = "pinned", - observations: Optional[List[Dict[str, str]]] = None, - tags: Optional[List[str]] = None, - ) -> MentalModelResponse: - """ - Create a mental model. - - Args: - bank_id: The memory bank ID - name: Human-readable name for the mental model - description: One-liner description for quick scanning - subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations) - observations: For directives only - list of observations with 'title' and 'content' keys - tags: Optional list of tags for scoped visibility - - Returns: - MentalModelResponse with created mental model - """ - from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest - from hindsight_client_api.models.observation_input import ObservationInput - - obs_list = None - if observations: - obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations] - - request_obj = CreateMentalModelRequest( - name=name, - description=description, - subtype=subtype, - observations=obs_list, - tags=tags or [], - ) - return _run_async(self._mental_models_api.create_mental_model( - bank_id=bank_id, - create_mental_model_request=request_obj, - )) - - def update_mental_model( - self, - bank_id: str, - model_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - ) -> MentalModelResponse: - """ - Update a mental model's name and/or description. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID - name: Optional new name - description: Optional new description - - Returns: - MentalModelResponse with updated mental model - """ - from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest - - request_obj = UpdateMentalModelRequest( - name=name, - description=description, - ) - return _run_async(self._mental_models_api.update_mental_model( - bank_id=bank_id, - model_id=model_id, - update_mental_model_request=request_obj, - )) - - def delete_mental_model( - self, - bank_id: str, - model_id: str, - ): - """ - Delete a mental model. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID - - Returns: - DeleteResponse confirming deletion - """ - return _run_async(self._mental_models_api.delete_mental_model( - bank_id=bank_id, - model_id=model_id, - )) - - def refresh_mental_models( - self, - bank_id: str, - subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None, - tags: Optional[List[str]] = None, - ) -> AsyncOperationSubmitResponse: - """ - Submit a background job to refresh mental models for a bank. - - Args: - bank_id: The memory bank ID - subtype: Optional - only refresh models of this subtype - tags: Optional - tags to apply to newly created mental models - - Returns: - AsyncOperationSubmitResponse with operation_id to track progress - """ - from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest - - request_obj = RefreshMentalModelsRequest( - subtype=subtype, - tags=tags, - ) - return _run_async(self._mental_models_api.refresh_mental_models( - bank_id=bank_id, - refresh_mental_models_request=request_obj, - )) - - def refresh_mental_model( - self, - bank_id: str, - model_id: str, - ) -> AsyncOperationSubmitResponse: - """ - Submit a background job to refresh content for a specific mental model. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID to refresh - - Returns: - AsyncOperationSubmitResponse with operation_id to track progress - """ - return _run_async(self._mental_models_api.refresh_mental_model( - bank_id=bank_id, - model_id=model_id, - )) - - def list_mental_model_versions( - self, - bank_id: str, - model_id: str, - ): - """ - List all saved versions of a mental model's observations. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID - - Returns: - List of version objects ordered by version descending - """ - return _run_async(self._mental_models_api.list_mental_model_versions( - bank_id=bank_id, - model_id=model_id, - )) - - def get_mental_model_version( - self, - bank_id: str, - model_id: str, - version: int, - ): - """ - Get observations from a specific version of a mental model. - - Args: - bank_id: The memory bank ID - model_id: The mental model ID - version: The version number - - Returns: - Version object with observations at that version - """ - return _run_async(self._mental_models_api.get_mental_model_version( - bank_id=bank_id, - model_id=model_id, - version=version, - )) - # Async methods (native async, no _run_async wrapper) async def aretain_batch( diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index a00f92d8..d6d3bcb9 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -18,12 +18,13 @@ __version__ = "0.0.7" # import apis into sdk package from hindsight_client_api.api.banks_api import BanksApi +from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi from hindsight_client_api.api.entities_api import EntitiesApi from hindsight_client_api.api.memory_api import MemoryApi -from hindsight_client_api.api.mental_models_api import MentalModelsApi from hindsight_client_api.api.monitoring_api import MonitoringApi from hindsight_client_api.api.operations_api import OperationsApi +from hindsight_client_api.api.reflections_api import ReflectionsApi # import ApiClient from hindsight_client_api.api_response import ApiResponse @@ -38,7 +39,6 @@ from hindsight_client_api.exceptions import ApiException # import models into sdk package from hindsight_client_api.models.add_background_request import AddBackgroundRequest -from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse from hindsight_client_api.models.background_response import BackgroundResponse from hindsight_client_api.models.bank_list_item import BankListItem from hindsight_client_api.models.bank_list_response import BankListResponse @@ -49,11 +49,15 @@ from hindsight_client_api.models.cancel_operation_response import CancelOperatio from hindsight_client_api.models.chunk_data import ChunkData from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions from hindsight_client_api.models.chunk_response import ChunkResponse +from hindsight_client_api.models.consolidation_response import ConsolidationResponse from hindsight_client_api.models.create_bank_request import CreateBankRequest -from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest -from hindsight_client_api.models.created_mental_model import CreatedMentalModel +from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest +from hindsight_client_api.models.create_reflection_request import CreateReflectionRequest +from hindsight_client_api.models.create_reflection_response import CreateReflectionResponse from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse from hindsight_client_api.models.delete_response import DeleteResponse +from hindsight_client_api.models.directive_list_response import DirectiveListResponse +from hindsight_client_api.models.directive_response import DirectiveResponse from hindsight_client_api.models.disposition_traits import DispositionTraits from hindsight_client_api.models.document_response import DocumentResponse from hindsight_client_api.models.entity_detail_response import EntityDetailResponse @@ -63,6 +67,7 @@ from hindsight_client_api.models.entity_list_item import EntityListItem from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse +from hindsight_client_api.models.features_info import FeaturesInfo from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.http_validation_error import HTTPValidationError from hindsight_client_api.models.include_options import IncludeOptions @@ -70,12 +75,6 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse from hindsight_client_api.models.memory_item import MemoryItem -from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse -from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse -from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse -from hindsight_client_api.models.mental_model_response import MentalModelResponse -from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse -from hindsight_client_api.models.observation_input import ObservationInput from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse from hindsight_client_api.models.operations_list_response import OperationsListResponse @@ -91,13 +90,16 @@ from hindsight_client_api.models.reflect_request import ReflectRequest from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.reflect_tool_call import ReflectToolCall from hindsight_client_api.models.reflect_trace import ReflectTrace -from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest +from hindsight_client_api.models.reflection_list_response import ReflectionListResponse +from hindsight_client_api.models.reflection_response import ReflectionResponse from hindsight_client_api.models.retain_request import RetainRequest from hindsight_client_api.models.retain_response import RetainResponse from hindsight_client_api.models.tag_item import TagItem from hindsight_client_api.models.token_usage import TokenUsage from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions +from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest -from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest +from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest from hindsight_client_api.models.validation_error import ValidationError from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner +from hindsight_client_api.models.version_response import VersionResponse diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py index 15a79c5a..b7029893 100644 --- a/hindsight-clients/python/hindsight_client_api/api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/api/__init__.py @@ -2,10 +2,11 @@ # import apis into api package from hindsight_client_api.api.banks_api import BanksApi +from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi from hindsight_client_api.api.entities_api import EntitiesApi from hindsight_client_api.api.memory_api import MemoryApi -from hindsight_client_api.api.mental_models_api import MentalModelsApi from hindsight_client_api.api.monitoring_api import MonitoringApi from hindsight_client_api.api.operations_api import OperationsApi +from hindsight_client_api.api.reflections_api import ReflectionsApi diff --git a/hindsight-clients/python/hindsight_client_api/api/banks_api.py b/hindsight-clients/python/hindsight_client_api/api/banks_api.py index 4ca11c2f..a0bf82b0 100644 --- a/hindsight-clients/python/hindsight_client_api/api/banks_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/banks_api.py @@ -23,6 +23,7 @@ from hindsight_client_api.models.background_response import BackgroundResponse from hindsight_client_api.models.bank_list_response import BankListResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse from hindsight_client_api.models.bank_stats_response import BankStatsResponse +from hindsight_client_api.models.consolidation_response import ConsolidationResponse from hindsight_client_api.models.create_bank_request import CreateBankRequest from hindsight_client_api.models.delete_response import DeleteResponse from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest @@ -354,6 +355,284 @@ class BanksApi: + @validate_call + async def clear_mental_models( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DeleteResponse: + """Clear all mental models + + Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._clear_mental_models_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DeleteResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def clear_mental_models_with_http_info( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DeleteResponse]: + """Clear all mental models + + Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._clear_mental_models_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DeleteResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def clear_mental_models_without_preload_content( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Clear all mental models + + Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._clear_mental_models_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DeleteResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _clear_mental_models_serialize( + self, + bank_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='DELETE', + resource_path='/v1/default/banks/{bank_id}/mental-models', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def create_or_update_bank( self, @@ -1757,6 +2036,284 @@ class BanksApi: + @validate_call + async def trigger_consolidation( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ConsolidationResponse: + """Trigger consolidation + + Run memory consolidation to create/update mental models from recent memories. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._trigger_consolidation_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ConsolidationResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def trigger_consolidation_with_http_info( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[ConsolidationResponse]: + """Trigger consolidation + + Run memory consolidation to create/update mental models from recent memories. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._trigger_consolidation_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ConsolidationResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def trigger_consolidation_without_preload_content( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Trigger consolidation + + Run memory consolidation to create/update mental models from recent memories. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._trigger_consolidation_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ConsolidationResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _trigger_consolidation_serialize( + self, + bank_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/consolidate', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def update_bank( self, diff --git a/hindsight-clients/python/hindsight_client_api/api/directives_api.py b/hindsight-clients/python/hindsight_client_api/api/directives_api.py new file mode 100644 index 00000000..74a038be --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/api/directives_api.py @@ -0,0 +1,1619 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import Field, StrictBool, StrictStr, field_validator +from typing import Any, List, Optional +from typing_extensions import Annotated +from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest +from hindsight_client_api.models.directive_list_response import DirectiveListResponse +from hindsight_client_api.models.directive_response import DirectiveResponse +from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest + +from hindsight_client_api.api_client import ApiClient, RequestSerialized +from hindsight_client_api.api_response import ApiResponse +from hindsight_client_api.rest import RESTResponseType + + +class DirectivesApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + async def create_directive( + self, + bank_id: StrictStr, + create_directive_request: CreateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DirectiveResponse: + """Create directive + + Create a hard rule that will be injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param create_directive_request: (required) + :type create_directive_request: CreateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._create_directive_serialize( + bank_id=bank_id, + create_directive_request=create_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def create_directive_with_http_info( + self, + bank_id: StrictStr, + create_directive_request: CreateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DirectiveResponse]: + """Create directive + + Create a hard rule that will be injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param create_directive_request: (required) + :type create_directive_request: CreateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._create_directive_serialize( + bank_id=bank_id, + create_directive_request=create_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def create_directive_without_preload_content( + self, + bank_id: StrictStr, + create_directive_request: CreateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Create directive + + Create a hard rule that will be injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param create_directive_request: (required) + :type create_directive_request: CreateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._create_directive_serialize( + bank_id=bank_id, + create_directive_request=create_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _create_directive_serialize( + self, + bank_id, + create_directive_request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + if create_directive_request is not None: + _body_params = create_directive_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/directives', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def delete_directive( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """Delete directive + + Delete a directive. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._delete_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def delete_directive_with_http_info( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[object]: + """Delete directive + + Delete a directive. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._delete_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def delete_directive_without_preload_content( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Delete directive + + Delete a directive. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._delete_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _delete_directive_serialize( + self, + bank_id, + directive_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if directive_id is not None: + _path_params['directive_id'] = directive_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='DELETE', + resource_path='/v1/default/banks/{bank_id}/directives/{directive_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def get_directive( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DirectiveResponse: + """Get directive + + Get a specific directive by ID. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_directive_with_http_info( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DirectiveResponse]: + """Get directive + + Get a specific directive by ID. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_directive_without_preload_content( + self, + bank_id: StrictStr, + directive_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get directive + + Get a specific directive by ID. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_directive_serialize( + self, + bank_id, + directive_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if directive_id is not None: + _path_params['directive_id'] = directive_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/directives/{directive_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def list_directives( + self, + bank_id: StrictStr, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DirectiveListResponse: + """List directives + + List hard rules that are injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param active_only: Only return active directives + :type active_only: bool + :param limit: + :type limit: int + :param offset: + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_directives_serialize( + bank_id=bank_id, + tags=tags, + tags_match=tags_match, + active_only=active_only, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveListResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def list_directives_with_http_info( + self, + bank_id: StrictStr, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DirectiveListResponse]: + """List directives + + List hard rules that are injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param active_only: Only return active directives + :type active_only: bool + :param limit: + :type limit: int + :param offset: + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_directives_serialize( + bank_id=bank_id, + tags=tags, + tags_match=tags_match, + active_only=active_only, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveListResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def list_directives_without_preload_content( + self, + bank_id: StrictStr, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """List directives + + List hard rules that are injected into prompts. + + :param bank_id: (required) + :type bank_id: str + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param active_only: Only return active directives + :type active_only: bool + :param limit: + :type limit: int + :param offset: + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_directives_serialize( + bank_id=bank_id, + tags=tags, + tags_match=tags_match, + active_only=active_only, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveListResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _list_directives_serialize( + self, + bank_id, + tags, + tags_match, + active_only, + limit, + offset, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + 'tags': 'multi', + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + if tags is not None: + + _query_params.append(('tags', tags)) + + if tags_match is not None: + + _query_params.append(('tags_match', tags_match)) + + if active_only is not None: + + _query_params.append(('active_only', active_only)) + + if limit is not None: + + _query_params.append(('limit', limit)) + + if offset is not None: + + _query_params.append(('offset', offset)) + + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/directives', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def update_directive( + self, + bank_id: StrictStr, + directive_id: StrictStr, + update_directive_request: UpdateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DirectiveResponse: + """Update directive + + Update a directive's properties. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param update_directive_request: (required) + :type update_directive_request: UpdateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + update_directive_request=update_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def update_directive_with_http_info( + self, + bank_id: StrictStr, + directive_id: StrictStr, + update_directive_request: UpdateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DirectiveResponse]: + """Update directive + + Update a directive's properties. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param update_directive_request: (required) + :type update_directive_request: UpdateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + update_directive_request=update_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def update_directive_without_preload_content( + self, + bank_id: StrictStr, + directive_id: StrictStr, + update_directive_request: UpdateDirectiveRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Update directive + + Update a directive's properties. + + :param bank_id: (required) + :type bank_id: str + :param directive_id: (required) + :type directive_id: str + :param update_directive_request: (required) + :type update_directive_request: UpdateDirectiveRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_directive_serialize( + bank_id=bank_id, + directive_id=directive_id, + update_directive_request=update_directive_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DirectiveResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _update_directive_serialize( + self, + bank_id, + directive_id, + update_directive_request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if directive_id is not None: + _path_params['directive_id'] = directive_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + if update_directive_request is not None: + _body_params = update_directive_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='PATCH', + resource_path='/v1/default/banks/{bank_id}/directives/{directive_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py index 354c25af..edd24acf 100644 --- a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py @@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated from typing import Any +from hindsight_client_api.models.version_response import VersionResponse from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse @@ -36,6 +37,251 @@ class MonitoringApi: self.api_client = api_client + @validate_call + async def get_version( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> VersionResponse: + """Get API version and feature flags + + Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_version_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "VersionResponse", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_version_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[VersionResponse]: + """Get API version and feature flags + + Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_version_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "VersionResponse", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_version_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get API version and feature flags + + Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_version_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "VersionResponse", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_version_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/version', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def health_endpoint_health_get( self, diff --git a/hindsight-clients/python/hindsight_client_api/api/operations_api.py b/hindsight-clients/python/hindsight_client_api/api/operations_api.py index 200c1abb..b3a7e63a 100644 --- a/hindsight-clients/python/hindsight_client_api/api/operations_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/operations_api.py @@ -16,8 +16,9 @@ from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import StrictStr +from pydantic import Field, StrictStr from typing import Optional +from typing_extensions import Annotated from hindsight_client_api.models.cancel_operation_response import CancelOperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse from hindsight_client_api.models.operations_list_response import OperationsListResponse @@ -630,6 +631,9 @@ class OperationsApi: async def list_operations( self, bank_id: StrictStr, + status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -646,10 +650,16 @@ class OperationsApi: ) -> OperationsListResponse: """List async operations - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations + Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first. :param bank_id: (required) :type bank_id: str + :param status: Filter by status: pending, completed, or failed + :type status: str + :param limit: Maximum number of operations to return + :type limit: int + :param offset: Number of operations to skip + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -676,6 +686,9 @@ class OperationsApi: _param = self._list_operations_serialize( bank_id=bank_id, + status=status, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -702,6 +715,9 @@ class OperationsApi: async def list_operations_with_http_info( self, bank_id: StrictStr, + status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -718,10 +734,16 @@ class OperationsApi: ) -> ApiResponse[OperationsListResponse]: """List async operations - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations + Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first. :param bank_id: (required) :type bank_id: str + :param status: Filter by status: pending, completed, or failed + :type status: str + :param limit: Maximum number of operations to return + :type limit: int + :param offset: Number of operations to skip + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -748,6 +770,9 @@ class OperationsApi: _param = self._list_operations_serialize( bank_id=bank_id, + status=status, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -774,6 +799,9 @@ class OperationsApi: async def list_operations_without_preload_content( self, bank_id: StrictStr, + status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -790,10 +818,16 @@ class OperationsApi: ) -> RESTResponseType: """List async operations - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations + Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first. :param bank_id: (required) :type bank_id: str + :param status: Filter by status: pending, completed, or failed + :type status: str + :param limit: Maximum number of operations to return + :type limit: int + :param offset: Number of operations to skip + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -820,6 +854,9 @@ class OperationsApi: _param = self._list_operations_serialize( bank_id=bank_id, + status=status, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -841,6 +878,9 @@ class OperationsApi: def _list_operations_serialize( self, bank_id, + status, + limit, + offset, authorization, _request_auth, _content_type, @@ -866,6 +906,18 @@ class OperationsApi: if bank_id is not None: _path_params['bank_id'] = bank_id # process the query parameters + if status is not None: + + _query_params.append(('status', status)) + + if limit is not None: + + _query_params.append(('limit', limit)) + + if offset is not None: + + _query_params.append(('offset', offset)) + # process the header parameters if authorization is not None: _header_params['authorization'] = authorization diff --git a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py b/hindsight-clients/python/hindsight_client_api/api/reflections_api.py similarity index 56% rename from hindsight-clients/python/hindsight_client_api/api/mental_models_api.py rename to hindsight-clients/python/hindsight_client_api/api/reflections_api.py index 66aa1d50..e617b1b0 100644 --- a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/reflections_api.py @@ -16,23 +16,21 @@ from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import Field, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictStr, field_validator from typing import Any, List, Optional from typing_extensions import Annotated -from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse -from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest -from hindsight_client_api.models.delete_response import DeleteResponse -from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse -from hindsight_client_api.models.mental_model_response import MentalModelResponse -from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest -from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest +from hindsight_client_api.models.create_reflection_request import CreateReflectionRequest +from hindsight_client_api.models.create_reflection_response import CreateReflectionResponse +from hindsight_client_api.models.reflection_list_response import ReflectionListResponse +from hindsight_client_api.models.reflection_response import ReflectionResponse +from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse from hindsight_client_api.rest import RESTResponseType -class MentalModelsApi: +class ReflectionsApi: """NOTE: This class is auto generated by OpenAPI Generator Ref: https://openapi-generator.tech @@ -46,10 +44,10 @@ class MentalModelsApi: @validate_call - async def create_mental_model( + async def create_reflection( self, bank_id: StrictStr, - create_mental_model_request: CreateMentalModelRequest, + create_reflection_request: CreateReflectionRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -63,15 +61,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> MentalModelResponse: - """Create mental model + ) -> CreateReflectionResponse: + """Create reflection - Create a mental model. Supports two subtypes: - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh - 'directive': User-defined hard rules, observations are provided at creation and never regenerated + Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status. :param bank_id: (required) :type bank_id: str - :param create_mental_model_request: (required) - :type create_mental_model_request: CreateMentalModelRequest + :param create_reflection_request: (required) + :type create_reflection_request: CreateReflectionRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -96,9 +94,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._create_mental_model_serialize( + _param = self._create_reflection_serialize( bank_id=bank_id, - create_mental_model_request=create_mental_model_request, + create_reflection_request=create_reflection_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -107,7 +105,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "CreateReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -122,10 +120,10 @@ class MentalModelsApi: @validate_call - async def create_mental_model_with_http_info( + async def create_reflection_with_http_info( self, bank_id: StrictStr, - create_mental_model_request: CreateMentalModelRequest, + create_reflection_request: CreateReflectionRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -139,15 +137,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[MentalModelResponse]: - """Create mental model + ) -> ApiResponse[CreateReflectionResponse]: + """Create reflection - Create a mental model. Supports two subtypes: - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh - 'directive': User-defined hard rules, observations are provided at creation and never regenerated + Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status. :param bank_id: (required) :type bank_id: str - :param create_mental_model_request: (required) - :type create_mental_model_request: CreateMentalModelRequest + :param create_reflection_request: (required) + :type create_reflection_request: CreateReflectionRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -172,9 +170,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._create_mental_model_serialize( + _param = self._create_reflection_serialize( bank_id=bank_id, - create_mental_model_request=create_mental_model_request, + create_reflection_request=create_reflection_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -183,7 +181,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "CreateReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -198,10 +196,10 @@ class MentalModelsApi: @validate_call - async def create_mental_model_without_preload_content( + async def create_reflection_without_preload_content( self, bank_id: StrictStr, - create_mental_model_request: CreateMentalModelRequest, + create_reflection_request: CreateReflectionRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -216,14 +214,14 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Create mental model + """Create reflection - Create a mental model. Supports two subtypes: - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh - 'directive': User-defined hard rules, observations are provided at creation and never regenerated + Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status. :param bank_id: (required) :type bank_id: str - :param create_mental_model_request: (required) - :type create_mental_model_request: CreateMentalModelRequest + :param create_reflection_request: (required) + :type create_reflection_request: CreateReflectionRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -248,9 +246,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._create_mental_model_serialize( + _param = self._create_reflection_serialize( bank_id=bank_id, - create_mental_model_request=create_mental_model_request, + create_reflection_request=create_reflection_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -259,7 +257,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "CreateReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -269,10 +267,10 @@ class MentalModelsApi: return response_data.response - def _create_mental_model_serialize( + def _create_reflection_serialize( self, bank_id, - create_mental_model_request, + create_reflection_request, authorization, _request_auth, _content_type, @@ -303,8 +301,8 @@ class MentalModelsApi: _header_params['authorization'] = authorization # process the form parameters # process the body parameter - if create_mental_model_request is not None: - _body_params = create_mental_model_request + if create_reflection_request is not None: + _body_params = create_reflection_request # set the HTTP header `Accept` @@ -335,7 +333,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='POST', - resource_path='/v1/default/banks/{bank_id}/mental-models', + resource_path='/v1/default/banks/{bank_id}/reflections', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -352,10 +350,10 @@ class MentalModelsApi: @validate_call - async def delete_mental_model( + async def delete_reflection( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -369,15 +367,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> DeleteResponse: - """Delete mental model + ) -> object: + """Delete reflection - Delete a mental model. + Delete a reflection. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -402,9 +400,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._delete_mental_model_serialize( + _param = self._delete_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -413,7 +411,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -428,10 +426,10 @@ class MentalModelsApi: @validate_call - async def delete_mental_model_with_http_info( + async def delete_reflection_with_http_info( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -445,15 +443,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[DeleteResponse]: - """Delete mental model + ) -> ApiResponse[object]: + """Delete reflection - Delete a mental model. + Delete a reflection. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -478,9 +476,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._delete_mental_model_serialize( + _param = self._delete_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -489,7 +487,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -504,10 +502,10 @@ class MentalModelsApi: @validate_call - async def delete_mental_model_without_preload_content( + async def delete_reflection_without_preload_content( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -522,14 +520,14 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Delete mental model + """Delete reflection - Delete a mental model. + Delete a reflection. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -554,9 +552,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._delete_mental_model_serialize( + _param = self._delete_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -565,7 +563,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -575,10 +573,10 @@ class MentalModelsApi: return response_data.response - def _delete_mental_model_serialize( + def _delete_reflection_serialize( self, bank_id, - model_id, + reflection_id, authorization, _request_auth, _content_type, @@ -603,8 +601,8 @@ class MentalModelsApi: # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id + if reflection_id is not None: + _path_params['reflection_id'] = reflection_id # process the query parameters # process the header parameters if authorization is not None: @@ -628,7 +626,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='DELETE', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}', + resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -645,10 +643,10 @@ class MentalModelsApi: @validate_call - async def get_mental_model( + async def get_reflection( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -662,15 +660,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> MentalModelResponse: - """Get mental model + ) -> ReflectionResponse: + """Get reflection - Get a specific mental model by ID. + Get a specific reflection by ID. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -695,9 +693,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_serialize( + _param = self._get_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -706,7 +704,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -721,10 +719,10 @@ class MentalModelsApi: @validate_call - async def get_mental_model_with_http_info( + async def get_reflection_with_http_info( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -738,15 +736,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[MentalModelResponse]: - """Get mental model + ) -> ApiResponse[ReflectionResponse]: + """Get reflection - Get a specific mental model by ID. + Get a specific reflection by ID. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -771,9 +769,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_serialize( + _param = self._get_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -782,7 +780,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -797,10 +795,10 @@ class MentalModelsApi: @validate_call - async def get_mental_model_without_preload_content( + async def get_reflection_without_preload_content( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -815,14 +813,14 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get mental model + """Get reflection - Get a specific mental model by ID. + Get a specific reflection by ID. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -847,9 +845,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_serialize( + _param = self._get_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -858,7 +856,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -868,10 +866,10 @@ class MentalModelsApi: return response_data.response - def _get_mental_model_serialize( + def _get_reflection_serialize( self, bank_id, - model_id, + reflection_id, authorization, _request_auth, _content_type, @@ -896,8 +894,8 @@ class MentalModelsApi: # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id + if reflection_id is not None: + _path_params['reflection_id'] = reflection_id # process the query parameters # process the header parameters if authorization is not None: @@ -921,7 +919,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}', + resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -938,11 +936,13 @@ class MentalModelsApi: @validate_call - async def get_mental_model_version( + async def list_reflections( self, bank_id: StrictStr, - model_id: StrictStr, - version: StrictInt, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -956,17 +956,21 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> object: - """Get specific mental model version + ) -> ReflectionListResponse: + """List reflections - Get observations from a specific version of a mental model. + List user-curated living documents that stay current. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str - :param version: (required) - :type version: int + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -991,10 +995,12 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_version_serialize( + _param = self._list_reflections_serialize( bank_id=bank_id, - model_id=model_id, - version=version, + tags=tags, + tags_match=tags_match, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1003,7 +1009,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ReflectionListResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1018,11 +1024,13 @@ class MentalModelsApi: @validate_call - async def get_mental_model_version_with_http_info( + async def list_reflections_with_http_info( self, bank_id: StrictStr, - model_id: StrictStr, - version: StrictInt, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1036,17 +1044,21 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[object]: - """Get specific mental model version + ) -> ApiResponse[ReflectionListResponse]: + """List reflections - Get observations from a specific version of a mental model. + List user-curated living documents that stay current. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str - :param version: (required) - :type version: int + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1071,10 +1083,12 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_version_serialize( + _param = self._list_reflections_serialize( bank_id=bank_id, - model_id=model_id, - version=version, + tags=tags, + tags_match=tags_match, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1083,7 +1097,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ReflectionListResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1098,11 +1112,13 @@ class MentalModelsApi: @validate_call - async def get_mental_model_version_without_preload_content( + async def list_reflections_without_preload_content( self, bank_id: StrictStr, - model_id: StrictStr, - version: StrictInt, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None, + limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None, + offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1117,16 +1133,20 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get specific mental model version + """List reflections - Get observations from a specific version of a mental model. + List user-curated living documents that stay current. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str - :param version: (required) - :type version: int + :param tags: Filter by tags + :type tags: List[str] + :param tags_match: How to match tags + :type tags_match: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1151,10 +1171,12 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._get_mental_model_version_serialize( + _param = self._list_reflections_serialize( bank_id=bank_id, - model_id=model_id, - version=version, + tags=tags, + tags_match=tags_match, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1163,7 +1185,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ReflectionListResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1173,625 +1195,13 @@ class MentalModelsApi: return response_data.response - def _get_mental_model_version_serialize( + def _list_reflections_serialize( self, bank_id, - model_id, - version, - authorization, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - if bank_id is not None: - _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id - if version is not None: - _path_params['version'] = version - # process the query parameters - # process the header parameters - if authorization is not None: - _header_params['authorization'] = authorization - # process the form parameters - # process the body parameter - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='GET', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - - - - @validate_call - async def list_mental_model_versions( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> object: - """List mental model version history - - List all saved versions of a mental model's observations, ordered by version descending. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_model_versions_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ).data - - - @validate_call - async def list_mental_model_versions_with_http_info( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[object]: - """List mental model version history - - List all saved versions of a mental model's observations, ordered by version descending. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_model_versions_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ) - - - @validate_call - async def list_mental_model_versions_without_preload_content( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """List mental model version history - - List all saved versions of a mental model's observations, ordered by version descending. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_model_versions_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_mental_model_versions_serialize( - self, - bank_id, - model_id, - authorization, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - if bank_id is not None: - _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id - # process the query parameters - # process the header parameters - if authorization is not None: - _header_params['authorization'] = authorization - # process the form parameters - # process the body parameter - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='GET', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - - - - @validate_call - async def list_mental_models( - self, - bank_id: StrictStr, - subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None, - tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None, - tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> MentalModelListResponse: - """List mental models - - List all mental models for a bank, optionally filtered by subtype or tags. - - :param bank_id: (required) - :type bank_id: str - :param subtype: Filter by subtype: structural, emergent, or pinned - :type subtype: str - :param tags: Filter by tags (includes untagged models) - :type tags: List[str] - :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact' - :type tags_match: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_models_serialize( - bank_id=bank_id, - subtype=subtype, - tags=tags, - tags_match=tags_match, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelListResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ).data - - - @validate_call - async def list_mental_models_with_http_info( - self, - bank_id: StrictStr, - subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None, - tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None, - tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[MentalModelListResponse]: - """List mental models - - List all mental models for a bank, optionally filtered by subtype or tags. - - :param bank_id: (required) - :type bank_id: str - :param subtype: Filter by subtype: structural, emergent, or pinned - :type subtype: str - :param tags: Filter by tags (includes untagged models) - :type tags: List[str] - :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact' - :type tags_match: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_models_serialize( - bank_id=bank_id, - subtype=subtype, - tags=tags, - tags_match=tags_match, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelListResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ) - - - @validate_call - async def list_mental_models_without_preload_content( - self, - bank_id: StrictStr, - subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None, - tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None, - tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """List mental models - - List all mental models for a bank, optionally filtered by subtype or tags. - - :param bank_id: (required) - :type bank_id: str - :param subtype: Filter by subtype: structural, emergent, or pinned - :type subtype: str - :param tags: Filter by tags (includes untagged models) - :type tags: List[str] - :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact' - :type tags_match: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._list_mental_models_serialize( - bank_id=bank_id, - subtype=subtype, - tags=tags, - tags_match=tags_match, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelListResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_mental_models_serialize( - self, - bank_id, - subtype, tags, tags_match, + limit, + offset, authorization, _request_auth, _content_type, @@ -1818,10 +1228,6 @@ class MentalModelsApi: if bank_id is not None: _path_params['bank_id'] = bank_id # process the query parameters - if subtype is not None: - - _query_params.append(('subtype', subtype)) - if tags is not None: _query_params.append(('tags', tags)) @@ -1830,6 +1236,14 @@ class MentalModelsApi: _query_params.append(('tags_match', tags_match)) + if limit is not None: + + _query_params.append(('limit', limit)) + + if offset is not None: + + _query_params.append(('offset', offset)) + # process the header parameters if authorization is not None: _header_params['authorization'] = authorization @@ -1852,7 +1266,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/mental-models', + resource_path='/v1/default/banks/{bank_id}/reflections', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1869,10 +1283,10 @@ class MentalModelsApi: @validate_call - async def refresh_mental_model( + async def refresh_reflection( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1886,15 +1300,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> AsyncOperationSubmitResponse: - """Refresh mental model content (async) + ) -> ReflectionResponse: + """Refresh reflection - Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model. + Re-run the source query through reflect and update the content. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1919,9 +1333,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._refresh_mental_model_serialize( + _param = self._refresh_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1930,7 +1344,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1945,10 +1359,10 @@ class MentalModelsApi: @validate_call - async def refresh_mental_model_with_http_info( + async def refresh_reflection_with_http_info( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1962,15 +1376,15 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[AsyncOperationSubmitResponse]: - """Refresh mental model content (async) + ) -> ApiResponse[ReflectionResponse]: + """Refresh reflection - Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model. + Re-run the source query through reflect and update the content. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1995,9 +1409,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._refresh_mental_model_serialize( + _param = self._refresh_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2006,7 +1420,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2021,10 +1435,10 @@ class MentalModelsApi: @validate_call - async def refresh_mental_model_without_preload_content( + async def refresh_reflection_without_preload_content( self, bank_id: StrictStr, - model_id: StrictStr, + reflection_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2039,14 +1453,14 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Refresh mental model content (async) + """Refresh reflection - Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model. + Re-run the source query through reflect and update the content. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str + :param reflection_id: (required) + :type reflection_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2071,9 +1485,9 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._refresh_mental_model_serialize( + _param = self._refresh_reflection_serialize( bank_id=bank_id, - model_id=model_id, + reflection_id=reflection_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2082,7 +1496,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2092,10 +1506,10 @@ class MentalModelsApi: return response_data.response - def _refresh_mental_model_serialize( + def _refresh_reflection_serialize( self, bank_id, - model_id, + reflection_id, authorization, _request_auth, _content_type, @@ -2120,8 +1534,8 @@ class MentalModelsApi: # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id + if reflection_id is not None: + _path_params['reflection_id'] = reflection_id # process the query parameters # process the header parameters if authorization is not None: @@ -2145,7 +1559,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='POST', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh', + resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -2162,11 +1576,12 @@ class MentalModelsApi: @validate_call - async def refresh_mental_models( + async def update_reflection( self, bank_id: StrictStr, + reflection_id: StrictStr, + update_reflection_request: UpdateReflectionRequest, authorization: Optional[StrictStr] = None, - refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2179,17 +1594,19 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> AsyncOperationSubmitResponse: - """Refresh mental models (async) + ) -> ReflectionResponse: + """Update reflection - Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress. + Update a reflection's name. :param bank_id: (required) :type bank_id: str + :param reflection_id: (required) + :type reflection_id: str + :param update_reflection_request: (required) + :type update_reflection_request: UpdateReflectionRequest :param authorization: :type authorization: str - :param refresh_mental_models_request: - :type refresh_mental_models_request: RefreshMentalModelsRequest :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -2212,10 +1629,11 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._refresh_mental_models_serialize( + _param = self._update_reflection_serialize( bank_id=bank_id, + reflection_id=reflection_id, + update_reflection_request=update_reflection_request, authorization=authorization, - refresh_mental_models_request=refresh_mental_models_request, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -2223,7 +1641,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2238,11 +1656,12 @@ class MentalModelsApi: @validate_call - async def refresh_mental_models_with_http_info( + async def update_reflection_with_http_info( self, bank_id: StrictStr, + reflection_id: StrictStr, + update_reflection_request: UpdateReflectionRequest, authorization: Optional[StrictStr] = None, - refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2255,17 +1674,19 @@ class MentalModelsApi: _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[AsyncOperationSubmitResponse]: - """Refresh mental models (async) + ) -> ApiResponse[ReflectionResponse]: + """Update reflection - Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress. + Update a reflection's name. :param bank_id: (required) :type bank_id: str + :param reflection_id: (required) + :type reflection_id: str + :param update_reflection_request: (required) + :type update_reflection_request: UpdateReflectionRequest :param authorization: :type authorization: str - :param refresh_mental_models_request: - :type refresh_mental_models_request: RefreshMentalModelsRequest :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of @@ -2288,10 +1709,11 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._refresh_mental_models_serialize( + _param = self._update_reflection_serialize( bank_id=bank_id, + reflection_id=reflection_id, + update_reflection_request=update_reflection_request, authorization=authorization, - refresh_mental_models_request=refresh_mental_models_request, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -2299,7 +1721,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2314,325 +1736,11 @@ class MentalModelsApi: @validate_call - async def refresh_mental_models_without_preload_content( + async def update_reflection_without_preload_content( self, bank_id: StrictStr, - authorization: Optional[StrictStr] = None, - refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """Refresh mental models (async) - - Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress. - - :param bank_id: (required) - :type bank_id: str - :param authorization: - :type authorization: str - :param refresh_mental_models_request: - :type refresh_mental_models_request: RefreshMentalModelsRequest - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._refresh_mental_models_serialize( - bank_id=bank_id, - authorization=authorization, - refresh_mental_models_request=refresh_mental_models_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _refresh_mental_models_serialize( - self, - bank_id, - authorization, - refresh_mental_models_request, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - if bank_id is not None: - _path_params['bank_id'] = bank_id - # process the query parameters - # process the header parameters - if authorization is not None: - _header_params['authorization'] = authorization - # process the form parameters - # process the body parameter - if refresh_mental_models_request is not None: - _body_params = refresh_mental_models_request - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - # set the HTTP header `Content-Type` - if _content_type: - _header_params['Content-Type'] = _content_type - else: - _default_content_type = ( - self.api_client.select_header_content_type( - [ - 'application/json' - ] - ) - ) - if _default_content_type is not None: - _header_params['Content-Type'] = _default_content_type - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='POST', - resource_path='/v1/default/banks/{bank_id}/mental-models/refresh', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - - - - @validate_call - async def update_mental_model( - self, - bank_id: StrictStr, - model_id: StrictStr, - update_mental_model_request: UpdateMentalModelRequest, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> MentalModelResponse: - """Update mental model - - Update a mental model's name and/or description. Useful for editing directives. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param update_mental_model_request: (required) - :type update_mental_model_request: UpdateMentalModelRequest - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._update_mental_model_serialize( - bank_id=bank_id, - model_id=model_id, - update_mental_model_request=update_mental_model_request, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ).data - - - @validate_call - async def update_mental_model_with_http_info( - self, - bank_id: StrictStr, - model_id: StrictStr, - update_mental_model_request: UpdateMentalModelRequest, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[MentalModelResponse]: - """Update mental model - - Update a mental model's name and/or description. Useful for editing directives. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param update_mental_model_request: (required) - :type update_mental_model_request: UpdateMentalModelRequest - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._update_mental_model_serialize( - bank_id=bank_id, - model_id=model_id, - update_mental_model_request=update_mental_model_request, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ) - - - @validate_call - async def update_mental_model_without_preload_content( - self, - bank_id: StrictStr, - model_id: StrictStr, - update_mental_model_request: UpdateMentalModelRequest, + reflection_id: StrictStr, + update_reflection_request: UpdateReflectionRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2647,16 +1755,16 @@ class MentalModelsApi: _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Update mental model + """Update reflection - Update a mental model's name and/or description. Useful for editing directives. + Update a reflection's name. :param bank_id: (required) :type bank_id: str - :param model_id: (required) - :type model_id: str - :param update_mental_model_request: (required) - :type update_mental_model_request: UpdateMentalModelRequest + :param reflection_id: (required) + :type reflection_id: str + :param update_reflection_request: (required) + :type update_reflection_request: UpdateReflectionRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2681,10 +1789,10 @@ class MentalModelsApi: :return: Returns the result object. """ # noqa: E501 - _param = self._update_mental_model_serialize( + _param = self._update_reflection_serialize( bank_id=bank_id, - model_id=model_id, - update_mental_model_request=update_mental_model_request, + reflection_id=reflection_id, + update_reflection_request=update_reflection_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2693,7 +1801,7 @@ class MentalModelsApi: ) _response_types_map: Dict[str, Optional[str]] = { - '200': "MentalModelResponse", + '200': "ReflectionResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2703,11 +1811,11 @@ class MentalModelsApi: return response_data.response - def _update_mental_model_serialize( + def _update_reflection_serialize( self, bank_id, - model_id, - update_mental_model_request, + reflection_id, + update_reflection_request, authorization, _request_auth, _content_type, @@ -2732,16 +1840,16 @@ class MentalModelsApi: # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id + if reflection_id is not None: + _path_params['reflection_id'] = reflection_id # process the query parameters # process the header parameters if authorization is not None: _header_params['authorization'] = authorization # process the form parameters # process the body parameter - if update_mental_model_request is not None: - _body_params = update_mental_model_request + if update_reflection_request is not None: + _body_params = update_reflection_request # set the HTTP header `Accept` @@ -2772,7 +1880,7 @@ class MentalModelsApi: return self.api_client.param_serialize( method='PATCH', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}', + resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}', path_params=_path_params, query_params=_query_params, header_params=_header_params, diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index e3cc2c5b..a6eebd19 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -15,7 +15,6 @@ # import models into model package from hindsight_client_api.models.add_background_request import AddBackgroundRequest -from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse from hindsight_client_api.models.background_response import BackgroundResponse from hindsight_client_api.models.bank_list_item import BankListItem from hindsight_client_api.models.bank_list_response import BankListResponse @@ -26,11 +25,15 @@ from hindsight_client_api.models.cancel_operation_response import CancelOperatio from hindsight_client_api.models.chunk_data import ChunkData from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions from hindsight_client_api.models.chunk_response import ChunkResponse +from hindsight_client_api.models.consolidation_response import ConsolidationResponse from hindsight_client_api.models.create_bank_request import CreateBankRequest -from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest -from hindsight_client_api.models.created_mental_model import CreatedMentalModel +from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest +from hindsight_client_api.models.create_reflection_request import CreateReflectionRequest +from hindsight_client_api.models.create_reflection_response import CreateReflectionResponse from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse from hindsight_client_api.models.delete_response import DeleteResponse +from hindsight_client_api.models.directive_list_response import DirectiveListResponse +from hindsight_client_api.models.directive_response import DirectiveResponse from hindsight_client_api.models.disposition_traits import DispositionTraits from hindsight_client_api.models.document_response import DocumentResponse from hindsight_client_api.models.entity_detail_response import EntityDetailResponse @@ -40,6 +43,7 @@ from hindsight_client_api.models.entity_list_item import EntityListItem from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse +from hindsight_client_api.models.features_info import FeaturesInfo from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.http_validation_error import HTTPValidationError from hindsight_client_api.models.include_options import IncludeOptions @@ -47,12 +51,6 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse from hindsight_client_api.models.memory_item import MemoryItem -from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse -from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse -from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse -from hindsight_client_api.models.mental_model_response import MentalModelResponse -from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse -from hindsight_client_api.models.observation_input import ObservationInput from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse from hindsight_client_api.models.operations_list_response import OperationsListResponse @@ -68,13 +66,16 @@ from hindsight_client_api.models.reflect_request import ReflectRequest from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.reflect_tool_call import ReflectToolCall from hindsight_client_api.models.reflect_trace import ReflectTrace -from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest +from hindsight_client_api.models.reflection_list_response import ReflectionListResponse +from hindsight_client_api.models.reflection_response import ReflectionResponse from hindsight_client_api.models.retain_request import RetainRequest from hindsight_client_api.models.retain_response import RetainResponse from hindsight_client_api.models.tag_item import TagItem from hindsight_client_api.models.token_usage import TokenUsage from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions +from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest -from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest +from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest from hindsight_client_api.models.validation_error import ValidationError from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner +from hindsight_client_api.models.version_response import VersionResponse diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py index c538ba07..bb0243bb 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py @@ -17,8 +17,8 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self @@ -36,7 +36,10 @@ class BankStatsResponse(BaseModel): links_breakdown: Dict[str, Dict[str, StrictInt]] pending_operations: StrictInt failed_operations: StrictInt - __properties: ClassVar[List[str]] = ["bank_id", "total_nodes", "total_links", "total_documents", "nodes_by_fact_type", "links_by_link_type", "links_by_fact_type", "links_breakdown", "pending_operations", "failed_operations"] + last_consolidated_at: Optional[StrictStr] = None + pending_consolidation: Optional[StrictInt] = Field(default=0, description="Number of memories not yet processed into mental models") + total_mental_models: Optional[StrictInt] = Field(default=0, description="Total number of mental models") + __properties: ClassVar[List[str]] = ["bank_id", "total_nodes", "total_links", "total_documents", "nodes_by_fact_type", "links_by_link_type", "links_by_fact_type", "links_breakdown", "pending_operations", "failed_operations", "last_consolidated_at", "pending_consolidation", "total_mental_models"] model_config = ConfigDict( populate_by_name=True, @@ -77,6 +80,11 @@ class BankStatsResponse(BaseModel): exclude=excluded_fields, exclude_none=True, ) + # set to None if last_consolidated_at (nullable) is None + # and model_fields_set contains the field + if self.last_consolidated_at is None and "last_consolidated_at" in self.model_fields_set: + _dict['last_consolidated_at'] = None + return _dict @classmethod @@ -98,7 +106,10 @@ class BankStatsResponse(BaseModel): "links_by_fact_type": obj.get("links_by_fact_type"), "links_breakdown": obj.get("links_breakdown"), "pending_operations": obj.get("pending_operations"), - "failed_operations": obj.get("failed_operations") + "failed_operations": obj.get("failed_operations"), + "last_consolidated_at": obj.get("last_consolidated_at"), + "pending_consolidation": obj.get("pending_consolidation") if obj.get("pending_consolidation") is not None else 0, + "total_mental_models": obj.get("total_mental_models") if obj.get("total_mental_models") is not None else 0 }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py similarity index 66% rename from hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py rename to hindsight-clients/python/hindsight_client_api/models/consolidation_response.py index e664abd6..e1f7c511 100644 --- a/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py @@ -17,20 +17,21 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List from typing import Optional, Set from typing_extensions import Self -class ObservationEvidenceResponse(BaseModel): +class ConsolidationResponse(BaseModel): """ - A single piece of evidence supporting an observation. + Response model for consolidation trigger endpoint. """ # noqa: E501 - memory_id: StrictStr = Field(description="ID of the memory unit this evidence comes from") - quote: StrictStr = Field(description="Exact quote from the memory supporting the observation") - relevance: StrictStr = Field(description="Brief explanation of how this quote supports the observation") - timestamp: StrictStr = Field(description="When the source memory was created (ISO format)") - __properties: ClassVar[List[str]] = ["memory_id", "quote", "relevance", "timestamp"] + status: StrictStr = Field(description="Status of the consolidation (completed or queued)") + processed: StrictInt = Field(description="Number of memories processed") + created: StrictInt = Field(description="Number of mental models created") + updated: StrictInt = Field(description="Number of mental models updated") + message: StrictStr = Field(description="Human-readable summary") + __properties: ClassVar[List[str]] = ["status", "processed", "created", "updated", "message"] model_config = ConfigDict( populate_by_name=True, @@ -50,7 +51,7 @@ class ObservationEvidenceResponse(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of ObservationEvidenceResponse from a JSON string""" + """Create an instance of ConsolidationResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -75,7 +76,7 @@ class ObservationEvidenceResponse(BaseModel): @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ObservationEvidenceResponse from a dict""" + """Create an instance of ConsolidationResponse from a dict""" if obj is None: return None @@ -83,10 +84,11 @@ class ObservationEvidenceResponse(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "memory_id": obj.get("memory_id"), - "quote": obj.get("quote"), - "relevance": obj.get("relevance"), - "timestamp": obj.get("timestamp") + "status": obj.get("status"), + "processed": obj.get("processed"), + "created": obj.get("created"), + "updated": obj.get("updated"), + "message": obj.get("message") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py new file mode 100644 index 00000000..813a8fa8 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class CreateDirectiveRequest(BaseModel): + """ + Request model for creating a directive. + """ # noqa: E501 + name: StrictStr = Field(description="Human-readable name for the directive") + content: StrictStr = Field(description="The directive text to inject into prompts") + priority: Optional[StrictInt] = Field(default=0, description="Higher priority directives are injected first") + is_active: Optional[StrictBool] = Field(default=True, description="Whether this directive is active") + tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for filtering") + __properties: ClassVar[List[str]] = ["name", "content", "priority", "is_active", "tags"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of CreateDirectiveRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of CreateDirectiveRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "name": obj.get("name"), + "content": obj.get("content"), + "priority": obj.get("priority") if obj.get("priority") is not None else 0, + "is_active": obj.get("is_active") if obj.get("is_active") is not None else True, + "tags": obj.get("tags") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py b/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py new file mode 100644 index 00000000..70817ab7 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py @@ -0,0 +1,94 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class CreateReflectionRequest(BaseModel): + """ + Request model for creating a reflection. + """ # noqa: E501 + name: StrictStr = Field(description="Human-readable name for the reflection") + source_query: StrictStr = Field(description="The query to run to generate content") + tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility") + max_tokens: Optional[Annotated[int, Field(le=8192, strict=True, ge=256)]] = Field(default=2048, description="Maximum tokens for generated content") + __properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of CreateReflectionRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of CreateReflectionRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "name": obj.get("name"), + "source_query": obj.get("source_query"), + "tags": obj.get("tags"), + "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 2048 + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_input.py b/hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py similarity index 80% rename from hindsight-clients/python/hindsight_client_api/models/observation_input.py rename to hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py index 17fe584d..a86a621b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/observation_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py @@ -22,13 +22,12 @@ from typing import Any, ClassVar, Dict, List from typing import Optional, Set from typing_extensions import Self -class ObservationInput(BaseModel): +class CreateReflectionResponse(BaseModel): """ - Input model for a single observation. + Response model for reflection creation. """ # noqa: E501 - title: StrictStr = Field(description="Short title/header for the observation") - content: StrictStr = Field(description="Content of the observation") - __properties: ClassVar[List[str]] = ["title", "content"] + operation_id: StrictStr = Field(description="Operation ID to track progress") + __properties: ClassVar[List[str]] = ["operation_id"] model_config = ConfigDict( populate_by_name=True, @@ -48,7 +47,7 @@ class ObservationInput(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of ObservationInput from a JSON string""" + """Create an instance of CreateReflectionResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -73,7 +72,7 @@ class ObservationInput(BaseModel): @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ObservationInput from a dict""" + """Create an instance of CreateReflectionResponse from a dict""" if obj is None: return None @@ -81,8 +80,7 @@ class ObservationInput(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "title": obj.get("title"), - "content": obj.get("content") + "operation_id": obj.get("operation_id") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py similarity index 83% rename from hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py rename to hindsight-clients/python/hindsight_client_api/models/directive_list_response.py index af4c7161..84c1467d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py @@ -19,15 +19,15 @@ import json from pydantic import BaseModel, ConfigDict from typing import Any, ClassVar, Dict, List -from hindsight_client_api.models.mental_model_response import MentalModelResponse +from hindsight_client_api.models.directive_response import DirectiveResponse from typing import Optional, Set from typing_extensions import Self -class MentalModelListResponse(BaseModel): +class DirectiveListResponse(BaseModel): """ - Response model for listing mental models. + Response model for listing directives. """ # noqa: E501 - items: List[MentalModelResponse] + items: List[DirectiveResponse] __properties: ClassVar[List[str]] = ["items"] model_config = ConfigDict( @@ -48,7 +48,7 @@ class MentalModelListResponse(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of MentalModelListResponse from a JSON string""" + """Create an instance of DirectiveListResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -80,7 +80,7 @@ class MentalModelListResponse(BaseModel): @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of MentalModelListResponse from a dict""" + """Create an instance of DirectiveListResponse from a dict""" if obj is None: return None @@ -88,7 +88,7 @@ class MentalModelListResponse(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "items": [MentalModelResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None + "items": [DirectiveResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py b/hindsight-clients/python/hindsight_client_api/models/directive_response.py similarity index 60% rename from hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py rename to hindsight-clients/python/hindsight_client_api/models/directive_response.py index 87127ad2..c6ec0ef1 100644 --- a/hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/directive_response.py @@ -17,28 +17,25 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, StrictStr, field_validator +from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self -class RefreshMentalModelsRequest(BaseModel): +class DirectiveResponse(BaseModel): """ - Request model for refresh mental models endpoint. + Response model for a directive. """ # noqa: E501 + id: StrictStr + bank_id: StrictStr + name: StrictStr + content: StrictStr + priority: Optional[StrictInt] = 0 + is_active: Optional[StrictBool] = True tags: Optional[List[StrictStr]] = None - subtype: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["tags", "subtype"] - - @field_validator('subtype') - def subtype_validate_enum(cls, value): - """Validates the enum""" - if value is None: - return value - - if value not in set(['structural', 'emergent', 'pinned', 'learned']): - raise ValueError("must be one of enum values ('structural', 'emergent', 'pinned', 'learned')") - return value + created_at: Optional[StrictStr] = None + updated_at: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["id", "bank_id", "name", "content", "priority", "is_active", "tags", "created_at", "updated_at"] model_config = ConfigDict( populate_by_name=True, @@ -58,7 +55,7 @@ class RefreshMentalModelsRequest(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of RefreshMentalModelsRequest from a JSON string""" + """Create an instance of DirectiveResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -79,21 +76,21 @@ class RefreshMentalModelsRequest(BaseModel): exclude=excluded_fields, exclude_none=True, ) - # set to None if tags (nullable) is None + # set to None if created_at (nullable) is None # and model_fields_set contains the field - if self.tags is None and "tags" in self.model_fields_set: - _dict['tags'] = None + if self.created_at is None and "created_at" in self.model_fields_set: + _dict['created_at'] = None - # set to None if subtype (nullable) is None + # set to None if updated_at (nullable) is None # and model_fields_set contains the field - if self.subtype is None and "subtype" in self.model_fields_set: - _dict['subtype'] = None + if self.updated_at is None and "updated_at" in self.model_fields_set: + _dict['updated_at'] = None return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RefreshMentalModelsRequest from a dict""" + """Create an instance of DirectiveResponse from a dict""" if obj is None: return None @@ -101,8 +98,15 @@ class RefreshMentalModelsRequest(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ + "id": obj.get("id"), + "bank_id": obj.get("bank_id"), + "name": obj.get("name"), + "content": obj.get("content"), + "priority": obj.get("priority") if obj.get("priority") is not None else 0, + "is_active": obj.get("is_active") if obj.get("is_active") is not None else True, "tags": obj.get("tags"), - "subtype": obj.get("subtype") + "created_at": obj.get("created_at"), + "updated_at": obj.get("updated_at") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py similarity index 72% rename from hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py rename to hindsight-clients/python/hindsight_client_api/models/features_info.py index 420d1e9e..c98d722f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -17,18 +17,19 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictBool from typing import Any, ClassVar, Dict, List from typing import Optional, Set from typing_extensions import Self -class AsyncOperationSubmitResponse(BaseModel): +class FeaturesInfo(BaseModel): """ - Response model for submitting an async operation. + Feature flags indicating which capabilities are enabled. """ # noqa: E501 - operation_id: StrictStr - status: StrictStr - __properties: ClassVar[List[str]] = ["operation_id", "status"] + mental_models: StrictBool = Field(description="Whether mental models (auto-consolidation) are enabled") + mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled") + worker: StrictBool = Field(description="Whether the background worker is enabled") + __properties: ClassVar[List[str]] = ["mental_models", "mcp", "worker"] model_config = ConfigDict( populate_by_name=True, @@ -48,7 +49,7 @@ class AsyncOperationSubmitResponse(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of AsyncOperationSubmitResponse from a JSON string""" + """Create an instance of FeaturesInfo from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -73,7 +74,7 @@ class AsyncOperationSubmitResponse(BaseModel): @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of AsyncOperationSubmitResponse from a dict""" + """Create an instance of FeaturesInfo from a dict""" if obj is None: return None @@ -81,8 +82,9 @@ class AsyncOperationSubmitResponse(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "operation_id": obj.get("operation_id"), - "status": obj.get("status") + "mental_models": obj.get("mental_models"), + "mcp": obj.get("mcp"), + "worker": obj.get("worker") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py deleted file mode 100644 index 4c013adc..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py +++ /dev/null @@ -1,107 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 0.1.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse -from typing import Optional, Set -from typing_extensions import Self - -class MentalModelObservationResponse(BaseModel): - """ - An observation within a mental model with its supporting evidence. - """ # noqa: E501 - title: StrictStr = Field(description="Short summary title for the observation") - content: StrictStr = Field(description="The observation content - detailed explanation") - evidence: Optional[List[ObservationEvidenceResponse]] = Field(default=None, description="Supporting evidence with quotes") - created_at: StrictStr = Field(description="When this observation was first created (ISO format)") - trend: StrictStr = Field(description="Computed trend: stable, strengthening, weakening, new, stale") - evidence_count: StrictInt = Field(description="Number of evidence items supporting this observation") - evidence_span: Dict[str, Any] = Field(description="Time span of evidence: {from: iso_date, to: iso_date}") - __properties: ClassVar[List[str]] = ["title", "content", "evidence", "created_at", "trend", "evidence_count", "evidence_span"] - - model_config = ConfigDict( - populate_by_name=True, - validate_assignment=True, - protected_namespaces=(), - ) - - - def to_str(self) -> str: - """Returns the string representation of the model using alias""" - return pprint.pformat(self.model_dump(by_alias=True)) - - def to_json(self) -> str: - """Returns the JSON representation of the model using alias""" - # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead - return json.dumps(self.to_dict()) - - @classmethod - def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of MentalModelObservationResponse from a JSON string""" - return cls.from_dict(json.loads(json_str)) - - def to_dict(self) -> Dict[str, Any]: - """Return the dictionary representation of the model using alias. - - This has the following differences from calling pydantic's - `self.model_dump(by_alias=True)`: - - * `None` is only added to the output dict for nullable fields that - were set at model initialization. Other fields with value `None` - are ignored. - """ - excluded_fields: Set[str] = set([ - ]) - - _dict = self.model_dump( - by_alias=True, - exclude=excluded_fields, - exclude_none=True, - ) - # override the default output from pydantic by calling `to_dict()` of each item in evidence (list) - _items = [] - if self.evidence: - for _item_evidence in self.evidence: - if _item_evidence: - _items.append(_item_evidence.to_dict()) - _dict['evidence'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of MentalModelObservationResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "title": obj.get("title"), - "content": obj.get("content"), - "evidence": [ObservationEvidenceResponse.from_dict(_item) for _item in obj["evidence"]] if obj.get("evidence") is not None else None, - "created_at": obj.get("created_at"), - "trend": obj.get("trend"), - "evidence_count": obj.get("evidence_count"), - "evidence_span": obj.get("evidence_span") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py deleted file mode 100644 index 790fcad4..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py +++ /dev/null @@ -1,145 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 0.1.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse -from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse -from typing import Optional, Set -from typing_extensions import Self - -class MentalModelResponse(BaseModel): - """ - Response model for a mental model. - """ # noqa: E501 - id: StrictStr - bank_id: StrictStr - subtype: StrictStr - name: StrictStr - description: StrictStr - observations: Optional[List[MentalModelObservationResponse]] = Field(default=None, description="Structured observations with per-observation fact attribution") - version: Optional[StrictInt] = Field(default=0, description="Version number of the mental model observations") - entity_id: Optional[StrictStr] = None - links: Optional[List[StrictStr]] = None - tags: Optional[List[StrictStr]] = None - last_updated: Optional[StrictStr] = None - last_refresh_at: Optional[StrictStr] = None - freshness: Optional[MentalModelFreshnessResponse] = None - created_at: StrictStr - __properties: ClassVar[List[str]] = ["id", "bank_id", "subtype", "name", "description", "observations", "version", "entity_id", "links", "tags", "last_updated", "last_refresh_at", "freshness", "created_at"] - - model_config = ConfigDict( - populate_by_name=True, - validate_assignment=True, - protected_namespaces=(), - ) - - - def to_str(self) -> str: - """Returns the string representation of the model using alias""" - return pprint.pformat(self.model_dump(by_alias=True)) - - def to_json(self) -> str: - """Returns the JSON representation of the model using alias""" - # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead - return json.dumps(self.to_dict()) - - @classmethod - def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of MentalModelResponse from a JSON string""" - return cls.from_dict(json.loads(json_str)) - - def to_dict(self) -> Dict[str, Any]: - """Return the dictionary representation of the model using alias. - - This has the following differences from calling pydantic's - `self.model_dump(by_alias=True)`: - - * `None` is only added to the output dict for nullable fields that - were set at model initialization. Other fields with value `None` - are ignored. - """ - excluded_fields: Set[str] = set([ - ]) - - _dict = self.model_dump( - by_alias=True, - exclude=excluded_fields, - exclude_none=True, - ) - # override the default output from pydantic by calling `to_dict()` of each item in observations (list) - _items = [] - if self.observations: - for _item_observations in self.observations: - if _item_observations: - _items.append(_item_observations.to_dict()) - _dict['observations'] = _items - # override the default output from pydantic by calling `to_dict()` of freshness - if self.freshness: - _dict['freshness'] = self.freshness.to_dict() - # set to None if entity_id (nullable) is None - # and model_fields_set contains the field - if self.entity_id is None and "entity_id" in self.model_fields_set: - _dict['entity_id'] = None - - # set to None if last_updated (nullable) is None - # and model_fields_set contains the field - if self.last_updated is None and "last_updated" in self.model_fields_set: - _dict['last_updated'] = None - - # set to None if last_refresh_at (nullable) is None - # and model_fields_set contains the field - if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set: - _dict['last_refresh_at'] = None - - # set to None if freshness (nullable) is None - # and model_fields_set contains the field - if self.freshness is None and "freshness" in self.model_fields_set: - _dict['freshness'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of MentalModelResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "bank_id": obj.get("bank_id"), - "subtype": obj.get("subtype"), - "name": obj.get("name"), - "description": obj.get("description"), - "observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None, - "version": obj.get("version") if obj.get("version") is not None else 0, - "entity_id": obj.get("entity_id"), - "links": obj.get("links"), - "tags": obj.get("tags"), - "last_updated": obj.get("last_updated"), - "last_refresh_at": obj.get("last_refresh_at"), - "freshness": MentalModelFreshnessResponse.from_dict(obj["freshness"]) if obj.get("freshness") is not None else None, - "created_at": obj.get("created_at") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py index 1a691898..56de4a9d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py @@ -29,8 +29,10 @@ class OperationsListResponse(BaseModel): """ # noqa: E501 bank_id: StrictStr total: StrictInt + limit: StrictInt + offset: StrictInt operations: List[OperationResponse] - __properties: ClassVar[List[str]] = ["bank_id", "total", "operations"] + __properties: ClassVar[List[str]] = ["bank_id", "total", "limit", "offset", "operations"] model_config = ConfigDict( populate_by_name=True, @@ -92,6 +94,8 @@ class OperationsListResponse(BaseModel): _obj = cls.model_validate({ "bank_id": obj.get("bank_id"), "total": obj.get("total"), + "limit": obj.get("limit"), + "offset": obj.get("offset"), "operations": [OperationResponse.from_dict(_item) for _item in obj["operations"]] if obj.get("operations") is not None else None }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py index 5a5143b1..a85dd282 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py @@ -20,7 +20,6 @@ import json from pydantic import BaseModel, ConfigDict, Field from typing import Any, ClassVar, Dict, List, Optional from hindsight_client_api.models.reflect_fact import ReflectFact -from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel from typing import Optional, Set from typing_extensions import Self @@ -29,8 +28,7 @@ class ReflectBasedOn(BaseModel): Evidence the response is based on: memories and mental models. """ # noqa: E501 memories: Optional[List[ReflectFact]] = Field(default=None, description="Memory facts used to generate the response") - mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models accessed during reflection") - __properties: ClassVar[List[str]] = ["memories", "mental_models"] + __properties: ClassVar[List[str]] = ["memories"] model_config = ConfigDict( populate_by_name=True, @@ -78,13 +76,6 @@ class ReflectBasedOn(BaseModel): if _item_memories: _items.append(_item_memories.to_dict()) _dict['memories'] = _items - # override the default output from pydantic by calling `to_dict()` of each item in mental_models (list) - _items = [] - if self.mental_models: - for _item_mental_models in self.mental_models: - if _item_mental_models: - _items.append(_item_mental_models.to_dict()) - _dict['mental_models'] = _items return _dict @classmethod @@ -97,8 +88,7 @@ class ReflectBasedOn(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None, - "mental_models": [ReflectMentalModel.from_dict(_item) for _item in obj["mental_models"]] if obj.get("mental_models") is not None else None + "memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py index bb0c0003..889acc44 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py @@ -17,9 +17,8 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, StrictStr from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.created_mental_model import CreatedMentalModel from hindsight_client_api.models.reflect_based_on import ReflectBasedOn from hindsight_client_api.models.reflect_trace import ReflectTrace from hindsight_client_api.models.token_usage import TokenUsage @@ -35,8 +34,7 @@ class ReflectResponse(BaseModel): structured_output: Optional[Dict[str, Any]] = None usage: Optional[TokenUsage] = None trace: Optional[ReflectTrace] = None - mental_models_created: Optional[List[CreatedMentalModel]] = Field(default=None, description="Mental models created during this reflection (via the learn tool).") - __properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage", "trace", "mental_models_created"] + __properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage", "trace"] model_config = ConfigDict( populate_by_name=True, @@ -86,13 +84,6 @@ class ReflectResponse(BaseModel): # override the default output from pydantic by calling `to_dict()` of trace if self.trace: _dict['trace'] = self.trace.to_dict() - # override the default output from pydantic by calling `to_dict()` of each item in mental_models_created (list) - _items = [] - if self.mental_models_created: - for _item_mental_models_created in self.mental_models_created: - if _item_mental_models_created: - _items.append(_item_mental_models_created.to_dict()) - _dict['mental_models_created'] = _items # set to None if based_on (nullable) is None # and model_fields_set contains the field if self.based_on is None and "based_on" in self.model_fields_set: @@ -129,8 +120,7 @@ class ReflectResponse(BaseModel): "based_on": ReflectBasedOn.from_dict(obj["based_on"]) if obj.get("based_on") is not None else None, "structured_output": obj.get("structured_output"), "usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None, - "trace": ReflectTrace.from_dict(obj["trace"]) if obj.get("trace") is not None else None, - "mental_models_created": [CreatedMentalModel.from_dict(_item) for _item in obj["mental_models_created"]] if obj.get("mental_models_created") is not None else None + "trace": ReflectTrace.from_dict(obj["trace"]) if obj.get("trace") is not None else None }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py b/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py new file mode 100644 index 00000000..bd65796c --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict +from typing import Any, ClassVar, Dict, List +from hindsight_client_api.models.reflection_response import ReflectionResponse +from typing import Optional, Set +from typing_extensions import Self + +class ReflectionListResponse(BaseModel): + """ + Response model for listing reflections. + """ # noqa: E501 + items: List[ReflectionResponse] + __properties: ClassVar[List[str]] = ["items"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ReflectionListResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in items (list) + _items = [] + if self.items: + for _item_items in self.items: + if _item_items: + _items.append(_item_items.to_dict()) + _dict['items'] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ReflectionListResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "items": [ReflectionResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py b/hindsight-clients/python/hindsight_client_api/models/reflection_response.py similarity index 53% rename from hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py rename to hindsight-clients/python/hindsight_client_api/models/reflection_response.py index cda3661f..4632c7c5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflection_response.py @@ -17,20 +17,25 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr +from pydantic import BaseModel, ConfigDict, StrictStr from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self -class MentalModelFreshnessResponse(BaseModel): +class ReflectionResponse(BaseModel): """ - Freshness information for a mental model. + Response model for a reflection. """ # noqa: E501 - is_up_to_date: StrictBool = Field(description="Whether the model has been refreshed since the last memory was added") - last_refresh_at: Optional[StrictStr] - memories_since_refresh: StrictInt = Field(description="Number of memories added since last refresh") - reasons: Optional[List[StrictStr]] = Field(default=None, description="Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed") - __properties: ClassVar[List[str]] = ["is_up_to_date", "last_refresh_at", "memories_since_refresh", "reasons"] + id: StrictStr + bank_id: StrictStr + name: StrictStr + source_query: StrictStr + content: StrictStr + tags: Optional[List[StrictStr]] = None + last_refreshed_at: Optional[StrictStr] = None + created_at: Optional[StrictStr] = None + reflect_response: Optional[Dict[str, Any]] = None + __properties: ClassVar[List[str]] = ["id", "bank_id", "name", "source_query", "content", "tags", "last_refreshed_at", "created_at", "reflect_response"] model_config = ConfigDict( populate_by_name=True, @@ -50,7 +55,7 @@ class MentalModelFreshnessResponse(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of MentalModelFreshnessResponse from a JSON string""" + """Create an instance of ReflectionResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -71,16 +76,26 @@ class MentalModelFreshnessResponse(BaseModel): exclude=excluded_fields, exclude_none=True, ) - # set to None if last_refresh_at (nullable) is None + # set to None if last_refreshed_at (nullable) is None # and model_fields_set contains the field - if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set: - _dict['last_refresh_at'] = None + if self.last_refreshed_at is None and "last_refreshed_at" in self.model_fields_set: + _dict['last_refreshed_at'] = None + + # set to None if created_at (nullable) is None + # and model_fields_set contains the field + if self.created_at is None and "created_at" in self.model_fields_set: + _dict['created_at'] = None + + # set to None if reflect_response (nullable) is None + # and model_fields_set contains the field + if self.reflect_response is None and "reflect_response" in self.model_fields_set: + _dict['reflect_response'] = None return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of MentalModelFreshnessResponse from a dict""" + """Create an instance of ReflectionResponse from a dict""" if obj is None: return None @@ -88,10 +103,15 @@ class MentalModelFreshnessResponse(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "is_up_to_date": obj.get("is_up_to_date"), - "last_refresh_at": obj.get("last_refresh_at"), - "memories_since_refresh": obj.get("memories_since_refresh"), - "reasons": obj.get("reasons") + "id": obj.get("id"), + "bank_id": obj.get("bank_id"), + "name": obj.get("name"), + "source_query": obj.get("source_query"), + "content": obj.get("content"), + "tags": obj.get("tags"), + "last_refreshed_at": obj.get("last_refreshed_at"), + "created_at": obj.get("created_at"), + "reflect_response": obj.get("reflect_response") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py similarity index 54% rename from hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py rename to hindsight-clients/python/hindsight_client_api/models/update_directive_request.py index 67032696..265102cf 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py @@ -17,22 +17,21 @@ import pprint import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.observation_input import ObservationInput from typing import Optional, Set from typing_extensions import Self -class CreateMentalModelRequest(BaseModel): +class UpdateDirectiveRequest(BaseModel): """ - Request model for creating a mental model. + Request model for updating a directive. """ # noqa: E501 - name: StrictStr = Field(description="Human-readable name for the mental model") - description: StrictStr = Field(description="One-liner description for quick scanning") - subtype: Optional[StrictStr] = Field(default='pinned', description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)") - observations: Optional[List[ObservationInput]] = None - tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility") - __properties: ClassVar[List[str]] = ["name", "description", "subtype", "observations", "tags"] + name: Optional[StrictStr] = None + content: Optional[StrictStr] = None + priority: Optional[StrictInt] = None + is_active: Optional[StrictBool] = None + tags: Optional[List[StrictStr]] = None + __properties: ClassVar[List[str]] = ["name", "content", "priority", "is_active", "tags"] model_config = ConfigDict( populate_by_name=True, @@ -52,7 +51,7 @@ class CreateMentalModelRequest(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of CreateMentalModelRequest from a JSON string""" + """Create an instance of UpdateDirectiveRequest from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -73,23 +72,36 @@ class CreateMentalModelRequest(BaseModel): exclude=excluded_fields, exclude_none=True, ) - # override the default output from pydantic by calling `to_dict()` of each item in observations (list) - _items = [] - if self.observations: - for _item_observations in self.observations: - if _item_observations: - _items.append(_item_observations.to_dict()) - _dict['observations'] = _items - # set to None if observations (nullable) is None + # set to None if name (nullable) is None # and model_fields_set contains the field - if self.observations is None and "observations" in self.model_fields_set: - _dict['observations'] = None + if self.name is None and "name" in self.model_fields_set: + _dict['name'] = None + + # set to None if content (nullable) is None + # and model_fields_set contains the field + if self.content is None and "content" in self.model_fields_set: + _dict['content'] = None + + # set to None if priority (nullable) is None + # and model_fields_set contains the field + if self.priority is None and "priority" in self.model_fields_set: + _dict['priority'] = None + + # set to None if is_active (nullable) is None + # and model_fields_set contains the field + if self.is_active is None and "is_active" in self.model_fields_set: + _dict['is_active'] = None + + # set to None if tags (nullable) is None + # and model_fields_set contains the field + if self.tags is None and "tags" in self.model_fields_set: + _dict['tags'] = None return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of CreateMentalModelRequest from a dict""" + """Create an instance of UpdateDirectiveRequest from a dict""" if obj is None: return None @@ -98,9 +110,9 @@ class CreateMentalModelRequest(BaseModel): _obj = cls.model_validate({ "name": obj.get("name"), - "description": obj.get("description"), - "subtype": obj.get("subtype") if obj.get("subtype") is not None else 'pinned', - "observations": [ObservationInput.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None, + "content": obj.get("content"), + "priority": obj.get("priority"), + "is_active": obj.get("is_active"), "tags": obj.get("tags") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py similarity index 77% rename from hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py rename to hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py index ea0596ab..9017521b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py @@ -22,13 +22,12 @@ from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self -class UpdateMentalModelRequest(BaseModel): +class UpdateReflectionRequest(BaseModel): """ - Request model for updating a mental model. + Request model for updating a reflection. """ # noqa: E501 name: Optional[StrictStr] = None - description: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["name", "description"] + __properties: ClassVar[List[str]] = ["name"] model_config = ConfigDict( populate_by_name=True, @@ -48,7 +47,7 @@ class UpdateMentalModelRequest(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of UpdateMentalModelRequest from a JSON string""" + """Create an instance of UpdateReflectionRequest from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -74,16 +73,11 @@ class UpdateMentalModelRequest(BaseModel): if self.name is None and "name" in self.model_fields_set: _dict['name'] = None - # set to None if description (nullable) is None - # and model_fields_set contains the field - if self.description is None and "description" in self.model_fields_set: - _dict['description'] = None - return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of UpdateMentalModelRequest from a dict""" + """Create an instance of UpdateReflectionRequest from a dict""" if obj is None: return None @@ -91,8 +85,7 @@ class UpdateMentalModelRequest(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "name": obj.get("name"), - "description": obj.get("description") + "name": obj.get("name") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/created_mental_model.py b/hindsight-clients/python/hindsight_client_api/models/version_response.py similarity index 71% rename from hindsight-clients/python/hindsight_client_api/models/created_mental_model.py rename to hindsight-clients/python/hindsight_client_api/models/version_response.py index abc1fafe..68e40187 100644 --- a/hindsight-clients/python/hindsight_client_api/models/created_mental_model.py +++ b/hindsight-clients/python/hindsight_client_api/models/version_response.py @@ -19,17 +19,17 @@ import json from pydantic import BaseModel, ConfigDict, Field, StrictStr from typing import Any, ClassVar, Dict, List +from hindsight_client_api.models.features_info import FeaturesInfo from typing import Optional, Set from typing_extensions import Self -class CreatedMentalModel(BaseModel): +class VersionResponse(BaseModel): """ - A mental model created during reflection. + Response model for the version/info endpoint. """ # noqa: E501 - id: StrictStr = Field(description="Mental model ID") - name: StrictStr = Field(description="Human-readable name") - description: StrictStr = Field(description="What this model tracks") - __properties: ClassVar[List[str]] = ["id", "name", "description"] + api_version: StrictStr = Field(description="API version string") + features: FeaturesInfo = Field(description="Enabled feature flags") + __properties: ClassVar[List[str]] = ["api_version", "features"] model_config = ConfigDict( populate_by_name=True, @@ -49,7 +49,7 @@ class CreatedMentalModel(BaseModel): @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of CreatedMentalModel from a JSON string""" + """Create an instance of VersionResponse from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -70,11 +70,14 @@ class CreatedMentalModel(BaseModel): exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of features + if self.features: + _dict['features'] = self.features.to_dict() return _dict @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of CreatedMentalModel from a dict""" + """Create an instance of VersionResponse from a dict""" if obj is None: return None @@ -82,9 +85,8 @@ class CreatedMentalModel(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "id": obj.get("id"), - "name": obj.get("name"), - "description": obj.get("description") + "api_version": obj.get("api_version"), + "features": FeaturesInfo.from_dict(obj["features"]) if obj.get("features") is not None else None }) return _obj diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py index e728dd99..6dc86cf5 100644 --- a/hindsight-clients/python/tests/test_main_operations.py +++ b/hindsight-clients/python/tests/test_main_operations.py @@ -546,8 +546,8 @@ class TestDeleteBank: assert memories.total == 0 -class TestMentalModels: - """Tests for mental model operations.""" +class TestMission: + """Tests for mission operations.""" def test_set_mission(self, client, bank_id): """Test setting a bank's mission.""" @@ -559,190 +559,3 @@ class TestMentalModels: assert response is not None assert response.bank_id == bank_id assert response.mission == "Be a helpful PM tracking sprint progress and team capacity" - - def test_create_pinned_mental_model(self, client, bank_id): - """Test creating a pinned mental model.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - response = client.create_mental_model( - bank_id=bank_id, - name="Product Roadmap", - description="Track product priorities and feature decisions", - subtype="pinned", - tags=["test"], - ) - - assert response is not None - assert response.name == "Product Roadmap" - assert response.description == "Track product priorities and feature decisions" - assert response.subtype == "pinned" - - def test_create_directive_mental_model(self, client, bank_id): - """Test creating a directive mental model with observations.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - response = client.create_mental_model( - bank_id=bank_id, - name="Response Guidelines", - description="Rules for responding to users", - subtype="directive", - observations=[ - {"title": "Always be polite", "content": "All responses must be courteous and professional"}, - {"title": "Never share private info", "content": "Do not reveal internal details or user data"}, - ], - tags=["test"], - ) - - assert response is not None - assert response.name == "Response Guidelines" - assert response.subtype == "directive" - assert response.observations is not None - assert len(response.observations) == 2 - - def test_list_mental_models(self, client, bank_id): - """Test listing mental models.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - client.create_mental_model( - bank_id=bank_id, - name="Test Model", - description="A test mental model", - subtype="pinned", - ) - - response = client.list_mental_models(bank_id=bank_id) - - assert response is not None - assert response.items is not None - assert len(response.items) >= 1 - - def test_get_mental_model(self, client, bank_id): - """Test getting a specific mental model.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - created = client.create_mental_model( - bank_id=bank_id, - name="Retrieve Test Model", - description="A model to retrieve", - subtype="pinned", - ) - - response = client.get_mental_model( - bank_id=bank_id, - model_id=created.id, - ) - - assert response is not None - assert response.id == created.id - assert response.name == "Retrieve Test Model" - - def test_update_mental_model(self, client, bank_id): - """Test updating a mental model.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - created = client.create_mental_model( - bank_id=bank_id, - name="Update Test Model", - description="Original description", - subtype="pinned", - ) - - response = client.update_mental_model( - bank_id=bank_id, - model_id=created.id, - name="Updated Model Name", - description="Updated description", - ) - - assert response is not None - assert response.name == "Updated Model Name" - assert response.description == "Updated description" - - def test_delete_mental_model(self, client, bank_id): - """Test deleting a mental model.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - created = client.create_mental_model( - bank_id=bank_id, - name="Delete Test Model", - description="A model to delete", - subtype="pinned", - ) - - response = client.delete_mental_model( - bank_id=bank_id, - model_id=created.id, - ) - - assert response is not None - assert response.success is True - - def test_refresh_mental_models(self, client, bank_id): - """Test refreshing all mental models (async operation).""" - # Set mission first (required for refresh) - this also creates the bank - client.set_mission( - bank_id=bank_id, - mission="Track team progress and decisions", - ) - - response = client.refresh_mental_models( - bank_id=bank_id, - tags=["test"], - ) - - assert response is not None - assert response.operation_id is not None - assert response.status == "queued" - - def test_refresh_mental_model(self, client, bank_id): - """Test refreshing a single mental model (async operation).""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - created = client.create_mental_model( - bank_id=bank_id, - name="Refresh Single Test", - description="A model to refresh individually", - subtype="pinned", - ) - - response = client.refresh_mental_model( - bank_id=bank_id, - model_id=created.id, - ) - - assert response is not None - assert response.operation_id is not None - assert response.status == "queued" - - def test_list_mental_model_versions(self, client, bank_id): - """Test listing mental model versions.""" - # Create bank first (required for mental models) - client.create_bank(bank_id=bank_id) - - # Create a model first - created = client.create_mental_model( - bank_id=bank_id, - name="Versions Test Model", - description="A model to test version history", - subtype="pinned", - ) - - response = client.list_mental_model_versions( - bank_id=bank_id, - model_id=created.id, - ) - - # Newly created model should have version history - assert response is not None diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 82b112f4..d3b83066 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -12,21 +12,30 @@ import type { ClearBankMemoriesData, ClearBankMemoriesErrors, ClearBankMemoriesResponses, - CreateMentalModelData, - CreateMentalModelErrors, - CreateMentalModelResponses, + ClearMentalModelsData, + ClearMentalModelsErrors, + ClearMentalModelsResponses, + CreateDirectiveData, + CreateDirectiveErrors, + CreateDirectiveResponses, CreateOrUpdateBankData, CreateOrUpdateBankErrors, CreateOrUpdateBankResponses, + CreateReflectionData, + CreateReflectionErrors, + CreateReflectionResponses, DeleteBankData, DeleteBankErrors, DeleteBankResponses, + DeleteDirectiveData, + DeleteDirectiveErrors, + DeleteDirectiveResponses, DeleteDocumentData, DeleteDocumentErrors, DeleteDocumentResponses, - DeleteMentalModelData, - DeleteMentalModelErrors, - DeleteMentalModelResponses, + DeleteReflectionData, + DeleteReflectionErrors, + DeleteReflectionResponses, GetAgentStatsData, GetAgentStatsErrors, GetAgentStatsResponses, @@ -36,6 +45,9 @@ import type { GetChunkData, GetChunkErrors, GetChunkResponses, + GetDirectiveData, + GetDirectiveErrors, + GetDirectiveResponses, GetDocumentData, GetDocumentErrors, GetDocumentResponses, @@ -48,20 +60,22 @@ import type { GetMemoryData, GetMemoryErrors, GetMemoryResponses, - GetMentalModelData, - GetMentalModelErrors, - GetMentalModelResponses, - GetMentalModelVersionData, - GetMentalModelVersionErrors, - GetMentalModelVersionResponses, GetOperationStatusData, GetOperationStatusErrors, GetOperationStatusResponses, + GetReflectionData, + GetReflectionErrors, + GetReflectionResponses, + GetVersionData, + GetVersionResponses, HealthEndpointHealthGetData, HealthEndpointHealthGetResponses, ListBanksData, ListBanksErrors, ListBanksResponses, + ListDirectivesData, + ListDirectivesErrors, + ListDirectivesResponses, ListDocumentsData, ListDocumentsErrors, ListDocumentsResponses, @@ -71,15 +85,12 @@ import type { ListMemoriesData, ListMemoriesErrors, ListMemoriesResponses, - ListMentalModelsData, - ListMentalModelsErrors, - ListMentalModelsResponses, - ListMentalModelVersionsData, - ListMentalModelVersionsErrors, - ListMentalModelVersionsResponses, ListOperationsData, ListOperationsErrors, ListOperationsResponses, + ListReflectionsData, + ListReflectionsErrors, + ListReflectionsResponses, ListTagsData, ListTagsErrors, ListTagsResponses, @@ -91,27 +102,30 @@ import type { ReflectData, ReflectErrors, ReflectResponses, - RefreshMentalModelData, - RefreshMentalModelErrors, - RefreshMentalModelResponses, - RefreshMentalModelsData, - RefreshMentalModelsErrors, - RefreshMentalModelsResponses, + RefreshReflectionData, + RefreshReflectionErrors, + RefreshReflectionResponses, RegenerateEntityObservationsData, RegenerateEntityObservationsErrors, RegenerateEntityObservationsResponses, RetainMemoriesData, RetainMemoriesErrors, RetainMemoriesResponses, + TriggerConsolidationData, + TriggerConsolidationErrors, + TriggerConsolidationResponses, UpdateBankData, UpdateBankDispositionData, UpdateBankDispositionErrors, UpdateBankDispositionResponses, UpdateBankErrors, UpdateBankResponses, - UpdateMentalModelData, - UpdateMentalModelErrors, - UpdateMentalModelResponses, + UpdateDirectiveData, + UpdateDirectiveErrors, + UpdateDirectiveResponses, + UpdateReflectionData, + UpdateReflectionErrors, + UpdateReflectionResponses, } from "./types.gen"; export type Options< @@ -145,6 +159,19 @@ export const healthEndpointHealthGet = ( ThrowOnError >({ url: "/health", ...options }); +/** + * Get API version and feature flags + * + * Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment. + */ +export const getVersion = ( + options?: Options, +) => + (options?.client ?? client).get({ + url: "/version", + ...options, + }); + /** * Prometheus metrics endpoint * @@ -336,35 +363,33 @@ export const regenerateEntityObservations = < }); /** - * List mental models + * List reflections * - * List all mental models for a bank, optionally filtered by subtype or tags. + * List user-curated living documents that stay current. */ -export const listMentalModels = ( - options: Options, +export const listReflections = ( + options: Options, ) => (options.client ?? client).get< - ListMentalModelsResponses, - ListMentalModelsErrors, + ListReflectionsResponses, + ListReflectionsErrors, ThrowOnError - >({ url: "/v1/default/banks/{bank_id}/mental-models", ...options }); + >({ url: "/v1/default/banks/{bank_id}/reflections", ...options }); /** - * Create mental model + * Create reflection * - * Create a mental model. Supports two subtypes: - * - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh - * - 'directive': User-defined hard rules, observations are provided at creation and never regenerated + * Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status. */ -export const createMentalModel = ( - options: Options, +export const createReflection = ( + options: Options, ) => (options.client ?? client).post< - CreateMentalModelResponses, - CreateMentalModelErrors, + CreateReflectionResponses, + CreateReflectionErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models", + url: "/v1/default/banks/{bank_id}/reflections", ...options, headers: { "Content-Type": "application/json", @@ -373,53 +398,53 @@ export const createMentalModel = ( }); /** - * Delete mental model + * Delete reflection * - * Delete a mental model. + * Delete a reflection. */ -export const deleteMentalModel = ( - options: Options, +export const deleteReflection = ( + options: Options, ) => (options.client ?? client).delete< - DeleteMentalModelResponses, - DeleteMentalModelErrors, + DeleteReflectionResponses, + DeleteReflectionErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}", + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}", ...options, }); /** - * Get mental model + * Get reflection * - * Get a specific mental model by ID. + * Get a specific reflection by ID. */ -export const getMentalModel = ( - options: Options, +export const getReflection = ( + options: Options, ) => (options.client ?? client).get< - GetMentalModelResponses, - GetMentalModelErrors, + GetReflectionResponses, + GetReflectionErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}", + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}", ...options, }); /** - * Update mental model + * Update reflection * - * Update a mental model's name and/or description. Useful for editing directives. + * Update a reflection's name. */ -export const updateMentalModel = ( - options: Options, +export const updateReflection = ( + options: Options, ) => (options.client ?? client).patch< - UpdateMentalModelResponses, - UpdateMentalModelErrors, + UpdateReflectionResponses, + UpdateReflectionErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}", + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}", ...options, headers: { "Content-Type": "application/json", @@ -428,19 +453,50 @@ export const updateMentalModel = ( }); /** - * Refresh mental models (async) + * Refresh reflection * - * Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress. + * Re-run the source query through reflect and update the content. */ -export const refreshMentalModels = ( - options: Options, +export const refreshReflection = ( + options: Options, ) => (options.client ?? client).post< - RefreshMentalModelsResponses, - RefreshMentalModelsErrors, + RefreshReflectionResponses, + RefreshReflectionErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/refresh", + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh", + ...options, + }); + +/** + * List directives + * + * List hard rules that are injected into prompts. + */ +export const listDirectives = ( + options: Options, +) => + (options.client ?? client).get< + ListDirectivesResponses, + ListDirectivesErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/directives", ...options }); + +/** + * Create directive + * + * Create a hard rule that will be injected into prompts. + */ +export const createDirective = ( + options: Options, +) => + (options.client ?? client).post< + CreateDirectiveResponses, + CreateDirectiveErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/directives", ...options, headers: { "Content-Type": "application/json", @@ -449,54 +505,58 @@ export const refreshMentalModels = ( }); /** - * Refresh mental model content (async) + * Delete directive * - * Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model. + * Delete a directive. */ -export const refreshMentalModel = ( - options: Options, +export const deleteDirective = ( + options: Options, ) => - (options.client ?? client).post< - RefreshMentalModelResponses, - RefreshMentalModelErrors, + (options.client ?? client).delete< + DeleteDirectiveResponses, + DeleteDirectiveErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh", + url: "/v1/default/banks/{bank_id}/directives/{directive_id}", ...options, }); /** - * List mental model version history + * Get directive * - * List all saved versions of a mental model's observations, ordered by version descending. + * Get a specific directive by ID. */ -export const listMentalModelVersions = ( - options: Options, +export const getDirective = ( + options: Options, ) => (options.client ?? client).get< - ListMentalModelVersionsResponses, - ListMentalModelVersionsErrors, + GetDirectiveResponses, + GetDirectiveErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions", + url: "/v1/default/banks/{bank_id}/directives/{directive_id}", ...options, }); /** - * Get specific mental model version + * Update directive * - * Get observations from a specific version of a mental model. + * Update a directive's properties. */ -export const getMentalModelVersion = ( - options: Options, +export const updateDirective = ( + options: Options, ) => - (options.client ?? client).get< - GetMentalModelVersionResponses, - GetMentalModelVersionErrors, + (options.client ?? client).patch< + UpdateDirectiveResponses, + UpdateDirectiveErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}", + url: "/v1/default/banks/{bank_id}/directives/{directive_id}", ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, }); /** @@ -579,7 +639,7 @@ export const getChunk = ( /** * List async operations * - * Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations + * Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first. */ export const listOperations = ( options: Options, @@ -738,6 +798,34 @@ export const createOrUpdateBank = ( }, }); +/** + * Clear all mental models + * + * Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge. + */ +export const clearMentalModels = ( + options: Options, +) => + (options.client ?? client).delete< + ClearMentalModelsResponses, + ClearMentalModelsErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/mental-models", ...options }); + +/** + * Trigger consolidation + * + * Run memory consolidation to create/update mental models from recent memories. + */ +export const triggerConsolidation = ( + options: Options, +) => + (options.client ?? client).post< + TriggerConsolidationResponses, + TriggerConsolidationErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/consolidate", ...options }); + /** * Clear memory bank memories * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 3b17254c..5d42fe45 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -24,22 +24,6 @@ export type AddBackgroundRequest = { update_disposition?: boolean; }; -/** - * AsyncOperationSubmitResponse - * - * Response model for submitting an async operation. - */ -export type AsyncOperationSubmitResponse = { - /** - * Operation Id - */ - operation_id: string; - /** - * Status - */ - status: string; -}; - /** * BackgroundResponse * @@ -185,6 +169,24 @@ export type BankStatsResponse = { * Failed Operations */ failed_operations: number; + /** + * Last Consolidated At + * + * When consolidation last ran (ISO format) + */ + last_consolidated_at?: string | null; + /** + * Pending Consolidation + * + * Number of memories not yet processed into mental models + */ + pending_consolidation?: number; + /** + * Total Mental Models + * + * Total number of mental models + */ + total_mental_models?: number; }; /** @@ -286,6 +288,44 @@ export type ChunkResponse = { created_at: string; }; +/** + * ConsolidationResponse + * + * Response model for consolidation trigger endpoint. + */ +export type ConsolidationResponse = { + /** + * Status + * + * Status of the consolidation (completed or queued) + */ + status: string; + /** + * Processed + * + * Number of memories processed + */ + processed: number; + /** + * Created + * + * Number of mental models created + */ + created: number; + /** + * Updated + * + * Number of mental models updated + */ + updated: number; + /** + * Message + * + * Human-readable summary + */ + message: string; +}; + /** * CreateBankRequest * @@ -312,67 +352,87 @@ export type CreateBankRequest = { }; /** - * CreateMentalModelRequest + * CreateDirectiveRequest * - * Request model for creating a mental model. + * Request model for creating a directive. */ -export type CreateMentalModelRequest = { +export type CreateDirectiveRequest = { /** * Name * - * Human-readable name for the mental model + * Human-readable name for the directive */ name: string; /** - * Description + * Content * - * One-liner description for quick scanning + * The directive text to inject into prompts */ - description: string; + content: string; /** - * Subtype + * Priority * - * Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided) + * Higher priority directives are injected first */ - subtype?: string; + priority?: number; /** - * Observations + * Is Active * - * For directives only: list of user-provided observations. Required when subtype='directive'. + * Whether this directive is active */ - observations?: Array | null; + is_active?: boolean; + /** + * Tags + * + * Tags for filtering + */ + tags?: Array; +}; + +/** + * CreateReflectionRequest + * + * Request model for creating a reflection. + */ +export type CreateReflectionRequest = { + /** + * Name + * + * Human-readable name for the reflection + */ + name: string; + /** + * Source Query + * + * The query to run to generate content + */ + source_query: string; /** * Tags * * Tags for scoped visibility */ tags?: Array; + /** + * Max Tokens + * + * Maximum tokens for generated content + */ + max_tokens?: number; }; /** - * CreatedMentalModel + * CreateReflectionResponse * - * A mental model created during reflection. + * Response model for reflection creation. */ -export type CreatedMentalModel = { +export type CreateReflectionResponse = { /** - * Id + * Operation Id * - * Mental model ID + * Operation ID to track progress */ - id: string; - /** - * Name - * - * Human-readable name - */ - name: string; - /** - * Description - * - * What this model tracks - */ - description: string; + operation_id: string; }; /** @@ -419,6 +479,62 @@ export type DeleteResponse = { deleted_count?: number | null; }; +/** + * DirectiveListResponse + * + * Response model for listing directives. + */ +export type DirectiveListResponse = { + /** + * Items + */ + items: Array; +}; + +/** + * DirectiveResponse + * + * Response model for a directive. + */ +export type DirectiveResponse = { + /** + * Id + */ + id: string; + /** + * Bank Id + */ + bank_id: string; + /** + * Name + */ + name: string; + /** + * Content + */ + content: string; + /** + * Priority + */ + priority?: number; + /** + * Is Active + */ + is_active?: boolean; + /** + * Tags + */ + tags?: Array; + /** + * Created At + */ + created_at?: string | null; + /** + * Updated At + */ + updated_at?: string | null; +}; + /** * DispositionTraits * @@ -662,6 +778,32 @@ export type FactsIncludeOptions = { [key: string]: unknown; }; +/** + * FeaturesInfo + * + * Feature flags indicating which capabilities are enabled. + */ +export type FeaturesInfo = { + /** + * Mental Models + * + * Whether mental models (auto-consolidation) are enabled + */ + mental_models: boolean; + /** + * Mcp + * + * Whether MCP (Model Context Protocol) server is enabled + */ + mcp: boolean; + /** + * Worker + * + * Whether the background worker is enabled + */ + worker: boolean; +}; + /** * GraphDataResponse * @@ -842,224 +984,6 @@ export type MemoryItem = { tags?: Array | null; }; -/** - * MentalModelFreshnessResponse - * - * Freshness information for a mental model. - */ -export type MentalModelFreshnessResponse = { - /** - * Is Up To Date - * - * Whether the model has been refreshed since the last memory was added - */ - is_up_to_date: boolean; - /** - * Last Refresh At - * - * When the model was last refreshed (ISO format) - */ - last_refresh_at: string | null; - /** - * Memories Since Refresh - * - * Number of memories added since last refresh - */ - memories_since_refresh: number; - /** - * Reasons - * - * Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed - */ - reasons?: Array; -}; - -/** - * MentalModelListResponse - * - * Response model for listing mental models. - */ -export type MentalModelListResponse = { - /** - * Items - */ - items: Array; -}; - -/** - * MentalModelObservationResponse - * - * An observation within a mental model with its supporting evidence. - */ -export type MentalModelObservationResponse = { - /** - * Title - * - * Short summary title for the observation - */ - title: string; - /** - * Content - * - * The observation content - detailed explanation - */ - content: string; - /** - * Evidence - * - * Supporting evidence with quotes - */ - evidence?: Array; - /** - * Created At - * - * When this observation was first created (ISO format) - */ - created_at: string; - /** - * Trend - * - * Computed trend: stable, strengthening, weakening, new, stale - */ - trend: string; - /** - * Evidence Count - * - * Number of evidence items supporting this observation - */ - evidence_count: number; - /** - * Evidence Span - * - * Time span of evidence: {from: iso_date, to: iso_date} - */ - evidence_span: { - [key: string]: unknown; - }; -}; - -/** - * MentalModelResponse - * - * Response model for a mental model. - */ -export type MentalModelResponse = { - /** - * Id - */ - id: string; - /** - * Bank Id - */ - bank_id: string; - /** - * Subtype - */ - subtype: string; - /** - * Name - */ - name: string; - /** - * Description - */ - description: string; - /** - * Observations - * - * Structured observations with per-observation fact attribution - */ - observations?: Array; - /** - * Version - * - * Version number of the mental model observations - */ - version?: number; - /** - * Entity Id - */ - entity_id?: string | null; - /** - * Links - */ - links?: Array; - /** - * Tags - */ - tags?: Array; - /** - * Last Updated - */ - last_updated?: string | null; - /** - * Last Refresh At - * - * When observations were last refreshed (ISO format) - */ - last_refresh_at?: string | null; - /** - * Freshness info (null for directive subtypes which don't need refresh) - */ - freshness?: MentalModelFreshnessResponse | null; - /** - * Created At - */ - created_at: string; -}; - -/** - * ObservationEvidenceResponse - * - * A single piece of evidence supporting an observation. - */ -export type ObservationEvidenceResponse = { - /** - * Memory Id - * - * ID of the memory unit this evidence comes from - */ - memory_id: string; - /** - * Quote - * - * Exact quote from the memory supporting the observation - */ - quote: string; - /** - * Relevance - * - * Brief explanation of how this quote supports the observation - */ - relevance: string; - /** - * Timestamp - * - * When the source memory was created (ISO format) - */ - timestamp: string; -}; - -/** - * ObservationInput - * - * Input model for a single observation. - */ -export type ObservationInput = { - /** - * Title - * - * Short title/header for the observation - */ - title: string; - /** - * Content - * - * Content of the observation - */ - content: string; -}; - /** * OperationResponse * @@ -1146,6 +1070,14 @@ export type OperationsListResponse = { * Total */ total: number; + /** + * Limit + */ + limit: number; + /** + * Offset + */ + offset: number; /** * Operations */ @@ -1165,7 +1097,7 @@ export type RecallRequest = { /** * Types * - * List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall). + * List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall). */ types?: Array | null; budget?: Budget; @@ -1305,12 +1237,6 @@ export type ReflectBasedOn = { * Memory facts used to generate the response */ memories?: Array; - /** - * Mental Models - * - * Mental models accessed during reflection - */ - mental_models?: Array; }; /** @@ -1500,12 +1426,6 @@ export type ReflectResponse = { * Execution trace of tool and LLM calls. Only present when include.tool_calls is set. */ trace?: ReflectTrace | null; - /** - * Mental Models Created - * - * Mental models created during this reflection (via the learn tool). - */ - mental_models_created?: Array; }; /** @@ -1577,23 +1497,63 @@ export type ReflectTrace = { }; /** - * RefreshMentalModelsRequest + * ReflectionListResponse * - * Request model for refresh mental models endpoint. + * Response model for listing reflections. */ -export type RefreshMentalModelsRequest = { +export type ReflectionListResponse = { + /** + * Items + */ + items: Array; +}; + +/** + * ReflectionResponse + * + * Response model for a reflection. + */ +export type ReflectionResponse = { + /** + * Id + */ + id: string; + /** + * Bank Id + */ + bank_id: string; + /** + * Name + */ + name: string; + /** + * Source Query + */ + source_query: string; + /** + * Content + */ + content: string; /** * Tags - * - * Tags to apply to newly created mental models */ - tags?: Array | null; + tags?: Array; /** - * Subtype - * - * Only refresh models of this subtype. If not specified, refreshes all subtypes. + * Last Refreshed At */ - subtype?: "structural" | "emergent" | "pinned" | "learned" | null; + last_refreshed_at?: string | null; + /** + * Created At + */ + created_at?: string | null; + /** + * Reflect Response + * + * Full reflect API response payload including based_on facts and mental_models + */ + reflect_response?: { + [key: string]: unknown; + } | null; }; /** @@ -1719,6 +1679,44 @@ export type ToolCallsIncludeOptions = { output?: boolean; }; +/** + * UpdateDirectiveRequest + * + * Request model for updating a directive. + */ +export type UpdateDirectiveRequest = { + /** + * Name + * + * New name + */ + name?: string | null; + /** + * Content + * + * New content + */ + content?: string | null; + /** + * Priority + * + * New priority + */ + priority?: number | null; + /** + * Is Active + * + * New active status + */ + is_active?: boolean | null; + /** + * Tags + * + * New tags + */ + tags?: Array | null; +}; + /** * UpdateDispositionRequest * @@ -1729,23 +1727,17 @@ export type UpdateDispositionRequest = { }; /** - * UpdateMentalModelRequest + * UpdateReflectionRequest * - * Request model for updating a mental model. + * Request model for updating a reflection. */ -export type UpdateMentalModelRequest = { +export type UpdateReflectionRequest = { /** * Name * - * New name for the mental model + * New name for the reflection */ name?: string | null; - /** - * Description - * - * New description/rule text - */ - description?: string | null; }; /** @@ -1766,6 +1758,24 @@ export type ValidationError = { type: string; }; +/** + * VersionResponse + * + * Response model for the version/info endpoint. + */ +export type VersionResponse = { + /** + * Api Version + * + * API version string + */ + api_version: string; + /** + * Enabled feature flags + */ + features: FeaturesInfo; +}; + export type HealthEndpointHealthGetData = { body?: never; path?: never; @@ -1780,6 +1790,22 @@ export type HealthEndpointHealthGetResponses = { 200: unknown; }; +export type GetVersionData = { + body?: never; + path?: never; + query?: never; + url: "/version"; +}; + +export type GetVersionResponses = { + /** + * Successful Response + */ + 200: VersionResponse; +}; + +export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses]; + export type MetricsEndpointMetricsGetData = { body?: never; path?: never; @@ -2205,7 +2231,7 @@ export type RegenerateEntityObservationsResponses = { export type RegenerateEntityObservationsResponse = RegenerateEntityObservationsResponses[keyof RegenerateEntityObservationsResponses]; -export type ListMentalModelsData = { +export type ListReflectionsData = { body?: never; headers?: { /** @@ -2220,50 +2246,52 @@ export type ListMentalModelsData = { bank_id: string; }; query?: { - /** - * Subtype - * - * Filter by subtype: structural, emergent, or pinned - */ - subtype?: string | null; /** * Tags * - * Filter by tags (includes untagged models) + * Filter by tags */ tags?: Array | null; /** * Tags Match * - * How to match tags: 'any' (OR), 'all' (AND), or 'exact' + * How to match tags */ tags_match?: "any" | "all" | "exact"; + /** + * Limit + */ + limit?: number; + /** + * Offset + */ + offset?: number; }; - url: "/v1/default/banks/{bank_id}/mental-models"; + url: "/v1/default/banks/{bank_id}/reflections"; }; -export type ListMentalModelsErrors = { +export type ListReflectionsErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ListMentalModelsError = - ListMentalModelsErrors[keyof ListMentalModelsErrors]; +export type ListReflectionsError = + ListReflectionsErrors[keyof ListReflectionsErrors]; -export type ListMentalModelsResponses = { +export type ListReflectionsResponses = { /** * Successful Response */ - 200: MentalModelListResponse; + 200: ReflectionListResponse; }; -export type ListMentalModelsResponse = - ListMentalModelsResponses[keyof ListMentalModelsResponses]; +export type ListReflectionsResponse = + ListReflectionsResponses[keyof ListReflectionsResponses]; -export type CreateMentalModelData = { - body: CreateMentalModelRequest; +export type CreateReflectionData = { + body: CreateReflectionRequest; headers?: { /** * Authorization @@ -2277,30 +2305,30 @@ export type CreateMentalModelData = { bank_id: string; }; query?: never; - url: "/v1/default/banks/{bank_id}/mental-models"; + url: "/v1/default/banks/{bank_id}/reflections"; }; -export type CreateMentalModelErrors = { +export type CreateReflectionErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type CreateMentalModelError = - CreateMentalModelErrors[keyof CreateMentalModelErrors]; +export type CreateReflectionError = + CreateReflectionErrors[keyof CreateReflectionErrors]; -export type CreateMentalModelResponses = { +export type CreateReflectionResponses = { /** * Successful Response */ - 200: MentalModelResponse; + 200: CreateReflectionResponse; }; -export type CreateMentalModelResponse = - CreateMentalModelResponses[keyof CreateMentalModelResponses]; +export type CreateReflectionResponse2 = + CreateReflectionResponses[keyof CreateReflectionResponses]; -export type DeleteMentalModelData = { +export type DeleteReflectionData = { body?: never; headers?: { /** @@ -2314,241 +2342,32 @@ export type DeleteMentalModelData = { */ bank_id: string; /** - * Model Id + * Reflection Id */ - model_id: string; + reflection_id: string; }; query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}"; + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}"; }; -export type DeleteMentalModelErrors = { +export type DeleteReflectionErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DeleteMentalModelError = - DeleteMentalModelErrors[keyof DeleteMentalModelErrors]; +export type DeleteReflectionError = + DeleteReflectionErrors[keyof DeleteReflectionErrors]; -export type DeleteMentalModelResponses = { - /** - * Successful Response - */ - 200: DeleteResponse; -}; - -export type DeleteMentalModelResponse = - DeleteMentalModelResponses[keyof DeleteMentalModelResponses]; - -export type GetMentalModelData = { - body?: never; - headers?: { - /** - * Authorization - */ - authorization?: string | null; - }; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Model Id - */ - model_id: string; - }; - query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}"; -}; - -export type GetMentalModelErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetMentalModelError = - GetMentalModelErrors[keyof GetMentalModelErrors]; - -export type GetMentalModelResponses = { - /** - * Successful Response - */ - 200: MentalModelResponse; -}; - -export type GetMentalModelResponse = - GetMentalModelResponses[keyof GetMentalModelResponses]; - -export type UpdateMentalModelData = { - body: UpdateMentalModelRequest; - headers?: { - /** - * Authorization - */ - authorization?: string | null; - }; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Model Id - */ - model_id: string; - }; - query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}"; -}; - -export type UpdateMentalModelErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type UpdateMentalModelError = - UpdateMentalModelErrors[keyof UpdateMentalModelErrors]; - -export type UpdateMentalModelResponses = { - /** - * Successful Response - */ - 200: MentalModelResponse; -}; - -export type UpdateMentalModelResponse = - UpdateMentalModelResponses[keyof UpdateMentalModelResponses]; - -export type RefreshMentalModelsData = { - /** - * Body - */ - body?: RefreshMentalModelsRequest | null; - headers?: { - /** - * Authorization - */ - authorization?: string | null; - }; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/refresh"; -}; - -export type RefreshMentalModelsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type RefreshMentalModelsError = - RefreshMentalModelsErrors[keyof RefreshMentalModelsErrors]; - -export type RefreshMentalModelsResponses = { - /** - * Successful Response - */ - 200: AsyncOperationSubmitResponse; -}; - -export type RefreshMentalModelsResponse = - RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses]; - -export type RefreshMentalModelData = { - body?: never; - headers?: { - /** - * Authorization - */ - authorization?: string | null; - }; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Model Id - */ - model_id: string; - }; - query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh"; -}; - -export type RefreshMentalModelErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type RefreshMentalModelError = - RefreshMentalModelErrors[keyof RefreshMentalModelErrors]; - -export type RefreshMentalModelResponses = { - /** - * Successful Response - */ - 200: AsyncOperationSubmitResponse; -}; - -export type RefreshMentalModelResponse = - RefreshMentalModelResponses[keyof RefreshMentalModelResponses]; - -export type ListMentalModelVersionsData = { - body?: never; - headers?: { - /** - * Authorization - */ - authorization?: string | null; - }; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Model Id - */ - model_id: string; - }; - query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions"; -}; - -export type ListMentalModelVersionsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListMentalModelVersionsError = - ListMentalModelVersionsErrors[keyof ListMentalModelVersionsErrors]; - -export type ListMentalModelVersionsResponses = { +export type DeleteReflectionResponses = { /** * Successful Response */ 200: unknown; }; -export type GetMentalModelVersionData = { +export type GetReflectionData = { body?: never; headers?: { /** @@ -2562,35 +2381,342 @@ export type GetMentalModelVersionData = { */ bank_id: string; /** - * Model Id + * Reflection Id */ - model_id: string; - /** - * Version - */ - version: number; + reflection_id: string; }; query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}"; + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}"; }; -export type GetMentalModelVersionErrors = { +export type GetReflectionErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GetMentalModelVersionError = - GetMentalModelVersionErrors[keyof GetMentalModelVersionErrors]; +export type GetReflectionError = GetReflectionErrors[keyof GetReflectionErrors]; -export type GetMentalModelVersionResponses = { +export type GetReflectionResponses = { + /** + * Successful Response + */ + 200: ReflectionResponse; +}; + +export type GetReflectionResponse = + GetReflectionResponses[keyof GetReflectionResponses]; + +export type UpdateReflectionData = { + body: UpdateReflectionRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Reflection Id + */ + reflection_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}"; +}; + +export type UpdateReflectionErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateReflectionError = + UpdateReflectionErrors[keyof UpdateReflectionErrors]; + +export type UpdateReflectionResponses = { + /** + * Successful Response + */ + 200: ReflectionResponse; +}; + +export type UpdateReflectionResponse = + UpdateReflectionResponses[keyof UpdateReflectionResponses]; + +export type RefreshReflectionData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Reflection Id + */ + reflection_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh"; +}; + +export type RefreshReflectionErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RefreshReflectionError = + RefreshReflectionErrors[keyof RefreshReflectionErrors]; + +export type RefreshReflectionResponses = { + /** + * Successful Response + */ + 200: ReflectionResponse; +}; + +export type RefreshReflectionResponse = + RefreshReflectionResponses[keyof RefreshReflectionResponses]; + +export type ListDirectivesData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: { + /** + * Tags + * + * Filter by tags + */ + tags?: Array | null; + /** + * Tags Match + * + * How to match tags + */ + tags_match?: "any" | "all" | "exact"; + /** + * Active Only + * + * Only return active directives + */ + active_only?: boolean; + /** + * Limit + */ + limit?: number; + /** + * Offset + */ + offset?: number; + }; + url: "/v1/default/banks/{bank_id}/directives"; +}; + +export type ListDirectivesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListDirectivesError = + ListDirectivesErrors[keyof ListDirectivesErrors]; + +export type ListDirectivesResponses = { + /** + * Successful Response + */ + 200: DirectiveListResponse; +}; + +export type ListDirectivesResponse = + ListDirectivesResponses[keyof ListDirectivesResponses]; + +export type CreateDirectiveData = { + body: CreateDirectiveRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/directives"; +}; + +export type CreateDirectiveErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateDirectiveError = + CreateDirectiveErrors[keyof CreateDirectiveErrors]; + +export type CreateDirectiveResponses = { + /** + * Successful Response + */ + 200: DirectiveResponse; +}; + +export type CreateDirectiveResponse = + CreateDirectiveResponses[keyof CreateDirectiveResponses]; + +export type DeleteDirectiveData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Directive Id + */ + directive_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/directives/{directive_id}"; +}; + +export type DeleteDirectiveErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteDirectiveError = + DeleteDirectiveErrors[keyof DeleteDirectiveErrors]; + +export type DeleteDirectiveResponses = { /** * Successful Response */ 200: unknown; }; +export type GetDirectiveData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Directive Id + */ + directive_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/directives/{directive_id}"; +}; + +export type GetDirectiveErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetDirectiveError = GetDirectiveErrors[keyof GetDirectiveErrors]; + +export type GetDirectiveResponses = { + /** + * Successful Response + */ + 200: DirectiveResponse; +}; + +export type GetDirectiveResponse = + GetDirectiveResponses[keyof GetDirectiveResponses]; + +export type UpdateDirectiveData = { + body: UpdateDirectiveRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Directive Id + */ + directive_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/directives/{directive_id}"; +}; + +export type UpdateDirectiveErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateDirectiveError = + UpdateDirectiveErrors[keyof UpdateDirectiveErrors]; + +export type UpdateDirectiveResponses = { + /** + * Successful Response + */ + 200: DirectiveResponse; +}; + +export type UpdateDirectiveResponse = + UpdateDirectiveResponses[keyof UpdateDirectiveResponses]; + export type ListDocumentsData = { body?: never; headers?: { @@ -2829,7 +2955,26 @@ export type ListOperationsData = { */ bank_id: string; }; - query?: never; + query?: { + /** + * Status + * + * Filter by status: pending, completed, or failed + */ + status?: string | null; + /** + * Limit + * + * Maximum number of operations to return + */ + limit?: number; + /** + * Offset + * + * Number of operations to skip + */ + offset?: number; + }; url: "/v1/default/banks/{bank_id}/operations"; }; @@ -3161,6 +3306,82 @@ export type CreateOrUpdateBankResponses = { export type CreateOrUpdateBankResponse = CreateOrUpdateBankResponses[keyof CreateOrUpdateBankResponses]; +export type ClearMentalModelsData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/mental-models"; +}; + +export type ClearMentalModelsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ClearMentalModelsError = + ClearMentalModelsErrors[keyof ClearMentalModelsErrors]; + +export type ClearMentalModelsResponses = { + /** + * Successful Response + */ + 200: DeleteResponse; +}; + +export type ClearMentalModelsResponse = + ClearMentalModelsResponses[keyof ClearMentalModelsResponses]; + +export type TriggerConsolidationData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/consolidate"; +}; + +export type TriggerConsolidationErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type TriggerConsolidationError = + TriggerConsolidationErrors[keyof TriggerConsolidationErrors]; + +export type TriggerConsolidationResponses = { + /** + * Successful Response + */ + 200: ConsolidationResponse; +}; + +export type TriggerConsolidationResponse = + TriggerConsolidationResponses[keyof TriggerConsolidationResponses]; + export type ClearBankMemoriesData = { body?: never; headers?: { diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 9a0ef223..64473aed 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -40,10 +40,6 @@ import type { BankProfileResponse, CreateBankRequest, Budget, - MentalModelResponse, - MentalModelListResponse, - AsyncOperationSubmitResponse, - ObservationInput, } from '../generated/types.gen'; export interface HindsightClientOptions { @@ -325,163 +321,6 @@ export class HindsightClient { return this.validateResponse(response, 'setMission'); } - - /** - * List mental models for a bank. - */ - async listMentalModels( - bankId: string, - options?: { - subtype?: 'structural' | 'emergent' | 'pinned' | 'learned' | 'directive'; - tags?: string[]; - tagsMatch?: 'any' | 'all' | 'exact'; - } - ): Promise { - const response = await sdk.listMentalModels({ - client: this.client, - path: { bank_id: bankId }, - query: { - subtype: options?.subtype, - tags: options?.tags, - tags_match: options?.tagsMatch, - }, - }); - - return this.validateResponse(response, 'listMentalModels'); - } - - /** - * Get a specific mental model by ID. - */ - async getMentalModel(bankId: string, modelId: string): Promise { - const response = await sdk.getMentalModel({ - client: this.client, - path: { bank_id: bankId, model_id: modelId }, - }); - - return this.validateResponse(response, 'getMentalModel'); - } - - /** - * Create a mental model. - */ - async createMentalModel( - bankId: string, - options: { - name: string; - description: string; - subtype?: 'pinned' | 'directive'; - observations?: Array<{ title: string; content: string }>; - tags?: string[]; - } - ): Promise { - const response = await sdk.createMentalModel({ - client: this.client, - path: { bank_id: bankId }, - body: { - name: options.name, - description: options.description, - subtype: options.subtype, - observations: options.observations, - tags: options.tags, - }, - }); - - return this.validateResponse(response, 'createMentalModel'); - } - - /** - * Update a mental model's name and/or description. - */ - async updateMentalModel( - bankId: string, - modelId: string, - options: { - name?: string; - description?: string; - } - ): Promise { - const response = await sdk.updateMentalModel({ - client: this.client, - path: { bank_id: bankId, model_id: modelId }, - body: { - name: options.name, - description: options.description, - }, - }); - - return this.validateResponse(response, 'updateMentalModel'); - } - - /** - * Delete a mental model. - */ - async deleteMentalModel(bankId: string, modelId: string): Promise { - const response = await sdk.deleteMentalModel({ - client: this.client, - path: { bank_id: bankId, model_id: modelId }, - }); - - this.validateResponse(response, 'deleteMentalModel'); - } - - /** - * Submit a background job to refresh mental models for a bank. - */ - async refreshMentalModels( - bankId: string, - options?: { - subtype?: 'structural' | 'emergent' | 'pinned' | 'learned'; - tags?: string[]; - } - ): Promise { - const response = await sdk.refreshMentalModels({ - client: this.client, - path: { bank_id: bankId }, - body: { - subtype: options?.subtype, - tags: options?.tags, - }, - }); - - return this.validateResponse(response, 'refreshMentalModels'); - } - - /** - * Submit a background job to refresh content for a specific mental model. - */ - async refreshMentalModel(bankId: string, modelId: string): Promise { - const response = await sdk.refreshMentalModel({ - client: this.client, - path: { bank_id: bankId, model_id: modelId }, - }); - - return this.validateResponse(response, 'refreshMentalModel'); - } - - /** - * List all saved versions of a mental model's observations. - */ - async listMentalModelVersions(bankId: string, modelId: string): Promise { - const response = await sdk.listMentalModelVersions({ - client: this.client, - path: { bank_id: bankId, model_id: modelId }, - }); - - return this.validateResponse(response, 'listMentalModelVersions'); - } - - /** - * Get observations from a specific version of a mental model. - */ - async getMentalModelVersion(bankId: string, modelId: string, version: number): Promise { - const response = await sdk.getMentalModelVersion({ - client: this.client, - path: { bank_id: bankId, model_id: modelId, version }, - }); - - return this.validateResponse(response, 'getMentalModelVersion'); - } } // Re-export types for convenience @@ -497,10 +336,6 @@ export type { BankProfileResponse, CreateBankRequest, Budget, - MentalModelResponse, - MentalModelListResponse, - AsyncOperationSubmitResponse, - ObservationInput, }; // Also export low-level SDK functions for advanced usage diff --git a/hindsight-clients/typescript/tests/main_operations.test.ts b/hindsight-clients/typescript/tests/main_operations.test.ts index 4d7b2255..d88ea51b 100644 --- a/hindsight-clients/typescript/tests/main_operations.test.ts +++ b/hindsight-clients/typescript/tests/main_operations.test.ts @@ -413,7 +413,7 @@ describe('TestDeleteBank', () => { }); }); -describe('TestMentalModels', () => { +describe('TestMission', () => { test('set mission', async () => { const bankId = randomBankId(); const response = await client.setMission( @@ -425,173 +425,4 @@ describe('TestMentalModels', () => { expect(response.bank_id).toBe(bankId); expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity'); }); - - test('create pinned mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - const response = await client.createMentalModel(bankId, { - name: 'Product Roadmap', - description: 'Track product priorities and feature decisions', - subtype: 'pinned', - tags: ['test'], - }); - - expect(response).not.toBeNull(); - expect(response.name).toBe('Product Roadmap'); - expect(response.description).toBe('Track product priorities and feature decisions'); - expect(response.subtype).toBe('pinned'); - }); - - test('create directive mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - const response = await client.createMentalModel(bankId, { - name: 'Response Guidelines', - description: 'Rules for responding to users', - subtype: 'directive', - observations: [ - { title: 'Always be polite', content: 'All responses must be courteous and professional' }, - { title: 'Never share private info', content: 'Do not reveal internal details or user data' }, - ], - tags: ['test'], - }); - - expect(response).not.toBeNull(); - expect(response.name).toBe('Response Guidelines'); - expect(response.subtype).toBe('directive'); - expect(response.observations).toBeDefined(); - expect(response.observations!.length).toBe(2); - }); - - test('list mental models', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - await client.createMentalModel(bankId, { - name: 'Test Model', - description: 'A test mental model', - subtype: 'pinned', - }); - - const response = await client.listMentalModels(bankId); - - expect(response).not.toBeNull(); - expect(response.items).toBeDefined(); - expect(response.items!.length).toBeGreaterThanOrEqual(1); - }); - - test('get mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - const created = await client.createMentalModel(bankId, { - name: 'Retrieve Test Model', - description: 'A model to retrieve', - subtype: 'pinned', - }); - - const response = await client.getMentalModel(bankId, created.id); - - expect(response).not.toBeNull(); - expect(response.id).toBe(created.id); - expect(response.name).toBe('Retrieve Test Model'); - }); - - test('update mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - const created = await client.createMentalModel(bankId, { - name: 'Update Test Model', - description: 'Original description', - subtype: 'pinned', - }); - - const response = await client.updateMentalModel(bankId, created.id, { - name: 'Updated Model Name', - description: 'Updated description', - }); - - expect(response).not.toBeNull(); - expect(response.name).toBe('Updated Model Name'); - expect(response.description).toBe('Updated description'); - }); - - test('delete mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - const created = await client.createMentalModel(bankId, { - name: 'Delete Test Model', - description: 'A model to delete', - subtype: 'pinned', - }); - - // Delete should not throw - await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow(); - }); - - test('refresh mental models', async () => { - const bankId = randomBankId(); - - // Set mission first (required for refresh) - this also creates the bank - await client.setMission(bankId, 'Track team progress and decisions'); - - const response = await client.refreshMentalModels(bankId, { - tags: ['test'], - }); - - expect(response).not.toBeNull(); - expect(response.operation_id).toBeDefined(); - expect(response.status).toBe('queued'); - }); - - test('refresh mental model', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - const created = await client.createMentalModel(bankId, { - name: 'Refresh Single Test', - description: 'A model to refresh individually', - subtype: 'pinned', - }); - - const response = await client.refreshMentalModel(bankId, created.id); - - expect(response).not.toBeNull(); - expect(response.operation_id).toBeDefined(); - expect(response.status).toBe('queued'); - }); - - test('list mental model versions', async () => { - const bankId = randomBankId(); - // Create bank first (required for mental models) - await client.createBank(bankId, {}); - - // Create a model first - const created = await client.createMentalModel(bankId, { - name: 'Versions Test Model', - description: 'A model to test version history', - subtype: 'pinned', - }); - - const response = await client.listMentalModelVersions(bankId, created.id); - - // Newly created model should have version history - expect(response).not.toBeNull(); - }); }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts new file mode 100644 index 00000000..2d6c9e33 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { sdk, lowLevelClient } from "@/lib/hindsight-client"; + +export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const response = await sdk.triggerConsolidation({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + + if (response.error) { + console.error("API error triggering consolidation:", response.error); + return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 }); + } + + return NextResponse.json(response.data, { status: 200 }); + } catch (error) { + console.error("Error triggering consolidation:", error); + return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts new file mode 100644 index 00000000..ece22087 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ bankId: string; directiveId: string }> } +) { + try { + const { bankId, directiveId } = await params; + + if (!bankId || !directiveId) { + return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 }); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`, + { method: "GET" } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error getting directive:", errorText); + return NextResponse.json({ error: "Failed to get directive" }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error getting directive:", error); + return NextResponse.json({ error: "Failed to get directive" }, { status: 500 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ bankId: string; directiveId: string }> } +) { + try { + const { bankId, directiveId } = await params; + + if (!bankId || !directiveId) { + return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 }); + } + + const body = await request.json(); + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error updating directive:", errorText); + return NextResponse.json( + { error: errorText || "Failed to update directive" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error updating directive:", error); + return NextResponse.json({ error: "Failed to update directive" }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ bankId: string; directiveId: string }> } +) { + try { + const { bankId, directiveId } = await params; + + if (!bankId || !directiveId) { + return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 }); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`, + { method: "DELETE" } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error deleting directive:", errorText); + return NextResponse.json( + { error: errorText || "Failed to delete directive" }, + { status: response.status } + ); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("Error deleting directive:", error); + return NextResponse.json({ error: "Failed to delete directive" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts new file mode 100644 index 00000000..aa884c94 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + const { searchParams } = new URL(request.url); + const tags = searchParams.getAll("tags"); + const tagsMatch = searchParams.get("tags_match"); + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const queryParams = new URLSearchParams(); + if (tags.length > 0) { + tags.forEach((t) => queryParams.append("tags", t)); + } + if (tagsMatch) { + queryParams.append("tags_match", tagsMatch); + } + + const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/directives${queryParams.toString() ? `?${queryParams}` : ""}`; + const response = await fetch(url, { method: "GET" }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error listing directives:", errorText); + return NextResponse.json({ error: "Failed to list directives" }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error listing directives:", error); + return NextResponse.json({ error: "Failed to list directives" }, { status: 500 }); + } +} + +export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const body = await request.json(); + + const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/directives`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error creating directive:", errorText); + return NextResponse.json( + { error: errorText || "Failed to create directive" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 201 }); + } catch (error) { + console.error("Error creating directive:", error); + return NextResponse.json({ error: "Failed to create directive" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts deleted file mode 100644 index 8e63aa87..00000000 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function POST( - request: Request, - { params }: { params: Promise<{ bankId: string; modelId: string }> } -) { - try { - const { bankId, modelId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - if (!modelId) { - return NextResponse.json({ error: "model_id is required" }, { status: 400 }); - } - - const response = await sdk.refreshMentalModel({ - client: lowLevelClient, - path: { bank_id: bankId, model_id: modelId }, - }); - - if (response.error) { - console.error("API error refreshing mental model:", response.error); - return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error refreshing mental model:", error); - return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts index d0ca5b40..97333009 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts @@ -1,40 +1,28 @@ import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; -export async function PATCH( +export async function GET( request: Request, { params }: { params: Promise<{ bankId: string; modelId: string }> } ) { try { const { bankId, modelId } = await params; - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + if (!bankId || !modelId) { + return NextResponse.json({ error: "bank_id and model_id are required" }, { status: 400 }); } - if (!modelId) { - return NextResponse.json({ error: "model_id is required" }, { status: 400 }); - } - - const body = await request.json(); - - // Call the dataplane API directly since SDK may not have the update method yet const response = await fetch( `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - } + { method: "GET" } ); if (!response.ok) { const errorText = await response.text(); - console.error("API error updating mental model:", errorText); + console.error("API error getting mental model:", errorText); return NextResponse.json( - { error: errorText || "Failed to update mental model" }, + { error: "Failed to get mental model" }, { status: response.status } ); } @@ -42,39 +30,7 @@ export async function PATCH( const data = await response.json(); return NextResponse.json(data, { status: 200 }); } catch (error) { - console.error("Error updating mental model:", error); - return NextResponse.json({ error: "Failed to update mental model" }, { status: 500 }); - } -} - -export async function DELETE( - request: Request, - { params }: { params: Promise<{ bankId: string; modelId: string }> } -) { - try { - const { bankId, modelId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - if (!modelId) { - return NextResponse.json({ error: "model_id is required" }, { status: 400 }); - } - - const response = await sdk.deleteMentalModel({ - client: lowLevelClient, - path: { bank_id: bankId, model_id: modelId }, - }); - - if (response.error) { - console.error("API error deleting mental model:", response.error); - return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error deleting mental model:", error); - return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 }); + console.error("Error getting mental model:", error); + return NextResponse.json({ error: "Failed to get mental model" }, { status: 500 }); } } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts deleted file mode 100644 index 5587f4b8..00000000 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse } from "next/server"; - -const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; - -export async function GET( - request: Request, - { params }: { params: Promise<{ bankId: string; modelId: string; version: string }> } -) { - try { - const { bankId, modelId, version } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - if (!modelId) { - return NextResponse.json({ error: "model_id is required" }, { status: 400 }); - } - - if (!version) { - return NextResponse.json({ error: "version is required" }, { status: 400 }); - } - - const response = await fetch( - `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions/${version}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - } - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("API error getting mental model version:", errorText); - return NextResponse.json( - { error: errorText || "Failed to get mental model version" }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data, { status: 200 }); - } catch (error) { - console.error("Error getting mental model version:", error); - return NextResponse.json({ error: "Failed to get mental model version" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts deleted file mode 100644 index 29f2ea7d..00000000 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; - -const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; - -export async function GET( - request: Request, - { params }: { params: Promise<{ bankId: string; modelId: string }> } -) { - try { - const { bankId, modelId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - if (!modelId) { - return NextResponse.json({ error: "model_id is required" }, { status: 400 }); - } - - const response = await fetch( - `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - } - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("API error listing mental model versions:", errorText); - return NextResponse.json( - { error: errorText || "Failed to list mental model versions" }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data, { status: 200 }); - } catch (error) { - console.error("Error listing mental model versions:", error); - return NextResponse.json({ error: "Failed to list mental model versions" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts deleted file mode 100644 index 26c2bf44..00000000 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { - try { - const { bankId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - // Parse request body for optional subtype filter - let body: { subtype?: "structural" | "emergent"; tags?: string[] } | undefined; - try { - const text = await request.text(); - if (text) { - body = JSON.parse(text); - } - } catch { - // Empty body is fine - } - - const response = await sdk.refreshMentalModels({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: body, - }); - - if (response.error) { - console.error("API error refreshing mental models:", response.error); - return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error refreshing mental models:", error); - return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts index 39fd3211..d98b6c78 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts @@ -1,42 +1,22 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; -const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; - export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { const { bankId } = await params; - const { searchParams } = new URL(request.url); - const subtype = searchParams.get("subtype"); if (!bankId) { return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } - // If subtype is specified, call the dataplane API directly with the query param - if (subtype) { - const response = await fetch( - `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models?subtype=${subtype}`, - { method: "GET" } - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("API error listing mental models:", errorText); - return NextResponse.json( - { error: "Failed to list mental models" }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data, { status: 200 }); - } - - // Default: use SDK which excludes directives - const response = await sdk.listMentalModels({ + // Note: tags filtering is not supported by the list_memories API endpoint + const response = await sdk.listMemories({ client: lowLevelClient, path: { bank_id: bankId }, + query: { + type: "mental_model", + limit: 1000, + }, }); if (response.error) { @@ -44,14 +24,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 }); } - return NextResponse.json(response.data, { status: 200 }); + // Transform list memories response to mental models format + const items = (response.data?.items || []).map((item) => ({ + id: item.id, + bank_id: bankId, + text: item.text, + proof_count: 1, + history: [], + tags: item.tags || [], + source_memory_ids: [], + source_memories: [], + created_at: item.date, + updated_at: item.date, + })); + + return NextResponse.json({ items }, { status: 200 }); } catch (error) { console.error("Error listing mental models:", error); return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 }); } } -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function DELETE( + request: Request, + { params }: { params: Promise<{ bankId: string }> } +) { try { const { bankId } = await params; @@ -59,30 +56,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } - const body = await request.json(); - - // Call the dataplane API directly since SDK may not have the new endpoint yet - const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), + const response = await sdk.clearMentalModels({ + client: lowLevelClient, + path: { bank_id: bankId }, }); - if (!response.ok) { - const errorText = await response.text(); - console.error("API error creating mental model:", errorText); - return NextResponse.json( - { error: errorText || "Failed to create mental model" }, - { status: response.status } - ); + if (response.error) { + console.error("API error clearing mental models:", response.error); + return NextResponse.json({ error: "Failed to clear mental models" }, { status: 500 }); } - const data = await response.json(); - return NextResponse.json(data, { status: 201 }); + return NextResponse.json(response.data, { status: 200 }); } catch (error) { - console.error("Error creating mental model:", error); - return NextResponse.json({ error: "Failed to create mental model" }, { status: 500 }); + console.error("Error clearing mental models:", error); + return NextResponse.json({ error: "Failed to clear mental models" }, { status: 500 }); } } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts new file mode 100644 index 00000000..2a6a2d43 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ bankId: string; reflectionId: string }> } +) { + try { + const { bankId, reflectionId } = await params; + + if (!bankId || !reflectionId) { + return NextResponse.json( + { error: "bank_id and reflection_id are required" }, + { status: 400 } + ); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}/refresh`, + { method: "POST" } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error refreshing reflection:", errorText); + return NextResponse.json( + { error: errorText || "Failed to refresh reflection" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error refreshing reflection:", error); + return NextResponse.json({ error: "Failed to refresh reflection" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts new file mode 100644 index 00000000..e146788a --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ bankId: string; reflectionId: string }> } +) { + try { + const { bankId, reflectionId } = await params; + + if (!bankId || !reflectionId) { + return NextResponse.json( + { error: "bank_id and reflection_id are required" }, + { status: 400 } + ); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`, + { method: "GET" } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error getting reflection:", errorText); + return NextResponse.json({ error: "Failed to get reflection" }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error getting reflection:", error); + return NextResponse.json({ error: "Failed to get reflection" }, { status: 500 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ bankId: string; reflectionId: string }> } +) { + try { + const { bankId, reflectionId } = await params; + + if (!bankId || !reflectionId) { + return NextResponse.json( + { error: "bank_id and reflection_id are required" }, + { status: 400 } + ); + } + + const body = await request.json(); + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error updating reflection:", errorText); + return NextResponse.json( + { error: errorText || "Failed to update reflection" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error updating reflection:", error); + return NextResponse.json({ error: "Failed to update reflection" }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ bankId: string; reflectionId: string }> } +) { + try { + const { bankId, reflectionId } = await params; + + if (!bankId || !reflectionId) { + return NextResponse.json( + { error: "bank_id and reflection_id are required" }, + { status: 400 } + ); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`, + { method: "DELETE" } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error deleting reflection:", errorText); + return NextResponse.json( + { error: errorText || "Failed to delete reflection" }, + { status: response.status } + ); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("Error deleting reflection:", error); + return NextResponse.json({ error: "Failed to delete reflection" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts new file mode 100644 index 00000000..89869976 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + const { searchParams } = new URL(request.url); + const tags = searchParams.getAll("tags"); + const tagsMatch = searchParams.get("tags_match"); + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const queryParams = new URLSearchParams(); + if (tags.length > 0) { + tags.forEach((t) => queryParams.append("tags", t)); + } + if (tagsMatch) { + queryParams.append("tags_match", tagsMatch); + } + + const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections${queryParams.toString() ? `?${queryParams}` : ""}`; + const response = await fetch(url, { method: "GET" }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error listing reflections:", errorText); + return NextResponse.json( + { error: "Failed to list reflections" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error listing reflections:", error); + return NextResponse.json({ error: "Failed to list reflections" }, { status: 500 }); + } +} + +export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const body = await request.json(); + + const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error creating reflection:", errorText); + return NextResponse.json( + { error: errorText || "Failed to create reflection" }, + { status: response.status } + ); + } + + const data = await response.json(); + // Returns operation_id - content is generated in background + return NextResponse.json(data, { status: 202 }); + } catch (error) { + console.error("Error creating reflection:", error); + return NextResponse.json({ error: "Failed to create reflection" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts index 8ca88ffe..c01dc36a 100644 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts @@ -7,9 +7,15 @@ export async function GET( ) { try { const { agentId } = await params; + const searchParams = request.nextUrl.searchParams; + const status = searchParams.get("status") || undefined; + const limit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined; + const offset = searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined; + const response = await sdk.listOperations({ client: lowLevelClient, path: { bank_id: agentId }, + query: { status, limit, offset }, }); return NextResponse.json(response.data || {}, { status: 200 }); } catch (error) { diff --git a/hindsight-control-plane/src/app/api/version/route.ts b/hindsight-control-plane/src/app/api/version/route.ts new file mode 100644 index 00000000..06e32f8b --- /dev/null +++ b/hindsight-control-plane/src/app/api/version/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { sdk, lowLevelClient } from "@/lib/hindsight-client"; + +export async function GET() { + try { + const response = await sdk.getVersion({ + client: lowLevelClient, + }); + + if (response.error) { + console.error("API error getting version:", response.error); + return NextResponse.json({ error: "Failed to get version" }, { status: 500 }); + } + + return NextResponse.json(response.data, { status: 200 }); + } catch (error) { + console.error("Error getting version:", error); + return NextResponse.json({ error: "Failed to get version" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx index c2055f9a..879e6f0d 100644 --- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx @@ -9,10 +9,10 @@ import { EntitiesView } from "@/components/entities-view"; import { ThinkView } from "@/components/think-view"; import { SearchDebugView } from "@/components/search-debug-view"; import { BankProfileView } from "@/components/bank-profile-view"; -import { MentalModelsView } from "@/components/mental-models-view"; +import { ReflectionsView } from "@/components/reflections-view"; type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; -type DataSubTab = "world" | "experience" | "models"; +type DataSubTab = "world" | "experience" | "models" | "reflections"; export default function BankPage() { const params = useParams(); @@ -124,13 +124,27 @@ export default function BankPage() {
)} +
{subTab === "world" && } {subTab === "experience" && } - {subTab === "models" && } + {subTab === "models" && } + {subTab === "reflections" && }
)} diff --git a/hindsight-control-plane/src/components/bank-profile-view.tsx b/hindsight-control-plane/src/components/bank-profile-view.tsx index 2438ab4f..9700190f 100644 --- a/hindsight-control-plane/src/components/bank-profile-view.tsx +++ b/hindsight-control-plane/src/components/bank-profile-view.tsx @@ -1,10 +1,12 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import ReactMarkdown from "react-markdown"; import { useRouter } from "next/navigation"; import { client } from "@/lib/api"; import { useBank } from "@/lib/bank-context"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -15,6 +17,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -25,6 +35,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { RefreshCw, Save, @@ -38,6 +55,13 @@ import { Activity, Trash2, Target, + AlertTriangle, + Plus, + Tag, + Loader2, + X, + MoreVertical, + Pencil, } from "lucide-react"; interface DispositionTraits { @@ -70,14 +94,32 @@ interface BankStats { }; pending_operations: number; failed_operations: number; + // Consolidation stats + last_consolidated_at: string | null; + pending_consolidation: number; + total_mental_models: number; } interface Operation { id: string; task_type: string; + items_count: number; + document_id: string | null; created_at: string; status: string; - error_message?: string; + error_message: string | null; +} + +interface Directive { + id: string; + bank_id: string; + name: string; + content: string; + priority: number; + is_active: boolean; + tags: string[]; + created_at: string; + updated_at: string; } const TRAIT_LABELS: Record< @@ -174,11 +216,20 @@ export function BankProfileView() { const [stats, setStats] = useState(null); const [operations, setOperations] = useState([]); const [totalOperations, setTotalOperations] = useState(0); - const [mentalModelsCount, setMentalModelsCount] = useState(0); + const [directives, setDirectives] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editMode, setEditMode] = useState(false); + // Directive state + const [showCreateDirective, setShowCreateDirective] = useState(false); + const [selectedDirective, setSelectedDirective] = useState(null); + const [directiveDeleteTarget, setDirectiveDeleteTarget] = useState<{ + id: string; + name: string; + } | null>(null); + const [deletingDirective, setDeletingDirective] = useState(false); + // Ref to track editMode for polling (avoids stale closure) const editModeRef = useRef(editMode); useEffect(() => { @@ -189,6 +240,19 @@ export function BankProfileView() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + // Clear mental models state + const [showClearMentalModelsDialog, setShowClearMentalModelsDialog] = useState(false); + const [isClearingMentalModels, setIsClearingMentalModels] = useState(false); + + // Consolidation state + const [isConsolidating, setIsConsolidating] = useState(false); + + // Operations filter/pagination state + const [opsStatusFilter, setOpsStatusFilter] = useState(null); + const [opsLimit] = useState(10); + const [opsOffset, setOpsOffset] = useState(0); + const [cancellingOpId, setCancellingOpId] = useState(null); + // Edit state const [editMission, setEditMission] = useState(""); const [editDisposition, setEditDisposition] = useState({ @@ -197,23 +261,38 @@ export function BankProfileView() { empathy: 3, }); + const loadOperations = async ( + statusFilter: string | null = opsStatusFilter, + offset: number = opsOffset + ) => { + if (!currentBank) return; + try { + const opsData = await client.listOperations(currentBank, { + status: statusFilter || undefined, + limit: opsLimit, + offset, + }); + setOperations(opsData.operations || []); + setTotalOperations(opsData.total || 0); + } catch (error) { + console.error("Error loading operations:", error); + } + }; + const loadData = async (isPolling = false) => { if (!currentBank) return; - // Don't overwrite form state during polling when in edit mode + // During polling, only refresh stats (not operations to avoid interfering with filters) // Use ref to get current value (avoids stale closure in setInterval) - if (isPolling && editModeRef.current) { - // Only refresh stats and operations during edit mode + if (isPolling) { try { - const [statsData, opsData, modelsData] = await Promise.all([ + const [statsData, directivesData] = await Promise.all([ client.getBankStats(currentBank), - client.listOperations(currentBank), - client.listMentalModels(currentBank), + client.listDirectives(currentBank), ]); setStats(statsData as BankStats); - setOperations((opsData as any)?.operations || []); - setTotalOperations((opsData as any)?.total || 0); - setMentalModelsCount(modelsData.items?.length || 0); + setDirectives(directivesData.items || []); + // Skip operations refresh during polling to not interfere with filter/pagination state } catch (error) { console.error("Error refreshing stats:", error); } @@ -222,17 +301,15 @@ export function BankProfileView() { setLoading(true); try { - const [profileData, statsData, opsData, modelsData] = await Promise.all([ + const [profileData, statsData, directivesData] = await Promise.all([ client.getBankProfile(currentBank), client.getBankStats(currentBank), - client.listOperations(currentBank), - client.listMentalModels(currentBank), + client.listDirectives(currentBank), ]); setProfile(profileData); setStats(statsData as BankStats); - setOperations((opsData as any)?.operations || []); - setTotalOperations((opsData as any)?.total || 0); - setMentalModelsCount(modelsData.items?.length || 0); + setDirectives(directivesData.items || []); + await loadOperations(); // Only initialize edit state when not in edit mode if (!editModeRef.current) { @@ -292,6 +369,85 @@ export function BankProfileView() { } }; + const handleClearMentalModels = async () => { + if (!currentBank) return; + + setIsClearingMentalModels(true); + try { + const result = await client.clearMentalModels(currentBank); + setShowClearMentalModelsDialog(false); + await loadData(); + alert(result.message || "Mental models cleared successfully"); + } catch (error) { + console.error("Error clearing mental models:", error); + alert("Error clearing mental models: " + (error as Error).message); + } finally { + setIsClearingMentalModels(false); + } + }; + + const handleTriggerConsolidation = async () => { + if (!currentBank) return; + + setIsConsolidating(true); + try { + const result = await client.triggerConsolidation(currentBank); + await loadData(); + alert( + result.message || + `Consolidation completed: ${result.created} created, ${result.updated} updated` + ); + } catch (error) { + console.error("Error triggering consolidation:", error); + alert("Error triggering consolidation: " + (error as Error).message); + } finally { + setIsConsolidating(false); + } + }; + + const handleOpsFilterChange = (newFilter: string | null) => { + setOpsStatusFilter(newFilter); + setOpsOffset(0); // Reset to first page when filter changes + loadOperations(newFilter, 0); + }; + + const handleOpsPageChange = (newOffset: number) => { + setOpsOffset(newOffset); + loadOperations(opsStatusFilter, newOffset); + }; + + const handleCancelOperation = async (operationId: string) => { + if (!currentBank) return; + + setCancellingOpId(operationId); + try { + await client.cancelOperation(currentBank, operationId); + await loadOperations(); + } catch (error) { + console.error("Error cancelling operation:", error); + alert("Error cancelling operation: " + (error as Error).message); + } finally { + setCancellingOpId(null); + } + }; + + const handleDeleteDirective = async () => { + if (!currentBank || !directiveDeleteTarget) return; + + setDeletingDirective(true); + try { + await client.deleteDirective(currentBank, directiveDeleteTarget.id); + setDirectives((prev) => prev.filter((d) => d.id !== directiveDeleteTarget.id)); + if (selectedDirective?.id === directiveDeleteTarget.id) setSelectedDirective(null); + setDirectiveDeleteTarget(null); + } catch (error) { + console.error("Error deleting directive:", error); + alert("Error deleting: " + (error as Error).message); + } finally { + setDeletingDirective(false); + } + }; + useEffect(() => { if (currentBank) { loadData(); @@ -301,6 +457,17 @@ export function BankProfileView() { } }, [currentBank]); + // Close directive detail panel on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelectedDirective(null); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + if (!currentBank) { return ( @@ -354,19 +521,44 @@ export function BankProfileView() { ) : ( - <> - - - - + + + + + + setEditMode(true)}> + + Edit Profile + + + + {isConsolidating ? ( + + ) : ( + + )} + {isConsolidating ? "Consolidating..." : "Run Consolidation"} + + setShowClearMentalModelsDialog(true)} + className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300" + > + + Clear Mental Models + + + setShowDeleteDialog(true)} + className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300" + > + + Delete Bank + + + )} @@ -440,7 +632,7 @@ export function BankProfileView() { {/* Memory Type Breakdown */} {stats && ( -
+

World Facts @@ -457,11 +649,21 @@ export function BankProfileView() { {stats.nodes_by_fact_type?.experience || 0}

-
-

+

+

Mental Models

-

{mentalModelsCount}

+

+ {stats.total_mental_models || 0} +

+
+
+

+ Directives +

+

+ {directives.length} +

)} @@ -521,6 +723,79 @@ export function BankProfileView() {
+ {/* Directives Section */} + + +
+
+ + + Directives + + Hard rules that must be followed during reflect +
+ +
+
+ + {directives.length > 0 ? ( +
+ {directives.map((d) => ( + setSelectedDirective(d)} + > + +
+ +
+ {d.name} +

+ {d.content} +

+ {d.tags && d.tags.length > 0 && ( +
+ + {d.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+
+
+ ))} +
+ ) : ( +
+ +

+ No directives yet. Directives are hard rules that must be followed during reflect. +

+
+ )} +
+
+ {/* Operations Section */} @@ -529,87 +804,145 @@ export function BankProfileView() { Background Operations + - {totalOperations} total operation{totalOperations !== 1 ? "s" : ""} - {operations.length < totalOperations ? ` (showing last ${operations.length})` : ""} + {totalOperations} operation{totalOperations !== 1 ? "s" : ""} + {opsStatusFilter ? ` (${opsStatusFilter})` : ""}
- {stats && (stats.pending_operations > 0 || stats.failed_operations > 0) && ( -
- {stats.pending_operations > 0 && ( -
- - - {stats.pending_operations} pending - -
- )} - {stats.failed_operations > 0 && ( -
- - - {stats.failed_operations} failed - -
- )} -
- )} +
+ {[ + { value: null, label: "All" }, + { value: "pending", label: "Pending" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + ].map((filter) => ( + + ))} +
{operations.length > 0 ? ( -
- - - - ID - Type - Created - Status - - - - {operations.map((op) => ( - - - {op.id.substring(0, 8)} - - {op.task_type} - - {new Date(op.created_at).toLocaleString()} - - - {op.status === "pending" && ( - - - pending - - )} - {op.status === "failed" && ( - - - failed - - )} - {op.status === "completed" && ( - - - done - - )} - + <> +
+
+ + + ID + Type + Created + Status + - ))} - -
-
+ + + {operations.map((op) => ( + + + {op.id.substring(0, 8)} + + {op.task_type} + + {new Date(op.created_at).toLocaleString()} + + + {op.status === "pending" && ( + + + pending + + )} + {op.status === "failed" && ( + + + failed + + )} + {op.status === "completed" && ( + + + completed + + )} + + + {op.status === "pending" && ( + + )} + + + ))} + + + + {/* Pagination */} + {totalOperations > opsLimit && ( +
+

+ Showing {opsOffset + 1}-{Math.min(opsOffset + opsLimit, totalOperations)} of{" "} + {totalOperations} +

+
+ + +
+
+ )} + ) : (

- No background operations + No {opsStatusFilter ? `${opsStatusFilter} ` : ""}operations

)}
@@ -661,6 +994,302 @@ export function BankProfileView() { + + {/* Clear Mental Models Confirmation Dialog */} + + + + Clear Mental Models + +
+

+ Are you sure you want to clear all mental models for{" "} + {currentBank}? +

+

+ This will delete all consolidated knowledge. Mental models will be regenerated the + next time consolidation runs. +

+ {stats && stats.total_mental_models > 0 && ( +

This will delete {stats.total_mental_models} mental models.

+ )} +
+
+
+ + Cancel + + {isClearingMentalModels ? ( + <> + + Clearing... + + ) : ( + <> + + Clear Mental Models + + )} + + +
+
+ + {/* Create Directive Dialog */} + setShowCreateDirective(false)} + onCreated={(d) => { + setDirectives((prev) => [d, ...prev]); + setShowCreateDirective(false); + }} + /> + + {/* Delete Directive Confirmation Dialog */} + !open && setDirectiveDeleteTarget(null)} + > + + + Delete Directive + + Are you sure you want to delete{" "} + "{directiveDeleteTarget?.name}"? +
+
+ This action cannot be undone. +
+
+ + Cancel + + {deletingDirective ? : null} + Delete + + +
+
+ + {/* Directive Detail Panel */} + {selectedDirective && ( + setSelectedDirective(null)} + onDelete={() => + setDirectiveDeleteTarget({ + id: selectedDirective.id, + name: selectedDirective.name, + }) + } + /> + )} + + ); +} + +// ============= CREATE DIRECTIVE DIALOG ============= + +function CreateDirectiveDialog({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (d: Directive) => void; +}) { + const { currentBank } = useBank(); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ name: "", description: "", tags: "" }); + + const handleCreate = async () => { + if (!currentBank || !form.name.trim() || !form.description.trim()) return; + + setCreating(true); + try { + const tags = form.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + const result = await client.createDirective(currentBank, { + name: form.name.trim(), + content: form.description.trim(), + tags: tags.length > 0 ? tags : undefined, + }); + + setForm({ name: "", description: "", tags: "" }); + onCreated(result); + } catch (error) { + console.error("Error creating directive:", error); + alert("Error creating directive: " + (error as Error).message); + } finally { + setCreating(false); + } + }; + + return ( + { + if (!o) { + setForm({ name: "", description: "", tags: "" }); + onClose(); + } + }} + > + + + + + Create Directive + + + Directives are hard rules that must be followed during reflect. + + + +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g., Competitor Policy" + /> +
+
+ +