Compare commits
10
Commits
graph
...
benchmark-mm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e560d5f22d | ||
|
|
9a080a0fd5 | ||
|
|
876deb2e36 | ||
|
|
9f10db6422 | ||
|
|
8e37acc5f6 | ||
|
|
431783dc35 | ||
|
|
db0fed246d | ||
|
|
833e260cd7 | ||
|
|
9d188942b1 | ||
|
|
19e3743683 |
@@ -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)")
|
||||
+134
@@ -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")
|
||||
+113
@@ -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
|
||||
+194
@@ -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
|
||||
+50
@@ -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'))
|
||||
""")
|
||||
+47
@@ -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
|
||||
""")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Consolidation engine for automatic learning creation from memories."""
|
||||
|
||||
from .consolidator import run_consolidation_job
|
||||
|
||||
__all__ = ["run_consolidation_job"]
|
||||
@@ -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}
|
||||
@@ -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"}}]"""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Directives module for hard rules injected into prompts."""
|
||||
|
||||
from .models import Directive
|
||||
|
||||
__all__ = ["Directive"]
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.)")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__}) "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||
@@ -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()
|
||||
|
||||
@@ -21,6 +21,8 @@ TABLES = [
|
||||
"documents",
|
||||
"chunks",
|
||||
"async_operations",
|
||||
"directives",
|
||||
"reflections",
|
||||
]
|
||||
|
||||
# Files to scan for SQL queries
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
+105
-140
@@ -173,7 +173,7 @@ impl ApiClient {
|
||||
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
|
||||
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<OperationsResponse> {
|
||||
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<Vec<String>>,
|
||||
tags_match: Option<&str>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelListResponse> {
|
||||
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<types::MentalModelResponse> {
|
||||
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<types::MentalModelResponse> {
|
||||
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<types::DeleteResponse> {
|
||||
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<types::MentalModelResponse> {
|
||||
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<Vec<String>>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
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<types::AsyncOperationSubmitResponse> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
@@ -574,6 +436,109 @@ impl ApiClient {
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Reflection Methods ---
|
||||
|
||||
pub fn list_reflections(&self, bank_id: &str, _verbose: bool) -> Result<types::ReflectionListResponse> {
|
||||
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<types::ReflectionResponse> {
|
||||
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<types::CreateReflectionResponse> {
|
||||
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<types::ReflectionResponse> {
|
||||
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<serde_json::Value> {
|
||||
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<types::ReflectionResponse> {
|
||||
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<types::DirectiveListResponse> {
|
||||
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<types::DirectiveResponse> {
|
||||
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<types::DirectiveResponse> {
|
||||
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<types::DirectiveResponse> {
|
||||
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<serde_json::Value> {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
content: Option<String>,
|
||||
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!();
|
||||
}
|
||||
@@ -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<VersionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionItem {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionDetailResponse {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations: Option<Vec<ObservationData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObservationData {
|
||||
title: String,
|
||||
content: String,
|
||||
trend: Option<String>,
|
||||
evidence: Option<Vec<EvidenceData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EvidenceData {
|
||||
quote: String,
|
||||
}
|
||||
|
||||
/// List mental models for a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
tags_match: Option<String>,
|
||||
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<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
observations_file: Option<PathBuf>,
|
||||
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<types::ObservationInput> = 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<String>,
|
||||
description: Option<String>,
|
||||
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<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
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!();
|
||||
}
|
||||
+174
-163
@@ -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<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Tag matching mode (any, all, any_strict, all_strict)
|
||||
#[arg(long, default_value = "any")]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Tags for the model
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Path to JSON file containing initial observations
|
||||
#[arg(long)]
|
||||
observations: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// New description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// New content
|
||||
#[arg(long)]
|
||||
content: Option<String>,
|
||||
},
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+269
-1161
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+16
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+7
-9
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+28
-24
@@ -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
|
||||
|
||||
+12
-10
@@ -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
|
||||
|
||||
-107
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+37
-17
@@ -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
|
||||
|
||||
+37
-25
@@ -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
|
||||
+6
-13
@@ -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
|
||||
|
||||
+13
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 extends boolean = false>(
|
||||
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 = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<GetVersionData, ThrowOnError>,
|
||||
) =>
|
||||
(options?.client ?? client).get<GetVersionResponses, unknown, ThrowOnError>({
|
||||
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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListMentalModelsData, ThrowOnError>,
|
||||
export const listReflections = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListReflectionsData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateMentalModelData, ThrowOnError>,
|
||||
export const createReflection = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateReflectionData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete mental model
|
||||
* Delete reflection
|
||||
*
|
||||
* Delete a mental model.
|
||||
* Delete a reflection.
|
||||
*/
|
||||
export const deleteMentalModel = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteMentalModelData, ThrowOnError>,
|
||||
export const deleteReflection = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteReflectionData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetMentalModelData, ThrowOnError>,
|
||||
export const getReflection = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetReflectionData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateMentalModelData, ThrowOnError>,
|
||||
export const updateReflection = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateReflectionData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RefreshMentalModelsData, ThrowOnError>,
|
||||
export const refreshReflection = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RefreshReflectionData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListDirectivesData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateDirectiveData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RefreshMentalModelData, ThrowOnError>,
|
||||
export const deleteDirective = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteDirectiveData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListMentalModelVersionsData, ThrowOnError>,
|
||||
export const getDirective = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetDirectiveData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetMentalModelVersionData, ThrowOnError>,
|
||||
export const updateDirective = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateDirectiveData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
/**
|
||||
* 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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListOperationsData, ThrowOnError>,
|
||||
@@ -738,6 +798,34 @@ export const createOrUpdateBank = <ThrowOnError extends boolean = false>(
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear all mental models
|
||||
*
|
||||
* Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
|
||||
*/
|
||||
export const clearMentalModels = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ClearMentalModelsData, ThrowOnError>,
|
||||
) =>
|
||||
(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 = <ThrowOnError extends boolean = false>(
|
||||
options: Options<TriggerConsolidationData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).post<
|
||||
TriggerConsolidationResponses,
|
||||
TriggerConsolidationErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/consolidate", ...options });
|
||||
|
||||
/**
|
||||
* Clear memory bank memories
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<MentalModelListResponse> {
|
||||
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<MentalModelResponse> {
|
||||
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<MentalModelResponse> {
|
||||
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<MentalModelResponse> {
|
||||
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<void> {
|
||||
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<AsyncOperationSubmitResponse> {
|
||||
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<AsyncOperationSubmitResponse> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
-34
@@ -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 });
|
||||
}
|
||||
}
|
||||
+8
-52
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
-47
@@ -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 });
|
||||
}
|
||||
}
|
||||
-43
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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 });
|
||||
}
|
||||
}
|
||||
+113
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user