From 63a65d0723e95de4cc2e50fbec9430b624833093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 19 Jan 2026 11:38:35 +0100 Subject: [PATCH] feat: improve mental model refresh and add directives (#166) * feat: improve mental model refresh and add directives * feat: improve mental model refresh and add directives * tags * ui * fix * fix * update * update --- .gitignore | 2 +- .pgbouncer/pgbouncer.ini | 39 - .pgbouncer/userlist.txt | 2 - CLAUDE.md | 32 + .../j5e6f7g8h9i0_mental_model_versions.py | 95 + .../k6f7g8h9i0j1_add_directive_subtype.py | 58 + hindsight-api/hindsight_api/api/http.py | 416 ++++- .../hindsight_api/engine/memory_engine.py | 733 ++++++-- .../engine/mental_models/models.py | 3 +- .../hindsight_api/engine/reflect/agent.py | 184 +- .../engine/reflect/mental_model_reflect.py | 1213 +++++++++++++ .../hindsight_api/engine/reflect/models.py | 16 +- .../engine/reflect/observations.py | 248 +++ .../hindsight_api/engine/reflect/prompts.py | 684 ++++++-- .../hindsight_api/engine/reflect/tools.py | 57 +- .../engine/reflect/tools_schema.py | 106 +- .../hindsight_api/engine/response_models.py | 14 +- .../hindsight_api/extensions/__init__.py | 4 + .../extensions/operation_validator.py | 77 + hindsight-api/tests/test_extensions.py | 125 ++ hindsight-api/tests/test_llm_tools.py | 14 +- hindsight-api/tests/test_main_module.py | 4 + hindsight-api/tests/test_mental_models.py | 639 +++++++ .../tests/test_observation_trends.py | 405 +++++ hindsight-api/tests/test_reflect_agent.py | 175 +- hindsight-api/tests/test_retain.py | 23 + hindsight-api/tests/test_server_module.py | 4 + .../python/.openapi-generator/FILES | 4 + .../python/hindsight_client_api/__init__.py | 4 + .../api/mental_models_api.py | 1519 ++++++++++++---- .../hindsight_client_api/models/__init__.py | 4 + .../models/create_mental_model_request.py | 21 +- .../models/mental_model_freshness_response.py | 98 ++ .../mental_model_observation_response.py | 32 +- .../models/mental_model_response.py | 24 +- .../models/observation_evidence_response.py | 93 + .../models/observation_input.py | 89 + .../models/reflect_mental_model.py | 16 +- .../models/reflect_trace.py | 14 +- .../models/update_mental_model_request.py | 99 ++ .../typescript/generated/sdk.gen.ts | 88 +- .../typescript/generated/types.gen.ts | 336 +++- hindsight-control-plane/package.json | 2 + .../[modelId]/{generate => refresh}/route.ts | 10 +- .../[bankId]/mental-models/[modelId]/route.ts | 46 + .../[modelId]/versions/[version]/route.ts | 47 + .../mental-models/[modelId]/versions/route.ts | 43 + .../api/banks/[bankId]/mental-models/route.ts | 23 + .../src/components/data-view.tsx | 9 +- .../src/components/memory-detail-modal.tsx | 391 +++++ .../src/components/mental-models-view.tsx | 1557 +++++++++++++---- .../src/components/think-view.tsx | 464 ++++- .../src/components/ui/tabs.tsx | 55 + .../src/components/ui/tooltip.tsx | 32 + hindsight-control-plane/src/lib/api.ts | 98 +- hindsight-docs/static/openapi.json | 541 +++++- package-lock.json | 84 + 57 files changed, 9899 insertions(+), 1316 deletions(-) delete mode 100644 .pgbouncer/pgbouncer.ini delete mode 100644 .pgbouncer/userlist.txt create mode 100644 hindsight-api/hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py create mode 100644 hindsight-api/hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py create mode 100644 hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py create mode 100644 hindsight-api/hindsight_api/engine/reflect/observations.py create mode 100644 hindsight-api/tests/test_observation_trends.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/observation_input.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py rename hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/{generate => refresh}/route.ts (66%) create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts create mode 100644 hindsight-control-plane/src/components/memory-detail-modal.tsx create mode 100644 hindsight-control-plane/src/components/ui/tabs.tsx create mode 100644 hindsight-control-plane/src/components/ui/tooltip.tsx diff --git a/.gitignore b/.gitignore index 71aaea52..3905c474 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ nltk_data/ # Monitoring stack (Prometheus/Grafana binaries and data) .monitoring/ -.pgbouncer +.pgbouncer/ # Large benchmark datasets (will be downloaded automatically) **/longmemeval_s_cleaned.json diff --git a/.pgbouncer/pgbouncer.ini b/.pgbouncer/pgbouncer.ini deleted file mode 100644 index 20a1945c..00000000 --- a/.pgbouncer/pgbouncer.ini +++ /dev/null @@ -1,39 +0,0 @@ -[databases] -; Connect to pg0 on port 5433 -; The actual pg0 database is called "hindsight" -hindsight = host=127.0.0.1 port=5433 dbname=hindsight user=hindsight password=hindsight - -[pgbouncer] -listen_addr = 127.0.0.1 -listen_port = 6432 - -; Use md5 authentication (matches pg0's auth) -auth_type = md5 -auth_file = /Users/nicoloboschi/dev/memory-poc/.pgbouncer/userlist.txt - -; Transaction pooling mode (recommended for hindsight) -pool_mode = transaction - -; Reset connection state after each transaction -server_reset_query = DISCARD ALL - -; Pool sizing -default_pool_size = 20 -max_client_conn = 200 -min_pool_size = 5 - -; Timeouts -server_idle_timeout = 600 -server_lifetime = 3600 -query_timeout = 120 - -; Logging -log_connections = 1 -log_disconnections = 1 -log_pooler_errors = 1 - -; Stats -stats_period = 60 - -; Admin console -admin_users = admin diff --git a/.pgbouncer/userlist.txt b/.pgbouncer/userlist.txt deleted file mode 100644 index 53bc286f..00000000 --- a/.pgbouncer/userlist.txt +++ /dev/null @@ -1,2 +0,0 @@ -"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6" -"admin" "" diff --git a/CLAUDE.md b/CLAUDE.md index fd56e53f..94e50d3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,38 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu - Pydantic models for request/response - Ruff for linting (line-length 120) - No Python files at project root - maintain clean directory structure +- **Never use multi-item tuple return values** - prefer dataclass or Pydantic model for structured returns + +### Type Safety with Pydantic Models +**NEVER use raw `dict` types for structured data.** Always use Pydantic models: +- Use Pydantic `BaseModel` for all data structures passed between functions +- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware) +- Avoid `dict.get()` patterns - use typed model attributes instead +- Parse external data (JSON, API responses) into Pydantic models at the boundary +- This catches type errors at parse time, not deep in business logic + +```python +# BAD - error-prone dict access +def process(data: dict) -> str: + return data.get("name", "") # No validation, silent failures + +# GOOD - typed and validated +class UserData(BaseModel): + name: str + created_at: datetime + + @field_validator("created_at", mode="before") + @classmethod + def ensure_tz_aware(cls, v): + if isinstance(v, str): + v = datetime.fromisoformat(v.replace("Z", "+00:00")) + if v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + +def process(data: UserData) -> str: + return data.name # Type-safe, validated at construction +``` ### TypeScript Style - Next.js App Router for control plane diff --git a/hindsight-api/hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py b/hindsight-api/hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py new file mode 100644 index 00000000..eb578656 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py @@ -0,0 +1,95 @@ +"""mental_model_versions + +Revision ID: j5e6f7g8h9i0 +Revises: i4d5e6f7g8h9 +Create Date: 2026-01-16 00:00:00.000000 + +This migration adds versioning support for mental models: +1. Creates mental_model_versions table to store observation snapshots +2. Adds version column to mental_models for tracking current version + +This enables changelog/diff functionality for mental model observations. +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "j5e6f7g8h9i0" +down_revision: str | Sequence[str] | None = "i4d5e6f7g8h9" +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 mental_model_versions table and add version tracking.""" + schema = _get_schema_prefix() + + # Create mental_model_versions table for storing observation snapshots + op.execute(f""" + CREATE TABLE {schema}mental_model_versions ( + id SERIAL PRIMARY KEY, + mental_model_id VARCHAR(64) NOT NULL, + bank_id VARCHAR(64) NOT NULL, + version INT NOT NULL, + observations JSONB NOT NULL DEFAULT '{{"observations": []}}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + FOREIGN KEY (mental_model_id, bank_id) + REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE, + UNIQUE (mental_model_id, bank_id, version) + ) + """) + + # Index for efficient version queries (get latest, list versions) + op.execute(f""" + CREATE INDEX idx_mental_model_versions_lookup + ON {schema}mental_model_versions(mental_model_id, bank_id, version DESC) + """) + + # Add version column to mental_models to track current version + op.execute(f""" + ALTER TABLE {schema}mental_models + ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 0 + """) + + # Migrate existing mental models: create version 1 for any that have observations + op.execute(f""" + INSERT INTO {schema}mental_model_versions (mental_model_id, bank_id, version, observations, created_at) + SELECT id, bank_id, 1, observations, COALESCE(last_updated, created_at) + FROM {schema}mental_models + WHERE observations IS NOT NULL + AND observations != '{{"observations": []}}'::jsonb + AND (observations->'observations') IS NOT NULL + AND jsonb_array_length(observations->'observations') > 0 + """) + + # Update version to 1 for migrated mental models + op.execute(f""" + UPDATE {schema}mental_models + SET version = 1 + WHERE observations IS NOT NULL + AND observations != '{{"observations": []}}'::jsonb + AND (observations->'observations') IS NOT NULL + AND jsonb_array_length(observations->'observations') > 0 + """) + + +def downgrade() -> None: + """Remove mental_model_versions table and version column.""" + schema = _get_schema_prefix() + + # Drop index + op.execute(f"DROP INDEX IF EXISTS {schema}idx_mental_model_versions_lookup") + + # Drop versions table + op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions") + + # Remove version column from mental_models + op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS version") diff --git a/hindsight-api/hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py b/hindsight-api/hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py new file mode 100644 index 00000000..4511f02b --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py @@ -0,0 +1,58 @@ +"""add_directive_subtype + +Revision ID: k6f7g8h9i0j1 +Revises: j5e6f7g8h9i0 +Create Date: 2026-01-16 00:00:00.000000 + +This migration adds 'directive' to the mental_models subtype constraint. +Directives are hard rules with user-provided observations that the reflect agent must follow. +""" + +from collections.abc import Sequence + +from alembic import context, op + +# revision identifiers, used by Alembic. +revision: str = "k6f7g8h9i0j1" +down_revision: str | Sequence[str] | None = "j5e6f7g8h9i0" +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 'directive' to mental_models subtype constraint.""" + schema = _get_schema_prefix() + + # Drop existing constraint + op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype") + + # Create new constraint with 'directive' added + op.execute(f""" + ALTER TABLE {schema}mental_models + ADD CONSTRAINT ck_mental_models_subtype + CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned', 'directive')) + """) + + +def downgrade() -> None: + """Remove 'directive' from mental_models subtype constraint.""" + schema = _get_schema_prefix() + + # First delete any directives (cannot downgrade if they exist) + op.execute(f"DELETE FROM {schema}mental_models WHERE subtype = 'directive'") + + # Drop constraint with directive + op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype") + + # Recreate original constraint without directive + op.execute(f""" + ALTER TABLE {schema}mental_models + ADD CONSTRAINT ck_mental_models_subtype + CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned')) + """) diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index e72143b5..1e093b50 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -36,6 +36,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from hindsight_api import MemoryEngine from hindsight_api.engine.db_utils import acquire_with_retry from hindsight_api.engine.memory_engine import Budget, fq_table +from hindsight_api.engine.reflect.observations import Observation from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage from hindsight_api.engine.search.tags import TagsMatch from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension @@ -559,9 +560,10 @@ class ReflectMentalModel(BaseModel): id: str = Field(description="Mental model ID") name: str = Field(description="Mental model name") type: str = Field(description="Mental model type: entity, concept, event") - subtype: str = Field(description="Mental model subtype: structural, emergent, learned") - description: str = Field(description="Brief description") - summary: str | None = Field(default=None, description="Full summary (when looked up in detail)") + subtype: str = Field(description="Mental model subtype: structural, emergent, learned, directive") + observations: list[str] | None = Field( + default=None, description="Observations for directive mental models (subtype='directive')" + ) class ReflectBasedOn(BaseModel): @@ -578,6 +580,10 @@ class ReflectTrace(BaseModel): tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection") llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection") + mental_models: list[ReflectMentalModel] = Field( + default_factory=list, + description="Mental models used during reflection (includes directives with subtype='directive')", + ) class CreatedMentalModel(BaseModel): @@ -1045,12 +1051,40 @@ class BankStatsResponse(BaseModel): # Mental Model models -class MentalModelObservationResponse(BaseModel): - """An observation within a mental model with its supporting memories.""" +class ObservationEvidenceResponse(BaseModel): + """A single piece of evidence supporting an observation.""" - title: str = Field(description="Observation header (empty for intro)") - text: str = Field(description="Observation content") - based_on: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation") + memory_id: str = Field(description="ID of the memory unit this evidence comes from") + quote: str = Field(description="Exact quote from the memory supporting the observation") + relevance: str = Field(description="Brief explanation of how this quote supports the observation") + timestamp: str = Field(description="When the source memory was created (ISO format)") + + +class MentalModelObservationResponse(BaseModel): + """An observation within a mental model with its supporting evidence.""" + + title: str = Field(description="Short summary title for the observation") + content: str = Field(description="The observation content - detailed explanation") + evidence: list[ObservationEvidenceResponse] = Field( + default_factory=list, description="Supporting evidence with quotes" + ) + created_at: str = Field(description="When this observation was first created (ISO format)") + trend: str = Field(description="Computed trend: stable, strengthening, weakening, new, stale") + evidence_count: int = Field(description="Number of evidence items supporting this observation") + evidence_span: dict = Field(description="Time span of evidence: {from: iso_date, to: iso_date}") + + +class MentalModelFreshnessResponse(BaseModel): + """Freshness information for a mental model.""" + + is_up_to_date: bool = Field(description="Whether the model has been refreshed since the last memory was added") + last_refresh_at: str | None = Field(description="When the model was last refreshed (ISO format)") + memories_since_refresh: int = Field(description="Number of memories added since last refresh") + reasons: list[str] = Field( + default_factory=list, + description="Reasons why the model needs refresh (empty if up to date). " + "Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed", + ) class MentalModelResponse(BaseModel): @@ -1064,11 +1098,36 @@ class MentalModelResponse(BaseModel): "subtype": "structural", "name": "Team Structure", "description": "Who's on the team and their roles", - "observations": [{"title": "Overview", "text": "The team consists of...", "based_on": ["uuid1"]}], + "observations": [ + { + "title": "Prefers async communication", + "content": "The team prefers async communication over synchronous meetings", + "evidence": [ + { + "memory_id": "uuid1", + "quote": "I prefer Slack over meetings", + "relevance": "Shows async preference", + "timestamp": "2024-01-10T08:00:00Z", + } + ], + "created_at": "2024-01-15T10:30:00Z", + "trend": "stable", + "evidence_count": 1, + "evidence_span": {"from": "2024-01-10T08:00:00Z", "to": "2024-01-10T08:00:00Z"}, + } + ], + "version": 1, "entity_id": None, "links": [], "tags": ["project-x"], "last_updated": "2024-01-15T10:30:00Z", + "last_refresh_at": "2024-01-15T10:30:00Z", + "freshness": { + "is_up_to_date": True, + "last_refresh_at": "2024-01-15T10:30:00Z", + "memories_since_refresh": 0, + "reasons": [], + }, "created_at": "2024-01-10T08:00:00Z", } } @@ -1082,10 +1141,15 @@ class MentalModelResponse(BaseModel): observations: list[MentalModelObservationResponse] = Field( default_factory=list, description="Structured observations with per-observation fact attribution" ) + version: int = Field(default=0, description="Version number of the mental model observations") entity_id: str | None = None links: list[str] = [] tags: list[str] = [] last_updated: str | None = None + last_refresh_at: str | None = Field(default=None, description="When observations were last refreshed (ISO format)") + freshness: MentalModelFreshnessResponse | None = Field( + default=None, description="Freshness info (null for directive subtypes which don't need refresh)" + ) created_at: str @@ -1095,6 +1159,39 @@ class MentalModelListResponse(BaseModel): items: list[MentalModelResponse] +def _observation_to_response(obs: Observation) -> MentalModelObservationResponse: + """Convert internal Observation model to API response model.""" + return MentalModelObservationResponse( + title=obs.title, + content=obs.content, + evidence=[ + ObservationEvidenceResponse( + memory_id=ev.memory_id, + quote=ev.quote, + relevance=ev.relevance, + timestamp=ev.timestamp.isoformat(), + ) + for ev in obs.evidence + ], + created_at=obs.created_at.isoformat(), + trend=obs.trend.value, + evidence_count=obs.evidence_count, + evidence_span=obs.evidence_span, + ) + + +def _prepare_mental_model_response(model: dict[str, Any]) -> MentalModelResponse: + """Convert internal mental model dict to API response model. + + Handles conversion of Observation models to MentalModelObservationResponse. + """ + observations = model.get("observations", []) + converted_observations = [ + _observation_to_response(obs) if isinstance(obs, Observation) else obs for obs in observations + ] + return MentalModelResponse(**{**model, "observations": converted_observations}) + + class RefreshMentalModelsRequest(BaseModel): """Request model for refresh mental models endpoint.""" @@ -1107,24 +1204,63 @@ class RefreshMentalModelsRequest(BaseModel): ) +class ObservationInput(BaseModel): + """Input model for a single observation.""" + + title: str = Field(description="Short title/header for the observation") + content: str = Field(description="Content of the observation") + + class CreateMentalModelRequest(BaseModel): - """Request model for creating a pinned mental model.""" + """Request model for creating a mental model.""" model_config = ConfigDict( json_schema_extra={ - "example": { - "name": "Product Roadmap", - "description": "Key product priorities and upcoming features", - "tags": ["project-x"], - } + "examples": [ + { + "name": "Product Roadmap", + "description": "Key product priorities and upcoming features", + "tags": ["project-x"], + }, + { + "name": "Meeting Rules", + "description": "Rules about scheduling meetings", + "subtype": "directive", + "observations": [{"title": "Morning meetings", "content": "Never schedule meetings before 10am"}], + }, + ] } ) name: str = Field(description="Human-readable name for the mental model") description: str = Field(description="One-liner description for quick scanning") + subtype: str = Field( + default="pinned", + description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)", + ) + observations: list[ObservationInput] | None = Field( + default=None, + description="For directives only: list of user-provided observations. Required when subtype='directive'.", + ) tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility") +class UpdateMentalModelRequest(BaseModel): + """Request model for updating a mental model.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Name", + "description": "Updated description with new rules", + } + } + ) + + name: str | None = Field(default=None, description="New name for the mental model") + description: str | None = Field(default=None, description="New description/rule text") + + class OperationResponse(BaseModel): """Response model for a single async operation.""" @@ -1742,14 +1878,12 @@ def _register_routes(app: FastAPI): name=mm.name, type=mm.type, subtype=mm.subtype, - description=mm.description, - summary=mm.summary, ) for mm in core_result.mental_models ] based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models) - # Build trace (tool_calls + llm_calls) if tool_calls is requested + # Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested trace_result: ReflectTrace | None = None if request.include.tool_calls is not None: include_output = request.include.tool_calls.output @@ -1764,7 +1898,24 @@ def _register_routes(app: FastAPI): for tc in core_result.tool_trace ] llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace] - trace_result = ReflectTrace(tool_calls=tool_calls, llm_calls=llm_calls) + # Build map of directive observations by id + directive_observations = {d.id: d.rules for d in core_result.directives_applied} + # Include all mental models (including directives with subtype='directive') + trace_mental_models = [ + ReflectMentalModel( + id=mm.id, + name=mm.name, + type=mm.type, + subtype=mm.subtype, + observations=directive_observations.get(mm.id) if mm.subtype == "directive" else None, + ) + for mm in core_result.mental_models + ] + trace_result = ReflectTrace( + tool_calls=tool_calls, + llm_calls=llm_calls, + mental_models=trace_mental_models, + ) # Build mental_models_created from tool trace (learn tool outputs) created_models: list[CreatedMentalModel] = [] @@ -2076,7 +2227,46 @@ def _register_routes(app: FastAPI): tags_match=tags_match, request_context=request_context, ) - return MentalModelListResponse(items=[MentalModelResponse(**m) for m in models]) + + # Add freshness to each model (skip for directives) + # Get data needed for freshness computation (once for all models) + from hindsight_api.engine.reflect.mental_model_reflect import ( + BankProfile, + DirectiveMentalModel, + check_needs_refresh, + ) + + total_memories = await app.state.memory._count_memories_since(bank_id, None) + bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) + + # Convert to typed models at the boundary + bank_profile = BankProfile.model_validate(bank_profile_dict) + directives = [DirectiveMentalModel.model_validate(m) for m in models if m.get("subtype") == "directive"] + + for model in models: + if model.get("subtype") != "directive": + last_refresh_at = model.get("last_refresh_at") + memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at) + + # Use check_needs_refresh to get reasons + stored_refresh_state = model.get("refresh_state") + refresh_check = check_needs_refresh( + stored_state=stored_refresh_state, + current_memories_count=total_memories, + bank_profile=bank_profile, + directives=directives, + ) + + model["freshness"] = { + "is_up_to_date": not refresh_check.needs_refresh, + "last_refresh_at": last_refresh_at, + "memories_since_refresh": memories_since, + "reasons": refresh_check.reasons, + } + else: + model["freshness"] = None + + return MentalModelListResponse(items=[_prepare_mental_model_response(m) for m in models]) except (AuthenticationError, HTTPException): raise except Exception as e: @@ -2090,7 +2280,11 @@ def _register_routes(app: FastAPI): "/v1/default/banks/{bank_id}/mental-models", response_model=MentalModelResponse, summary="Create mental model", - description="Create a pinned mental model. Pinned models are user-defined and persist across refreshes.", + description=( + "Create a mental model. Supports two subtypes:\n" + "- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n" + "- 'directive': User-defined hard rules, observations are provided at creation and never regenerated" + ), operation_id="create_mental_model", tags=["Mental Models"], ) @@ -2099,16 +2293,23 @@ def _register_routes(app: FastAPI): body: CreateMentalModelRequest, request_context: RequestContext = Depends(get_request_context), ): - """Create a pinned mental model.""" + """Create a mental model (pinned or directive).""" try: + # Convert observations to list of dicts if provided + observations_list = None + if body.observations: + observations_list = [{"title": obs.title, "content": obs.content} for obs in body.observations] + model = await app.state.memory.create_mental_model( bank_id=bank_id, name=body.name, description=body.description, + subtype=body.subtype, + observations=observations_list, tags=body.tags, request_context=request_context, ) - return MentalModelResponse(**model) + return _prepare_mental_model_response(model) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except (AuthenticationError, HTTPException): @@ -2142,7 +2343,47 @@ def _register_routes(app: FastAPI): ) if model is None: raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found") - return MentalModelResponse(**model) + + # Compute freshness for non-directive models + if model.get("subtype") != "directive": + from hindsight_api.engine.reflect.mental_model_reflect import ( + BankProfile, + DirectiveMentalModel, + check_needs_refresh, + ) + + last_refresh_at = model.get("last_refresh_at") + total_memories = await app.state.memory._count_memories_since(bank_id, None) + memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at) + bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) + directives_dicts = await app.state.memory.list_mental_models( + bank_id, subtype="directive", request_context=request_context + ) + + # Convert to typed models at the boundary + bank_profile = BankProfile.model_validate(bank_profile_dict) + directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts] + + # Use check_needs_refresh to get reasons + stored_refresh_state = model.get("refresh_state") + refresh_check = check_needs_refresh( + stored_state=stored_refresh_state, + current_memories_count=total_memories, + bank_profile=bank_profile, + directives=directives, + ) + + model["freshness"] = { + "is_up_to_date": not refresh_check.needs_refresh, + "last_refresh_at": last_refresh_at, + "memories_since_refresh": memories_since, + "reasons": refresh_check.reasons, + } + else: + # Directives don't need freshness - they're static + model["freshness"] = None + + return _prepare_mental_model_response(model) except (AuthenticationError, HTTPException): raise except Exception as e: @@ -2227,23 +2468,61 @@ def _register_routes(app: FastAPI): logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) - @app.post( - "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate", - response_model=AsyncOperationSubmitResponse, - summary="Generate mental model content (async)", - description="Submit a background job to generate/refresh content for a specific mental model. " - "This is useful for newly created learned models or to regenerate content for any model.", - operation_id="generate_mental_model", + @app.patch( + "/v1/default/banks/{bank_id}/mental-models/{model_id}", + response_model=MentalModelResponse, + summary="Update mental model", + description="Update a mental model's name and/or description. Useful for editing directives.", + operation_id="update_mental_model", tags=["Mental Models"], ) - async def api_generate_mental_model( + async def api_update_mental_model( + bank_id: str, + model_id: str, + body: UpdateMentalModelRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Update a mental model's name and/or description.""" + try: + if body.name is None and body.description is None: + raise HTTPException(status_code=400, detail="At least one of 'name' or 'description' must be provided") + + updated = await app.state.memory.update_mental_model( + bank_id=bank_id, + model_id=model_id, + name=body.name, + description=body.description, + request_context=request_context, + ) + if not updated: + raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found") + return _prepare_mental_model_response(updated) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.post( + "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh", + response_model=AsyncOperationSubmitResponse, + summary="Refresh mental model content (async)", + description="Submit a background job to refresh content for a specific mental model. " + "This is useful for newly created learned models or to refresh content for any model.", + operation_id="refresh_mental_model", + tags=["Mental Models"], + ) + async def api_refresh_mental_model( bank_id: str, model_id: str, request_context: RequestContext = Depends(get_request_context), ): - """Generate content for a specific mental model.""" + """Refresh content for a specific mental model.""" try: - result = await app.state.memory.generate_mental_model_async( + result = await app.state.memory.refresh_mental_model_async( bank_id=bank_id, model_id=model_id, request_context=request_context, @@ -2260,7 +2539,74 @@ def _register_routes(app: FastAPI): import traceback error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/generate: {error_detail}") + logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/refresh: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get( + "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions", + summary="List mental model version history", + description="List all saved versions of a mental model's observations, ordered by version descending.", + operation_id="list_mental_model_versions", + tags=["Mental Models"], + ) + async def api_list_mental_model_versions( + bank_id: str, + model_id: str, + request_context: RequestContext = Depends(get_request_context), + ): + """List version history for a mental model.""" + try: + versions = await app.state.memory.get_mental_model_versions( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + return {"versions": versions} + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get( + "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}", + summary="Get specific mental model version", + description="Get observations from a specific version of a mental model.", + operation_id="get_mental_model_version", + tags=["Mental Models"], + ) + async def api_get_mental_model_version( + bank_id: str, + model_id: str, + version: int, + request_context: RequestContext = Depends(get_request_context), + ): + """Get a specific version of a mental model.""" + try: + version_data = await app.state.memory.get_mental_model_version( + bank_id=bank_id, + model_id=model_id, + version=version, + request_context=request_context, + ) + if not version_data: + raise HTTPException( + status_code=404, + detail=f"Version {version} not found for mental model '{model_id}'", + ) + return version_data + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error( + f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}: {error_detail}" + ) raise HTTPException(status_code=500, detail=str(e)) @app.get( diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 72fce14b..5d846374 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -661,9 +661,13 @@ class MemoryEngine(MemoryEngineInterface): models = await self.list_mental_models(bank_id, request_context=internal_context) # Filter models to only those being refreshed based on subtype + # NOTE: Directives (subtype='directive') are NEVER refreshed - they have user-provided content models_to_refresh = [] for m in models: model_subtype = m["subtype"] + # Skip directives - they have user-defined content that should never be regenerated + if model_subtype == "directive": + continue if model_subtype == "structural" and refresh_structural: models_to_refresh.append(m) elif model_subtype == "emergent" and refresh_emergent: @@ -704,8 +708,8 @@ class MemoryEngine(MemoryEngineInterface): "status": "success", "name": model_name, "duration_ms": duration_ms, - "iterations": agent_result.iterations if agent_result else 0, - "tool_calls": agent_result.tools_called if agent_result else 0, + "phases": len(agent_result.phases_completed) if agent_result else 0, + "memories_analyzed": agent_result.memories_analyzed if agent_result else 0, "observations": len(agent_result.observations) if agent_result else 0, } else: @@ -756,11 +760,11 @@ class MemoryEngine(MemoryEngineInterface): f"Models: {' '.join(model_summaries)}" ) - async def _handle_generate_mental_model(self, task_dict: dict[str, Any]): + async def _handle_refresh_single_mental_model(self, task_dict: dict[str, Any]): """ - Handler for single mental model generation tasks. + Handler for single mental model refresh tasks. - Generates/refreshes content for a specific mental model. + Refreshes content for a specific mental model. Args: task_dict: Dict with 'bank_id', 'model_id', 'operation_id' @@ -770,17 +774,17 @@ class MemoryEngine(MemoryEngineInterface): operation_id = task_dict.get("operation_id") if not bank_id or not model_id: - raise ValueError("bank_id and model_id are required for generate mental model task") + raise ValueError("bank_id and model_id are required for refresh mental model task") logger.info( - f"[MENTAL_MODEL_TASK] Starting generation for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" + f"[MENTAL_MODEL_TASK] Starting refresh for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" ) from hindsight_api.models import RequestContext internal_context = RequestContext() - # Generate content for the model (reuses the same logic as refresh) + # Refresh content for the model result = await self.refresh_mental_model( bank_id=bank_id, model_id=model_id, @@ -788,7 +792,7 @@ class MemoryEngine(MemoryEngineInterface): ) if result: - logger.info(f"[MENTAL_MODEL_TASK] Completed generation for model_id={model_id}, bank_id={bank_id}") + logger.info(f"[MENTAL_MODEL_TASK] Completed refresh for model_id={model_id}, bank_id={bank_id}") else: logger.warning(f"[MENTAL_MODEL_TASK] Model not found: model_id={model_id}, bank_id={bank_id}") @@ -832,8 +836,8 @@ class MemoryEngine(MemoryEngineInterface): await self._handle_batch_retain(task_dict) elif task_type == "refresh_mental_models": await self._handle_refresh_mental_models(task_dict) - elif task_type == "generate_mental_model": - await self._handle_generate_mental_model(task_dict) + elif task_type == "refresh_mental_model": + await self._handle_refresh_single_mental_model(task_dict) else: logger.error(f"Unknown task type: {task_type}") # Don't retry unknown task types @@ -1682,7 +1686,7 @@ class MemoryEngine(MemoryEngineInterface): fact_type = [ft for ft in fact_type if ft != "opinion"] if not fact_type: # All requested types were opinions - return empty result - return RecallResult(results=[], entities={}, chunks={}) + return RecallResultModel(results=[], entities={}, chunks={}) # Validate operation if validator is configured if self._operation_validator: @@ -3545,23 +3549,36 @@ class MemoryEngine(MemoryEngineInterface): async def learn_fn(input: MentalModelInput) -> dict[str, Any]: async with pool.acquire() as conn: result = await tool_learn(conn, bank_id, input, tags=tags) - # If a new model was created, trigger background generation + # If a new model was created, trigger background refresh if result.get("status") == "created" and result.get("model_id"): try: - await self.generate_mental_model_async( + await self.refresh_mental_model_async( bank_id=bank_id, model_id=result["model_id"], request_context=request_context, ) - logger.info(f"[REFLECT] Triggered background generation for learned model: {result['model_id']}") + logger.info(f"[REFLECT] Triggered background refresh for learned model: {result['model_id']}") except Exception as e: - logger.warning(f"[REFLECT] Failed to trigger generation for {result['model_id']}: {e}") + logger.warning(f"[REFLECT] Failed to trigger refresh for {result['model_id']}: {e}") return result async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]: async with pool.acquire() as conn: return await tool_expand(conn, bank_id, memory_ids, depth) + # Load directives (mental models with subtype='directive') + # Directives are hard rules that must be followed in all responses + # Filter by tags if provided (same logic as other mental models) + directives = await self.list_mental_models( + bank_id=bank_id, + subtype="directive", + tags=tags, + tags_match=tags_match, + request_context=request_context, + ) + if directives: + logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives") + # Run the agent agent_result = await run_reflect_agent( llm_config=self._reflect_llm_config, @@ -3576,6 +3593,7 @@ class MemoryEngine(MemoryEngineInterface): max_iterations=max_iterations, max_tokens=max_tokens, response_schema=response_schema, + directives=directives, ) total_time = time.time() - reflect_start @@ -3668,6 +3686,45 @@ class MemoryEngine(MemoryEngineInterface): ) # List all models lookup - don't add to based_on (too verbose, just a listing) + # Add directives to mental_models list (they are mental models with subtype='directive') + for directive in directives: + # Extract summary from observations + summary_parts: list[str] = [] + for obs in directive.get("observations", []): + # Support both Pydantic Observation objects and dicts + if hasattr(obs, "content"): + content = obs.content + title = obs.title + else: + content = obs.get("content", "") + title = obs.get("title", "") + if title and content: + summary_parts.append(f"{title}: {content}") + elif content: + summary_parts.append(content) + + # Fallback to description if no observations + if not summary_parts and directive.get("description"): + summary_parts.append(directive["description"]) + + mental_models_result.append( + MentalModelRef( + id=directive.get("id", ""), + name=directive.get("name", ""), + type="directive", + subtype="directive", + description=directive.get("description", ""), + summary="; ".join(summary_parts) if summary_parts else None, + ) + ) + + # Build directives_applied from agent result + from hindsight_api.engine.response_models import DirectiveRef + + directives_applied_result = [ + DirectiveRef(id=d.id, name=d.name, rules=d.rules) for d in agent_result.directives_applied + ] + # Return response (compatible with existing API) result = ReflectResult( text=agent_result.text, @@ -3678,6 +3735,7 @@ class MemoryEngine(MemoryEngineInterface): tool_trace=tool_trace_result, llm_trace=llm_trace_result, mental_models=mental_models_result, + directives_applied=directives_applied_result, ) # Call post-operation hook if validator is configured @@ -4092,7 +4150,7 @@ class MemoryEngine(MemoryEngineInterface): query = f""" SELECT id, bank_id, subtype, name, description, observations, - entity_id, links, tags, last_updated, created_at + version, entity_id, links, tags, last_updated, created_at FROM {fq_table("mental_models")} WHERE bank_id = $1 """ @@ -4101,6 +4159,8 @@ class MemoryEngine(MemoryEngineInterface): if subtype: query += f" AND subtype = ${len(params) + 1}" params.append(subtype) + # Note: Directives are included in API listing for admin visibility. + # They are excluded from the reflect agent's tool_lookup (in tools.py) since they're in the system prompt. # Tags filtering: include untagged models OR models with matching tags if tags: @@ -4110,6 +4170,12 @@ class MemoryEngine(MemoryEngineInterface): elif tags_match == "all": # AND match: model has no tags OR model has all specified tags query += f" AND (tags = '{{}}' OR tags @> ${len(params) + 1})" + elif tags_match == "any_strict": + # OR match, strict: model must have at least one matching tag (no untagged) + query += f" AND tags && ${len(params) + 1}" + elif tags_match == "all_strict": + # AND match, strict: model must have all specified tags (no untagged) + query += f" AND tags @> ${len(params) + 1}" else: # exact # Exact match: model has no tags OR model has exactly the specified tags query += f" AND (tags = '{{}}' OR tags = ${len(params) + 1})" @@ -4137,7 +4203,7 @@ class MemoryEngine(MemoryEngineInterface): row = await conn.fetchrow( f""" SELECT id, bank_id, subtype, name, description, observations, - entity_id, links, tags, last_updated, created_at + version, entity_id, links, tags, last_updated, created_at FROM {fq_table("mental_models")} WHERE bank_id = $1 AND id = $2 """, @@ -4155,7 +4221,13 @@ class MemoryEngine(MemoryEngineInterface): request_context: "RequestContext", _return_agent_result: bool = False, ) -> dict[str, Any] | tuple[dict[str, Any] | None, Any] | None: - """Refresh the summary for a mental model using the reflect agent. + """Refresh the observations for a mental model using the 4-phase reflect loop. + + The 4-phase loop: + 1. SEED: Get diverse memory sample, generate candidate observations + 2. EVIDENCE HUNT: For each candidate, search for supporting/contradicting evidence + 3. VALIDATE: Keep/discard/merge candidates based on evidence, extract quotes + 4. COMPARE: Merge new observations with existing mental model Uses the model's stored tags to filter recall results. @@ -4169,101 +4241,243 @@ class MemoryEngine(MemoryEngineInterface): Updated mental model dict, or (model, agent_result) tuple if _return_agent_result=True """ await self._authenticate_tenant(request_context) + + # Validate operation if validator is configured + if self._operation_validator: + from hindsight_api.extensions.operation_validator import RefreshMentalModelContext + + ctx = RefreshMentalModelContext( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + await self._validate_operation(self._operation_validator.validate_refresh_mental_model(ctx)) + pool = await self._get_pool() + start_time = time.time() # Get the mental model model = await self.get_mental_model(bank_id, model_id, request_context=request_context) if not model: return None + # Don't refresh directives - their observations are user-provided and static + if model.get("subtype") == "directive": + logger.info(f"[MENTAL_MODELS] Skipping refresh for directive '{model_id}' - observations are static") + if _return_agent_result: + return (model, None) + return model + + # Import refresh state functions and typed models + from .reflect.mental_model_reflect import ( + BankProfile, + DirectiveMentalModel, + check_needs_refresh, + compute_refresh_state, + ) + + # Check if refresh is actually needed by comparing state hashes + # Get current state inputs + total_memories = await self._count_memories_since(bank_id, None, pool) + bank_profile_dict = await self.get_bank_profile(bank_id, request_context=request_context) + directives_dicts = await self.list_mental_models(bank_id, subtype="directive", request_context=request_context) + + # Convert to typed models at the boundary + bank_profile = BankProfile.model_validate(bank_profile_dict) + directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts] + + # Get stored refresh_state from the model + stored_refresh_state = model.get("refresh_state") + + # Check if refresh is needed + refresh_check = check_needs_refresh( + stored_state=stored_refresh_state, + current_memories_count=total_memories, + bank_profile=bank_profile, + directives=directives, + ) + + if not refresh_check.needs_refresh: + logger.info(f"[MENTAL_MODELS] Skipping refresh for '{model_id}' - nothing changed since last refresh") + if _return_agent_result: + return (model, None) + return model + + logger.info( + f"[MENTAL_MODELS] Refresh needed for '{model_id}': {', '.join(refresh_check.reasons)} " + f"(memories: {total_memories})" + ) + # Use the model's stored tags for filtering recall model_tags = model.get("tags") or None + current_version = model.get("version", 0) - # Import reflect agent and tools - from .reflect.agent import run_reflect_agent - from .reflect.tools import tool_expand, tool_lookup, tool_recall + # Import the 4-phase mental model reflect + from .reflect.mental_model_reflect import run_mental_model_reflect + from .reflect.tools import tool_recall - # Get bank profile for agent context - profile = await self.get_bank_profile(bank_id, request_context=request_context) - bank_profile = { - "name": profile.get("name", "Assistant"), - "mission": profile.get("mission", ""), - } - - # Build query for the agent - instruct to generate multiple structured observations - query = f"""Generate comprehensive observations about '{model["name"]}': {model["description"]} - -To create thorough observations: -1. Use multiple recall queries to explore different aspects and facets of this topic -2. Search for related events, relationships, preferences, patterns, and historical context -3. Use expand to get full context when a fact seems important but incomplete -4. Create multiple distinct observations, each covering a different dimension or aspect - -Each observation should be self-contained and focus on a specific theme (e.g., preferences, history, relationships, patterns).""" - - # Run the reflect agent with tools (no learn tool for summary generation) - # Use observations mode to get structured observations instead of a single text answer - config = get_config() metrics = get_metrics_collector() - # Use 2x iterations for observations mode - needs more iterations for: - # multiple recall queries + expand verification + observations creation - observations_max_iterations = config.reflect_max_iterations * 2 - with metrics.record_operation("mental_model_refresh", bank_id=bank_id, source="api"): - async with acquire_with_retry(pool) as conn: - result = await run_reflect_agent( - llm_config=self._reflect_llm_config, - bank_id=bank_id, - query=query, - bank_profile=bank_profile, - lookup_fn=lambda mid=None: tool_lookup(conn, bank_id, mid), - recall_fn=lambda q, mt=4096: tool_recall( - self, - bank_id, - q, - request_context, - max_tokens=mt, - tags=model_tags, - tags_match="any", - ), - expand_fn=lambda mem_ids, depth: tool_expand(conn, bank_id, mem_ids, depth), - learn_fn=None, # Disable learn tool for summary generation - max_iterations=observations_max_iterations, - output_mode="observations", # Get structured observations - ) - # Update the model with the structured observations from the agent + # Get existing observations (convert Observation models to dicts for the reflect loop) + from .reflect.observations import Observation + + raw_observations = model.get("observations", []) + existing_observations = [obs.model_dump() if isinstance(obs, Observation) else obs for obs in raw_observations] + + # Create callback for getting diverse memories + async def get_diverse_memories() -> list[dict]: + """Get a diverse sample of memories for seeding observations.""" + # Get recent memories (last 30 days) + recent_result = await tool_recall( + self, + bank_id, + "recent activity and events", + request_context, + max_tokens=4096, + tags=model_tags, + tags_match="any" if model_tags else None, + ) + recent_memories = recent_result.get("memories", []) + + # Get memories related to the mental model topic + topic_result = await tool_recall( + self, + bank_id, + model.get("name", "") + " " + model.get("description", ""), + request_context, + max_tokens=4096, + tags=model_tags, + tags_match="any" if model_tags else None, + ) + topic_memories = topic_result.get("memories", []) + + # Combine and deduplicate + seen_ids = set() + diverse_memories = [] + for mem in recent_memories + topic_memories: + mem_id = mem.get("id") + if mem_id and mem_id not in seen_ids: + seen_ids.add(mem_id) + diverse_memories.append(mem) + + return diverse_memories + + # Create callback for recall + async def recall_fn(query: str, max_tokens: int) -> dict: + return await tool_recall( + self, + bank_id, + query, + request_context, + max_tokens=max_tokens, + tags=model_tags, + tags_match="any" if model_tags else None, + ) + + with metrics.record_operation("mental_model_refresh_4phase", bank_id=bank_id, source="api"): + result = await run_mental_model_reflect( + llm_config=self._reflect_llm_config, + bank_id=bank_id, + mental_model_id=model_id, + mental_model_name=model.get("name", ""), + existing_observations=existing_observations, + current_version=current_version, + get_diverse_memories_fn=get_diverse_memories, + recall_fn=recall_fn, + topic=model.get("description"), + ) + + # Update the model with the new observations import json - # Use observations from the agent result directly, or fall back to single observation from text - if result.observations: - observations_list = [ - {"title": obs.title, "text": obs.text, "memory_ids": obs.memory_ids} for obs in result.observations - ] - else: - # Fallback if no structured observations returned - observations_list = [{"title": "", "text": result.text, "memory_ids": result.used_memory_ids or []}] + # Convert observations to serializable format + observations_list = [ + { + "title": obs.title, + "content": obs.content, + "evidence": [ + { + "memory_id": ev.memory_id, + "quote": ev.quote, + "relevance": ev.relevance, + "timestamp": ev.timestamp.isoformat(), + } + for ev in obs.evidence + ], + "created_at": obs.created_at.isoformat(), + } + for obs in result.observations + ] + + # Compute refresh_state snapshot (using values fetched at start of refresh) + refresh_state = compute_refresh_state( + memories_count=total_memories, + bank_profile=bank_profile, + directives=directives, + ) + + observations_json = { + "observations": observations_list, + "version": result.version, + "last_refresh_at": refresh_state.last_refresh_at, + "refresh_state": refresh_state.model_dump(), + } - observations_json = {"observations": observations_list} async with acquire_with_retry(pool) as conn: + # Save the new version first + await self.save_mental_model_version( + conn, + bank_id, + model_id, + observations_list, + result.version, + ) + + # Update the mental model with new observations and version updated_row = await conn.fetchrow( f""" UPDATE {fq_table("mental_models")} - SET observations = $1::jsonb, last_updated = NOW() - WHERE bank_id = $2 AND id = $3 + SET observations = $1::jsonb, version = $2, last_updated = NOW() + WHERE bank_id = $3 AND id = $4 RETURNING id, bank_id, subtype, name, description, observations, - entity_id, links, tags, last_updated, created_at + version, entity_id, links, tags, last_updated, created_at """, json.dumps(observations_json), + result.version, bank_id, model_id, ) model_result = self._row_to_mental_model(updated_row) if updated_row else None + + # Call post-operation hook if validator is configured + if self._operation_validator: + from hindsight_api.extensions.operation_validator import RefreshMentalModelResult + + duration_ms = int((time.time() - start_time) * 1000) + result_ctx = RefreshMentalModelResult( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + model_name=model.get("name"), + observations_count=len(result.observations), + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + total_tokens=result.total_tokens, + duration_ms=duration_ms, + success=True, + error=None, + ) + try: + await self._operation_validator.on_refresh_mental_model_complete(result_ctx) + except Exception as e: + logger.warning(f"Post-refresh-mental-model hook error (non-fatal): {e}") + if _return_agent_result: return (model_result, result) return model_result - async def generate_mental_model_async( + async def refresh_mental_model_async( self, bank_id: str, model_id: str, @@ -4271,16 +4485,16 @@ Each observation should be self-contained and focus on a specific theme (e.g., p request_context: "RequestContext", ) -> dict[str, Any]: """ - Submit a background job to generate/refresh a specific mental model. + Submit a background job to refresh a specific mental model. This is useful for: - - Generating content for newly created learned models - - Re-generating content for pinned models after description changes + - Refreshing content for newly created learned models + - Refreshing content for pinned models after description changes - Manual refresh of a specific model without touching others Args: bank_id: Bank identifier - model_id: Mental model ID to generate + model_id: Mental model ID to refresh Returns: Dict with operation_id to track progress @@ -4307,13 +4521,13 @@ Each observation should be self-contained and focus on a specific theme (e.g., p """, operation_id, bank_id, - "generate_mental_model", + "refresh_mental_model", json.dumps({"model_id": model_id}), ) # Submit task to background queue task_payload = { - "type": "generate_mental_model", + "type": "refresh_mental_model", "operation_id": str(operation_id), "bank_id": bank_id, "model_id": model_id, @@ -4322,7 +4536,7 @@ Each observation should be self-contained and focus on a specific theme (e.g., p await self._task_backend.submit_task(task_payload) logger.info( - f"[MENTAL_MODEL] Generation task queued for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" + f"[MENTAL_MODEL] Refresh task queued for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}" ) return { @@ -4520,31 +4734,55 @@ Each observation should be self-contained and focus on a specific theme (e.g., p name: str, description: str, *, + subtype: str = "pinned", + observations: list[dict[str, Any]] | None = None, tags: list[str] | None = None, request_context: "RequestContext", ) -> dict[str, Any]: """ - Create a pinned mental model. + Create a mental model. - Pinned mental models are user-defined and persist across refreshes. - They are not automatically removed when mental models are regenerated. + Supports two subtypes: + - 'pinned': User-defined topic, observations are LLM-generated on refresh + - 'directive': User-defined hard rules, observations are provided at creation + + For directives, observations must be provided and will NOT be regenerated. + For pinned models, observations are generated by the reflect agent on refresh. Args: bank_id: Bank identifier name: Human-readable name for the mental model description: One-liner description for quick scanning + subtype: 'pinned' (default) or 'directive' + observations: For directives, list of {title, text} dicts. Ignored for pinned. tags: Tags for scoped visibility Returns: The created mental model """ + import json + await self._authenticate_tenant(request_context) pool = await self._get_pool() from .mental_models.models import MentalModelSubtype - # Generate stable ID from name - model_id = f"pinned-{name.lower().replace(' ', '-').replace('/', '-')}" + # Validate subtype + if subtype not in ("pinned", "directive"): + raise ValueError(f"Invalid subtype '{subtype}'. Must be 'pinned' or 'directive'.") + + # For directives, observations must be provided + if subtype == "directive": + if not observations: + raise ValueError("Directives require observations to be provided") + subtype_enum = MentalModelSubtype.DIRECTIVE + model_id = f"directive-{name.lower().replace(' ', '-').replace('/', '-')}" + # Format observations for storage + observations_json = json.dumps({"observations": observations}) + else: + subtype_enum = MentalModelSubtype.PINNED + model_id = f"pinned-{name.lower().replace(' ', '-').replace('/', '-')}" + observations_json = None async with acquire_with_retry(pool) as conn: # Check if model already exists @@ -4559,20 +4797,22 @@ Each observation should be self-contained and focus on a specific theme (e.g., p row = await conn.fetchrow( f""" INSERT INTO {fq_table("mental_models")} - (id, bank_id, subtype, name, description, tags) - VALUES ($1, $2, $3, $4, $5, $6) + (id, bank_id, subtype, name, description, observations, tags, last_updated) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8) RETURNING id, bank_id, subtype, name, description, observations, entity_id, links, tags, last_updated, created_at """, model_id, bank_id, - MentalModelSubtype.PINNED.value, + subtype_enum.value, name, description, + observations_json, tags or [], + datetime.now(UTC) if subtype == "directive" else None, ) - logger.info(f"[MENTAL_MODELS] Created pinned mental model '{name}' (id={model_id}) for bank {bank_id}") + logger.info(f"[MENTAL_MODELS] Created {subtype} mental model '{name}' (id={model_id}) for bank {bank_id}") return self._row_to_mental_model(row) async def delete_mental_model( @@ -4595,12 +4835,205 @@ Each observation should be self-contained and focus on a specific theme (e.g., p return result == "DELETE 1" + async def update_mental_model( + self, + bank_id: str, + model_id: str, + *, + name: str | None = None, + description: str | None = None, + request_context: "RequestContext", + ) -> dict | None: + """Update a mental model's name and/or description. + + Returns the updated mental model dict, or None if not found. + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + # Build dynamic update query + updates = [] + params = [bank_id, model_id] + param_idx = 3 + + if name is not None: + updates.append(f"name = ${param_idx}") + params.append(name) + param_idx += 1 + + if description is not None: + updates.append(f"description = ${param_idx}") + params.append(description) + param_idx += 1 + + if not updates: + return None + + query = f""" + UPDATE {fq_table("mental_models")} + SET {", ".join(updates)} + WHERE bank_id = $1 AND id = $2 + RETURNING id, bank_id, subtype, name, description, observations, version, entity_id, links, tags, last_updated, created_at + """ + + row = await conn.fetchrow(query, *params) + + if not row: + return None + + return self._row_to_mental_model(row) + + async def save_mental_model_version( + self, + conn, + bank_id: str, + model_id: str, + observations: list[dict], + new_version: int, + ) -> None: + """Save a new version of mental model observations. + + Args: + conn: Database connection + bank_id: Bank identifier + model_id: Mental model ID + observations: List of observation dicts + new_version: Version number to save + """ + import json + + from ..config import get_config + + config = get_config() + max_versions = getattr(config, "mental_model_max_versions", 10) + + # Save the new version + await conn.execute( + f""" + INSERT INTO {fq_table("mental_model_versions")} + (mental_model_id, bank_id, version, observations) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (mental_model_id, bank_id, version) DO UPDATE + SET observations = EXCLUDED.observations, created_at = NOW() + """, + model_id, + bank_id, + new_version, + json.dumps({"observations": observations}), + ) + + # Clean up old versions (keep only max_versions) + await conn.execute( + f""" + DELETE FROM {fq_table("mental_model_versions")} + WHERE mental_model_id = $1 AND bank_id = $2 AND version <= $3::int - $4::int + """, + model_id, + bank_id, + new_version, + max_versions, + ) + + async def get_mental_model_versions( + self, + bank_id: str, + model_id: str, + *, + request_context: "RequestContext", + ) -> list[dict]: + """List version history for a mental model. + + Args: + bank_id: Bank identifier + model_id: Mental model ID + + Returns: + List of version summaries sorted by version descending + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + rows = await conn.fetch( + f""" + SELECT version, created_at, + jsonb_array_length(observations->'observations') as observation_count + FROM {fq_table("mental_model_versions")} + WHERE mental_model_id = $1 AND bank_id = $2 + ORDER BY version DESC + """, + model_id, + bank_id, + ) + + return [ + { + "version": row["version"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "observation_count": row["observation_count"] or 0, + } + for row in rows + ] + + async def get_mental_model_version( + self, + bank_id: str, + model_id: str, + version: int, + *, + request_context: "RequestContext", + ) -> dict | None: + """Get a specific version of mental model observations. + + Args: + bank_id: Bank identifier + model_id: Mental model ID + version: Version number to retrieve + + Returns: + Version data with observations, or None if not found + """ + await self._authenticate_tenant(request_context) + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + row = await conn.fetchrow( + f""" + SELECT version, observations, created_at + FROM {fq_table("mental_model_versions")} + WHERE mental_model_id = $1 AND bank_id = $2 AND version = $3 + """, + model_id, + bank_id, + version, + ) + + if not row: + return None + + import json + + observations_data = row["observations"] + if isinstance(observations_data, str): + observations_data = json.loads(observations_data) + + observations = observations_data.get("observations", []) if isinstance(observations_data, dict) else [] + + return { + "version": row["version"], + "observations": self._parse_observations(observations), + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + } + def _row_to_mental_model(self, row) -> dict[str, Any]: """Convert a database row to a mental model dict.""" import json # Parse observations JSON - can be a dict {"observations": [...]} or a list [] observations_data = row.get("observations") + last_refresh_at = None + refresh_state = None if observations_data is None: observations_raw = [] elif isinstance(observations_data, str): @@ -4608,26 +5041,20 @@ Each observation should be self-contained and focus on a specific theme (e.g., p observations_raw = ( observations_data.get("observations", []) if isinstance(observations_data, dict) else observations_data ) + if isinstance(observations_data, dict): + last_refresh_at = observations_data.get("last_refresh_at") + refresh_state = observations_data.get("refresh_state") elif isinstance(observations_data, list): observations_raw = observations_data elif isinstance(observations_data, dict): observations_raw = observations_data.get("observations", []) + last_refresh_at = observations_data.get("last_refresh_at") + refresh_state = observations_data.get("refresh_state") else: observations_raw = [] - # Normalize observation format: map memory_ids/fact_ids to based_on - observations = [] - for obs in observations_raw: - if isinstance(obs, dict): - # Get memory IDs from either memory_ids (new) or fact_ids (legacy) - based_on = obs.get("memory_ids") or obs.get("fact_ids") or [] - observations.append( - { - "title": obs.get("title", ""), - "text": obs.get("text", ""), - "based_on": based_on, - } - ) + # Parse observations into typed models + observations = self._parse_observations(observations_raw) return { "id": row["id"], @@ -4636,13 +5063,97 @@ Each observation should be self-contained and focus on a specific theme (e.g., p "name": row["name"], "description": row["description"], "observations": observations, + "version": row.get("version", 0), "entity_id": str(row["entity_id"]) if row["entity_id"] else None, "links": row["links"] or [], "tags": list(row["tags"]) if row.get("tags") else [], "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None, + "last_refresh_at": last_refresh_at, + "refresh_state": refresh_state, "created_at": row["created_at"].isoformat(), } + def _parse_observations(self, observations_raw: list): + """Parse raw observation dicts into typed Observation models. + + Returns list of Observation models with computed trend/evidence_span/evidence_count. + """ + from .reflect.observations import Observation, ObservationEvidence + + 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 _count_memories_since( + self, + bank_id: str, + since_timestamp: str | None, + pool=None, + ) -> int: + """ + Count memories created after a given timestamp. + + Args: + bank_id: Bank identifier + since_timestamp: ISO timestamp string. If None, returns total count. + pool: Optional database pool (uses default if not provided) + + Returns: + Number of memories created since the timestamp + """ + if pool is None: + pool = await self._get_pool() + + async with acquire_with_retry(pool) as conn: + if since_timestamp: + # Parse the timestamp + from datetime import datetime + + try: + ts = datetime.fromisoformat(since_timestamp.replace("Z", "+00:00")) + except ValueError: + # Invalid timestamp, return total count + ts = None + + if ts: + count = await conn.fetchval( + f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND created_at > $2", + bank_id, + ts, + ) + return count or 0 + + # No timestamp or invalid, return total count + count = await conn.fetchval( + f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1", + bank_id, + ) + return count or 0 + async def _invalidate_facts_from_mental_models( self, conn, diff --git a/hindsight-api/hindsight_api/engine/mental_models/models.py b/hindsight-api/hindsight_api/engine/mental_models/models.py index 7ed881af..12353111 100644 --- a/hindsight-api/hindsight_api/engine/mental_models/models.py +++ b/hindsight-api/hindsight_api/engine/mental_models/models.py @@ -14,7 +14,8 @@ class MentalModelSubtype(str, Enum): STRUCTURAL = "structural" # Derived from mission, created upfront EMERGENT = "emergent" # Discovered from data patterns LEARNED = "learned" # Formed through reflection - PINNED = "pinned" # User-defined, persists across refreshes + PINNED = "pinned" # User-defined topic, observations LLM-generated + DIRECTIVE = "directive" # User-defined hard rules, observations user-provided class MentalModel(BaseModel): diff --git a/hindsight-api/hindsight_api/engine/reflect/agent.py b/hindsight-api/hindsight_api/engine/reflect/agent.py index aef3568c..bd9d38af 100644 --- a/hindsight-api/hindsight_api/engine/reflect/agent.py +++ b/hindsight-api/hindsight_api/engine/reflect/agent.py @@ -6,12 +6,37 @@ import asyncio import json import logging import time -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal +from typing import TYPE_CHECKING, Any, Awaitable, Callable -from .models import LLMCall, MentalModelInput, Observation, ReflectAgentResult, ToolCall -from .prompts import FINAL_SYSTEM_PROMPT, build_final_prompt, build_system_prompt_for_tools +from .models import DirectiveInfo, LLMCall, MentalModelInput, 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 + +def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[DirectiveInfo]: + """Build list of DirectiveInfo from directive mental models.""" + if not directives: + return [] + + result = [] + for directive in directives: + directive_id = directive.get("id", "") + directive_name = directive.get("name", "") + observations = directive.get("observations", []) + + rules = [] + for obs in observations: + # Support both Pydantic Observation objects and dicts + if hasattr(obs, "content"): + rules.append(obs.content) + elif isinstance(obs, dict) and obs.get("content"): + rules.append(obs["content"]) + + result.append(DirectiveInfo(id=directive_id, name=directive_name, rules=rules)) + + return result + + if TYPE_CHECKING: from ..llm_wrapper import LLMProvider from ..response_models import LLMToolCall @@ -136,7 +161,7 @@ async def run_reflect_agent( max_iterations: int = DEFAULT_MAX_ITERATIONS, max_tokens: int | None = None, response_schema: dict | None = None, - output_mode: Literal["answer", "observations"] = "answer", + directives: list[dict[str, Any]] | None = None, ) -> ReflectAgentResult: """ Execute the reflect agent loop using native tool calling. @@ -158,7 +183,7 @@ async def run_reflect_agent( max_iterations: Maximum number of iterations before forcing response max_tokens: Maximum tokens for the final response response_schema: Optional JSON Schema for structured output in final response - output_mode: "answer" returns final text, "observations" returns structured observations + directives: Optional list of directive mental models to inject as hard rules Returns: ReflectAgentResult with final answer and metadata @@ -167,11 +192,17 @@ async def run_reflect_agent( reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}" start_time = time.time() - # Get tools for this agent - tools = get_reflect_tools(enable_learn=enable_learn, output_mode=output_mode) + # Build directives_applied for the trace + directives_applied = _build_directives_applied(directives) - # Build initial messages - system_prompt = build_system_prompt_for_tools(bank_profile, context, output_mode=output_mode) + # Extract directive rules for tool schema (if any) + 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) + + # 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) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": query}, @@ -189,44 +220,43 @@ async def run_reflect_agent( available_memory_ids: set[str] = set() available_model_ids: set[str] = set() - # In answer mode, pre-fetch mental models so the agent always starts with this knowledge - if output_mode == "answer": - prefetch_start = time.time() - models_result = await lookup_fn(None) # List all mental models - prefetch_duration = int((time.time() - prefetch_start) * 1000) + # 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"]) + # 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 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, - ) + # 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 + ) + 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```" + # 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```" def _get_llm_trace() -> list[LLMCall]: return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace] @@ -288,6 +318,7 @@ async def run_reflect_agent( mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), + directives_applied=directives_applied, ) # Call LLM with tools @@ -338,6 +369,7 @@ async def run_reflect_agent( mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), + directives_applied=directives_applied, ) # No tool calls - LLM wants to respond with text @@ -361,6 +393,7 @@ async def run_reflect_agent( mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), + directives_applied=directives_applied, ) # Empty response, force final prompt = build_final_prompt(query, context_history, bank_profile, context) @@ -390,10 +423,11 @@ async def run_reflect_agent( mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), + directives_applied=directives_applied, ) - # Check for done tool call - done_call = next((tc for tc in result.tool_calls if tc.name == "done"), None) + # 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) if done_call: # Guardrail: Require evidence before done has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids) @@ -421,7 +455,6 @@ async def run_reflect_agent( # Process done tool return await _process_done_tool( done_call, - output_mode, available_memory_ids, available_model_ids, iteration + 1, @@ -431,12 +464,13 @@ async def run_reflect_agent( _get_llm_trace(), _log_completion, reflect_id, + directives_applied=directives_applied, llm_config=llm_config, response_schema=response_schema, ) - # Execute other tools in parallel - other_tools = [tc for tc in result.tool_calls if tc.name != "done"] + # 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")] if other_tools: # Add assistant message with tool calls messages.append( @@ -534,6 +568,7 @@ async def run_reflect_agent( mental_models_created=mental_models_created, tool_trace=tool_trace, llm_trace=_get_llm_trace(), + directives_applied=directives_applied, ) @@ -551,7 +586,6 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]: async def _process_done_tool( done_call: "LLMToolCall", - output_mode: str, available_memory_ids: set[str], available_model_ids: set[str], iterations: int, @@ -561,60 +595,13 @@ async def _process_done_tool( llm_trace: list[LLMCall], log_completion: Callable, reflect_id: str, + directives_applied: list[DirectiveInfo], llm_config: "LLMProvider | None" = None, response_schema: dict | None = None, ) -> ReflectAgentResult: """Process the done tool call and return the result.""" args = done_call.arguments - if output_mode == "observations" and "observations" in args: - # Process observations - handle both list and nested {"observations": [...]} format - observations: list[Observation] = [] - used_memory_ids: list[str] = [] - - obs_list = args["observations"] - # Handle nested format where LLM outputs {"observations": [...]} instead of just [...] - if isinstance(obs_list, dict) and "observations" in obs_list: - obs_list = obs_list["observations"] - - for obs_data in obs_list: - validated_mids = [] - for mid in obs_data.get("memory_ids", []): - if mid in available_memory_ids: - validated_mids.append(mid) - if mid not in used_memory_ids: - used_memory_ids.append(mid) - - observations.append( - Observation( - title=obs_data.get("title", ""), - text=obs_data.get("text", ""), - memory_ids=validated_mids, - ) - ) - - # Build text from observations - text_parts = [] - for obs in observations: - if obs.title: - text_parts.append(f"## {obs.title}\n{obs.text}") - else: - text_parts.append(obs.text) - answer = "\n\n".join(text_parts) - - log_completion(answer, iterations) - return ReflectAgentResult( - text=answer, - observations=observations, - 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, - ) - - # Default: answer mode answer = args.get("answer", "").strip() if not answer: answer = "No answer provided." @@ -639,6 +626,7 @@ async def _process_done_tool( llm_trace=llm_trace, used_memory_ids=used_memory_ids, used_model_ids=used_model_ids, + directives_applied=directives_applied, ) @@ -665,6 +653,10 @@ async def _execute_tool( 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.") :] + if tool_name == "list_mental_models": return await lookup_fn(None) diff --git a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py b/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py new file mode 100644 index 00000000..df7fab15 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py @@ -0,0 +1,1213 @@ +""" +Diff-Based Mental Model Reflect Agent. + +This module implements a multi-phase agentic loop for generating and updating +mental model observations with evidence-grounded quotes and computed trends. + +Phases: +0. UPDATE EXISTING: Search for new evidence for existing observations +1. SEED: Generate NEW candidate observations (skipping already-tracked patterns) +2. EVIDENCE HUNT: For each new candidate, search for supporting/contradicting evidence +3. VALIDATE: Validate new candidates and extract quotes +4. COMPARE: Merge updated existing + new validated observations +""" + +import asyncio +import hashlib +import json +import logging +import time +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Awaitable, Callable + +from pydantic import BaseModel, Field, field_validator + +from .observations import ( + CandidateObservation, + CandidateWithEvidence, + Observation, + ObservationEvidence, + verify_evidence_quotes, +) +from .prompts import ( + COMPARE_PHASE_SYSTEM_PROMPT, + SEED_PHASE_SYSTEM_PROMPT, + UPDATE_EXISTING_SYSTEM_PROMPT, + VALIDATE_PHASE_SYSTEM_PROMPT, + build_compare_phase_prompt, + build_seed_phase_prompt, + build_update_existing_prompt, + build_validate_phase_prompt, +) + +if TYPE_CHECKING: + from ..llm_wrapper import LLMProvider + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Typed Models for Refresh State Tracking +# ============================================================================= + + +class DispositionTraits(BaseModel): + """Disposition traits for a memory bank.""" + + skepticism: int = Field(default=3, ge=1, le=5) + literalism: int = Field(default=3, ge=1, le=5) + empathy: int = Field(default=3, ge=1, le=5) + + +class BankProfile(BaseModel): + """Bank profile with mission and disposition.""" + + bank_id: str = Field(default="") + name: str = Field(default="") + mission: str | None = Field(default=None) + disposition: DispositionTraits = Field(default_factory=DispositionTraits) + + @field_validator("disposition", mode="before") + @classmethod + def parse_disposition(cls, v: DispositionTraits | dict | None) -> DispositionTraits: + """Parse disposition from various formats.""" + if v is None: + return DispositionTraits() + if isinstance(v, DispositionTraits): + return v + if isinstance(v, dict): + return DispositionTraits.model_validate(v) + # Handle Pydantic v1 style models + if hasattr(v, "model_dump"): + return DispositionTraits.model_validate(v.model_dump()) + return DispositionTraits() + + +class DirectiveObservation(BaseModel): + """A single observation in a directive mental model.""" + + title: str = Field(default="") + content: str = Field(default="") + text: str = Field(default="") # Legacy field + + @property + def effective_content(self) -> str: + """Get content, falling back to text for legacy data.""" + return self.content or self.text + + +class DirectiveMentalModel(BaseModel): + """A directive mental model with its observations.""" + + id: str = Field(default="") + name: str = Field(default="") + observations: list[DirectiveObservation] = Field(default_factory=list) + + @field_validator("observations", mode="before") + @classmethod + def parse_observations(cls, v: list | None) -> list[DirectiveObservation]: + """Parse observations from various formats.""" + if not v: + return [] + result = [] + for obs in v: + if isinstance(obs, DirectiveObservation): + result.append(obs) + elif isinstance(obs, dict): + result.append(DirectiveObservation.model_validate(obs)) + return result + + +class RefreshState(BaseModel): + """State snapshot at the time of last refresh. + + Used to determine if a refresh is needed by comparing current state + against this stored snapshot. + """ + + last_refresh_at: str = Field(description="ISO timestamp of last refresh") + memories_count: int = Field(default=0, description="Total memory count at refresh time") + mission_hash: str = Field(default="", description="Hash of bank mission text") + disposition_hash: str = Field(default="", description="Hash of bank disposition values") + directives_hash: str = Field(default="", description="Hash of all directive observations") + + +class RefreshCheckResult(BaseModel): + """Result of checking if a mental model needs refresh.""" + + needs_refresh: bool = Field(description="Whether a refresh is needed") + reasons: list[str] = Field(default_factory=list, description="Reasons why refresh is needed") + current_state: RefreshState | None = Field(default=None, description="Current state for comparison") + + +def _hash_string(s: str) -> str: + """Create a short hash of a string.""" + if not s: + return "" + return hashlib.sha256(s.encode()).hexdigest()[:16] + + +def _hash_mission(mission: str | None) -> str: + """Hash the bank mission text.""" + return _hash_string(mission or "") + + +def _hash_disposition(disposition: DispositionTraits) -> str: + """Hash the bank disposition values.""" + # Create deterministic string from disposition values + return _hash_string( + f"skepticism:{disposition.skepticism}|literalism:{disposition.literalism}|empathy:{disposition.empathy}" + ) + + +def _hash_directives(directives: list[DirectiveMentalModel]) -> str: + """Hash all directive observations.""" + if not directives: + return "" + + # Create deterministic string from all directive observations + parts = [] + for directive in sorted(directives, key=lambda d: d.id or d.name): + directive_id = directive.id or directive.name + for obs in directive.observations: + parts.append(f"{directive_id}:{obs.title}:{obs.effective_content}") + + return _hash_string("|".join(parts)) + + +def compute_refresh_state( + memories_count: int, + bank_profile: BankProfile, + directives: list[DirectiveMentalModel], +) -> RefreshState: + """Compute the current refresh state from inputs. + + Args: + memories_count: Total number of memories in the bank + bank_profile: Bank profile with mission and disposition + directives: List of directive mental models + """ + return RefreshState( + last_refresh_at=datetime.now(timezone.utc).isoformat(), + memories_count=memories_count, + mission_hash=_hash_mission(bank_profile.mission), + disposition_hash=_hash_disposition(bank_profile.disposition), + directives_hash=_hash_directives(directives), + ) + + +def check_needs_refresh( + stored_state: dict | RefreshState | None, + current_memories_count: int, + bank_profile: BankProfile, + directives: list[DirectiveMentalModel], +) -> RefreshCheckResult: + """Check if a mental model needs refresh by comparing states. + + Args: + stored_state: Previously stored refresh state (or None if never refreshed) + current_memories_count: Current total memory count + bank_profile: Current bank profile + directives: Current directive mental models + + Returns: + RefreshCheckResult with needs_refresh flag and reasons + """ + # Compute current state + current_state = compute_refresh_state(current_memories_count, bank_profile, directives) + + # Never refreshed = definitely needs refresh + if stored_state is None: + return RefreshCheckResult( + needs_refresh=True, + reasons=["never_refreshed"], + current_state=current_state, + ) + + # Parse stored state if dict + if isinstance(stored_state, dict): + try: + stored = RefreshState.model_validate(stored_state) + except Exception: + return RefreshCheckResult( + needs_refresh=True, + reasons=["invalid_stored_state"], + current_state=current_state, + ) + else: + stored = stored_state + + # Compare states + reasons: list[str] = [] + + if current_memories_count > stored.memories_count: + reasons.append("new_memories") + + if current_state.mission_hash != stored.mission_hash: + reasons.append("mission_changed") + + if current_state.disposition_hash != stored.disposition_hash: + reasons.append("disposition_changed") + + if current_state.directives_hash != stored.directives_hash: + reasons.append("directives_changed") + + return RefreshCheckResult( + needs_refresh=len(reasons) > 0, + reasons=reasons, + current_state=current_state, + ) + + +class PhaseTokenUsage(BaseModel): + """Token usage from a phase's LLM calls.""" + + input_tokens: int = Field(default=0) + output_tokens: int = Field(default=0) + total_tokens: int = Field(default=0) + + def __add__(self, other: "PhaseTokenUsage") -> "PhaseTokenUsage": + """Allow aggregating token usage.""" + return PhaseTokenUsage( + input_tokens=self.input_tokens + other.input_tokens, + output_tokens=self.output_tokens + other.output_tokens, + total_tokens=self.total_tokens + other.total_tokens, + ) + + +class SeedPhaseResult(BaseModel): + """Result from the seed phase.""" + + candidates: list[CandidateObservation] = Field(default_factory=list) + token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) + + +class UpdateExistingResult(BaseModel): + """Result from the update existing phase.""" + + updated_observations: list[dict] = Field(default_factory=list) + contradicted_titles: list[str] = Field(default_factory=list) + token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) + + +class ValidatePhaseResult(BaseModel): + """Result from the validate phase.""" + + verified_observations: list[dict] = Field(default_factory=list) + token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) + + +class ComparePhaseResult(BaseModel): + """Result from the compare phase.""" + + observations: list[Observation] = Field(default_factory=list) + changes: dict = Field(default_factory=dict) + token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage) + + +class MentalModelReflectResult(BaseModel): + """Result from the mental model reflect process.""" + + observations: list[Observation] = Field(default_factory=list, description="Final validated observations") + version: int = Field(default=1, description="New version number for this mental model") + changes: dict = Field(default_factory=dict, description="Summary of changes made") + phases_completed: list[str] = Field(default_factory=list, description="Which phases were completed") + duration_ms: int = Field(default=0, description="Total duration in milliseconds") + memories_analyzed: int = Field(default=0, description="Number of memories analyzed") + candidates_generated: int = Field(default=0, description="Number of candidate observations generated") + candidates_validated: int = Field(default=0, description="Number of candidates that passed validation") + # Token usage tracking + input_tokens: int = Field(default=0, description="Total input tokens used across all LLM calls") + output_tokens: int = Field(default=0, description="Total output tokens used across all LLM calls") + total_tokens: int = Field(default=0, description="Total tokens used (input + output)") + + +class SeedPhaseOutput(BaseModel): + """Output from the seed phase.""" + + candidates: list[CandidateObservation] = Field(default_factory=list) + + +class ValidatePhaseOutput(BaseModel): + """Output from the validate phase.""" + + observations: list[dict] = Field(default_factory=list) + discarded: list[dict] = Field(default_factory=list) + merged: list[dict] = Field(default_factory=list) + + +class ComparePhaseOutput(BaseModel): + """Output from the compare phase.""" + + observations: list[dict] = Field(default_factory=list) + changes: dict = Field(default_factory=dict) + + +class NewEvidenceItem(BaseModel): + """A new evidence item from the update existing phase.""" + + memory_id: str = Field(description="ID of the memory this quote is from") + quote: str = Field(description="Exact quote from the memory") + relevance: str = Field(default="", description="Why this quote supports the observation") + timestamp: str = Field(default="", description="When the memory was created (ISO format)") + + +class UpdatedObservation(BaseModel): + """Output for a single updated observation.""" + + title: str = Field(default="") + content: str = Field(default="") + existing_evidence_count: int = Field(default=0) + new_evidence: list[NewEvidenceItem] = Field(default_factory=list) + has_contradiction: bool = Field(default=False) + contradiction_note: str | None = Field(default=None) + + +class UpdateExistingPhaseOutput(BaseModel): + """Output from the update existing phase.""" + + updated_observations: list[UpdatedObservation] = Field(default_factory=list) + + +class ObservationWithEvidence(BaseModel): + """An existing observation with newly found evidence for the update phase.""" + + observation: dict = Field(description="The original observation dict") + supporting_memories: list[dict] = Field(default_factory=list, description="Newly found supporting memories") + contradicting_memories: list[dict] = Field(default_factory=list, description="Newly found contradicting memories") + + +async def run_mental_model_reflect( + llm_config: "LLMProvider", + bank_id: str, + mental_model_id: str, + mental_model_name: str, + existing_observations: list[dict], + current_version: int, + get_diverse_memories_fn: Callable[[], Awaitable[list[dict]]], + recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + topic: str | None = None, + max_candidates: int = 15, +) -> MentalModelReflectResult: + """ + Execute the diff-based mental model reflect loop. + + This is a 5-phase process that efficiently updates existing observations + and discovers new patterns without redundant work: + + Phase 0: UPDATE EXISTING - Search for new evidence for existing observations + Phase 1: SEED - Generate NEW candidate observations (skipping already-tracked patterns) + Phase 2: EVIDENCE HUNT - Search for evidence for new candidates + Phase 3: VALIDATE - Validate new candidates and extract quotes + Phase 4: COMPARE - Merge updated existing + new validated observations + + Args: + llm_config: LLM provider for agent calls + bank_id: Bank identifier + mental_model_id: ID of the mental model being updated + mental_model_name: Name of the mental model (for context) + existing_observations: Current observations in the mental model + current_version: Current version number of the mental model + get_diverse_memories_fn: Async function to get diverse memory sample + recall_fn: Async function for semantic search (query, max_tokens) -> memories + topic: Optional topic focus for the mental model + max_candidates: Maximum number of candidate observations to generate + + Returns: + MentalModelReflectResult with final observations and metadata + """ + reflect_id = f"mm-{bank_id[:8]}-{int(time.time() * 1000) % 100000}" + start_time = time.time() + phases_completed: list[str] = [] + updated_existing: list[dict] = [] + contradicted_observations: list[str] = [] + total_usage = PhaseTokenUsage() # Aggregate token usage across all phases + + logger.info( + f"[MM-REFLECT {reflect_id}] Starting diff-based reflect for mental model '{mental_model_name}' " + f"({len(existing_observations)} existing observations)" + ) + + # ========================================================================== + # PHASE 0: UPDATE EXISTING (if there are existing observations) + # ========================================================================== + if existing_observations: + logger.info(f"[MM-REFLECT {reflect_id}] Phase 0: UPDATE EXISTING - Searching for new evidence") + phase0_start = time.time() + + update_result = await _run_update_existing_phase( + llm_config=llm_config, + existing_observations=existing_observations, + recall_fn=recall_fn, + reflect_id=reflect_id, + ) + updated_existing = update_result.updated_observations + contradicted_observations = update_result.contradicted_titles + total_usage = total_usage + update_result.token_usage + + phase0_duration = int((time.time() - phase0_start) * 1000) + logger.info( + f"[MM-REFLECT {reflect_id}] Phase 0 complete: " + f"{len(updated_existing)} observations updated, " + f"{len(contradicted_observations)} flagged for removal ({phase0_duration}ms)" + ) + phases_completed.append("update_existing") + + # ========================================================================== + # PHASE 1: SEED (with existing observations context) + # ========================================================================== + logger.info(f"[MM-REFLECT {reflect_id}] Phase 1: SEED - Getting diverse memories") + phase1_start = time.time() + + # Get diverse memory sample + seed_memories = await get_diverse_memories_fn() + if not seed_memories: + logger.warning(f"[MM-REFLECT {reflect_id}] No memories found for seeding") + # Return updated existing observations if we have them + if updated_existing: + return MentalModelReflectResult( + observations=[_dict_to_observation(obs) for obs in updated_existing], + version=current_version + 1, + changes={ + "note": "Updated existing observations, no new patterns found", + "updated": len(updated_existing), + }, + phases_completed=phases_completed + ["seed_empty"], + duration_ms=int((time.time() - start_time) * 1000), + ) + return MentalModelReflectResult( + observations=[_dict_to_observation(obs) for obs in existing_observations], + version=current_version + 1, # Always increment version when refresh runs + changes={"note": "No memories available for analysis"}, + phases_completed=["seed_empty"], + duration_ms=int((time.time() - start_time) * 1000), + ) + + # Generate NEW candidate observations (pass existing to avoid rediscovering them) + seed_result = await _run_seed_phase( + llm_config=llm_config, + memories=seed_memories, + topic=topic or mental_model_name, + max_candidates=max_candidates, + reflect_id=reflect_id, + existing_observations=existing_observations, # Pass existing to skip them + ) + candidates = seed_result.candidates + total_usage = total_usage + seed_result.token_usage + + phase1_duration = int((time.time() - phase1_start) * 1000) + logger.info( + f"[MM-REFLECT {reflect_id}] Phase 1 complete: {len(candidates)} NEW candidates from {len(seed_memories)} memories ({phase1_duration}ms)" + ) + phases_completed.append("seed") + + # If no new candidates but we updated existing, return those + if not candidates and updated_existing: + logger.info(f"[MM-REFLECT {reflect_id}] No new patterns found, returning updated existing observations") + return MentalModelReflectResult( + observations=[_dict_to_observation(obs) for obs in updated_existing], + version=current_version + 1, + changes={ + "note": "Updated existing observations with new evidence, no new patterns discovered", + "updated": len(updated_existing), + "contradicted": contradicted_observations, + }, + phases_completed=phases_completed, + duration_ms=int((time.time() - start_time) * 1000), + memories_analyzed=len(seed_memories), + input_tokens=total_usage.input_tokens, + output_tokens=total_usage.output_tokens, + total_tokens=total_usage.total_tokens, + ) + + # If no candidates and no existing, return empty + if not candidates: + logger.warning(f"[MM-REFLECT {reflect_id}] No candidates generated in seed phase") + return MentalModelReflectResult( + observations=[_dict_to_observation(obs) for obs in existing_observations], + version=current_version + 1, # Always increment version when refresh runs + changes={"note": "No candidate observations could be generated"}, + phases_completed=phases_completed, + duration_ms=int((time.time() - start_time) * 1000), + memories_analyzed=len(seed_memories), + input_tokens=total_usage.input_tokens, + output_tokens=total_usage.output_tokens, + total_tokens=total_usage.total_tokens, + ) + + # ========================================================================== + # PHASE 2: EVIDENCE HUNT (for new candidates only) + # ========================================================================== + logger.info(f"[MM-REFLECT {reflect_id}] Phase 2: EVIDENCE HUNT - Searching for evidence") + phase2_start = time.time() + + candidates_with_evidence = await _run_evidence_hunt_phase( + candidates=candidates, + recall_fn=recall_fn, + reflect_id=reflect_id, + ) + + phase2_duration = int((time.time() - phase2_start) * 1000) + logger.info( + f"[MM-REFLECT {reflect_id}] Phase 2 complete: Evidence gathered for {len(candidates_with_evidence)} candidates ({phase2_duration}ms)" + ) + phases_completed.append("evidence_hunt") + + # ========================================================================== + # PHASE 3: VALIDATE & REFINE (for new candidates only) + # ========================================================================== + logger.info(f"[MM-REFLECT {reflect_id}] Phase 3: VALIDATE - Validating candidates") + phase3_start = time.time() + + validate_result = await _run_validate_phase( + llm_config=llm_config, + candidates_with_evidence=candidates_with_evidence, + reflect_id=reflect_id, + ) + validated_observations = validate_result.verified_observations + total_usage = total_usage + validate_result.token_usage + + phase3_duration = int((time.time() - phase3_start) * 1000) + logger.info( + f"[MM-REFLECT {reflect_id}] Phase 3 complete: {len(validated_observations)} observations validated ({phase3_duration}ms)" + ) + phases_completed.append("validate") + + # ========================================================================== + # PHASE 4: COMPARE & MERGE + # ========================================================================== + logger.info(f"[MM-REFLECT {reflect_id}] Phase 4: COMPARE - Merging updated existing + new observations") + phase4_start = time.time() + + # Use updated existing (with new evidence) instead of original existing + observations_for_compare = updated_existing if updated_existing else existing_observations + + compare_result = await _run_compare_phase( + llm_config=llm_config, + existing_observations=observations_for_compare, + new_observations=validated_observations, + reflect_id=reflect_id, + ) + final_observations = compare_result.observations + changes = compare_result.changes + total_usage = total_usage + compare_result.token_usage + + # Add contradiction info to changes + if contradicted_observations: + changes["contradicted"] = contradicted_observations + + phase4_duration = int((time.time() - phase4_start) * 1000) + logger.info( + f"[MM-REFLECT {reflect_id}] Phase 4 complete: {len(final_observations)} final observations ({phase4_duration}ms)" + ) + phases_completed.append("compare") + + # ========================================================================== + # FINALIZE + # ========================================================================== + total_duration = int((time.time() - start_time) * 1000) + new_version = current_version + 1 + + logger.info( + f"[MM-REFLECT {reflect_id}] Complete: " + f"v{current_version}→v{new_version}, " + f"{len(final_observations)} observations, " + f"{total_duration}ms total" + ) + + return MentalModelReflectResult( + observations=final_observations, + version=new_version, + changes=changes, + phases_completed=phases_completed, + duration_ms=total_duration, + memories_analyzed=len(seed_memories), + candidates_generated=len(candidates), + candidates_validated=len(validated_observations), + input_tokens=total_usage.input_tokens, + output_tokens=total_usage.output_tokens, + total_tokens=total_usage.total_tokens, + ) + + +async def _run_seed_phase( + llm_config: "LLMProvider", + memories: list[dict], + topic: str, + max_candidates: int, + reflect_id: str, + existing_observations: list[dict] | None = None, +) -> SeedPhaseResult: + """Phase 1: Generate NEW candidate observations from diverse memories. + + If existing_observations are provided, the LLM will be instructed to skip + patterns that are already tracked, focusing only on genuinely new discoveries. + """ + prompt = build_seed_phase_prompt(memories, topic, existing_observations) + + try: + response, token_usage = await llm_config.call( + messages=[ + {"role": "system", "content": SEED_PHASE_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + response_format=SeedPhaseOutput, + scope="mm_reflect_seed", + return_usage=True, + ) + + usage = PhaseTokenUsage( + input_tokens=token_usage.input_tokens if token_usage else 0, + output_tokens=token_usage.output_tokens if token_usage else 0, + total_tokens=token_usage.total_tokens if token_usage else 0, + ) + + # Parse response + if hasattr(response, "candidates"): + candidates = response.candidates[:max_candidates] + elif isinstance(response, dict) and "candidates" in response: + candidates = [ + CandidateObservation( + content=c.get("content", ""), + seed_memory_ids=c.get("seed_memory_ids", []), + ) + for c in response["candidates"][:max_candidates] + ] + else: + # Try to parse as JSON + try: + data = json.loads(str(response)) + candidates = [ + CandidateObservation( + content=c.get("content", ""), + seed_memory_ids=c.get("seed_memory_ids", []), + ) + for c in data.get("candidates", [])[:max_candidates] + ] + except (json.JSONDecodeError, TypeError): + logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse seed phase response") + candidates = [] + + return SeedPhaseResult(candidates=candidates, token_usage=usage) + + except Exception as e: + logger.error(f"[MM-REFLECT {reflect_id}] Seed phase failed: {e}") + return SeedPhaseResult() + + +def _parse_update_existing_response(response: Any, reflect_id: str) -> UpdateExistingPhaseOutput: + """Parse LLM response into UpdateExistingPhaseOutput. + + Handles multiple response formats: Pydantic model, dict, or JSON string. + """ + # Already a Pydantic model + if isinstance(response, UpdateExistingPhaseOutput): + return response + + # Dict response - validate and convert + if isinstance(response, dict): + try: + return UpdateExistingPhaseOutput.model_validate(response) + except Exception as e: + logger.warning(f"[MM-REFLECT {reflect_id}] Failed to validate dict response: {e}") + return UpdateExistingPhaseOutput() + + # String response - try to parse as JSON + try: + data = json.loads(str(response)) + return UpdateExistingPhaseOutput.model_validate(data) + except (json.JSONDecodeError, TypeError, Exception) as e: + logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse update existing response: {e}") + return UpdateExistingPhaseOutput() + + +async def _run_update_existing_phase( + llm_config: "LLMProvider", + existing_observations: list[dict], + recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + reflect_id: str, +) -> UpdateExistingResult: + """Phase 0: Search for new evidence for existing observations. + + For each existing observation: + 1. Search for new supporting evidence + 2. Search for contradicting evidence + 3. Extract new quotes and add to the observation + 4. Flag observations with strong contradictions + """ + if not existing_observations: + return UpdateExistingResult() + + # Search for evidence for all existing observations in parallel + async def search_evidence_for_observation(obs: dict) -> ObservationWithEvidence: + content = obs.get("content", "") + existing_evidence = obs.get("evidence", []) + existing_memory_ids = {e.get("memory_id") for e in existing_evidence if isinstance(e, dict)} + + # Search for supporting and contradicting evidence + supporting_query = f"evidence supporting: {content}" + contradicting_query = f"evidence against: {content}" + + supporting_result, contradicting_result = await asyncio.gather( + recall_fn(supporting_query, 2048), + recall_fn(contradicting_query, 2048), + return_exceptions=True, + ) + + supporting_memories: list[dict] = [] + contradicting_memories: list[dict] = [] + + if isinstance(supporting_result, dict) and "memories" in supporting_result: + # Filter out memories we already have evidence from + supporting_memories = [ + m + for m in supporting_result["memories"] + if isinstance(m, dict) and m.get("id") not in existing_memory_ids + ] + if isinstance(contradicting_result, dict) and "memories" in contradicting_result: + contradicting_memories = [m for m in contradicting_result["memories"] if isinstance(m, dict)] + + return ObservationWithEvidence( + observation=obs, + supporting_memories=supporting_memories, + contradicting_memories=contradicting_memories, + ) + + # Run all searches in parallel + tasks = [search_evidence_for_observation(obs) for obs in existing_observations] + search_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Filter out exceptions and collect valid results + valid_results: list[ObservationWithEvidence] = [r for r in search_results if isinstance(r, ObservationWithEvidence)] + + # Log any errors + errors = [r for r in search_results if isinstance(r, Exception)] + if errors: + logger.warning(f"[MM-REFLECT {reflect_id}] {len(errors)} evidence search errors: {errors[:3]}") + + # If no new evidence found for any observation, return originals unchanged + has_new_evidence = any(result.supporting_memories or result.contradicting_memories for result in valid_results) + + if not has_new_evidence: + logger.info(f"[MM-REFLECT {reflect_id}] No new evidence found for existing observations") + return UpdateExistingResult(updated_observations=existing_observations) + + # Build prompt data for LLM + prompt_data = [ + { + "observation": result.observation, + "supporting_memories": result.supporting_memories, + "contradicting_memories": result.contradicting_memories, + } + for result in valid_results + ] + + # Call LLM to extract quotes from new evidence + try: + prompt = build_update_existing_prompt(prompt_data) + + response, token_usage = await llm_config.call( + messages=[ + {"role": "system", "content": UPDATE_EXISTING_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + response_format=UpdateExistingPhaseOutput, + scope="mm_reflect_update_existing", + return_usage=True, + ) + + usage = PhaseTokenUsage( + input_tokens=token_usage.input_tokens if token_usage else 0, + output_tokens=token_usage.output_tokens if token_usage else 0, + total_tokens=token_usage.total_tokens if token_usage else 0, + ) + + # Parse response into typed model + parsed_response = _parse_update_existing_response(response, reflect_id) + + if not parsed_response.updated_observations: + logger.info(f"[MM-REFLECT {reflect_id}] No updated observations in LLM response") + return UpdateExistingResult(updated_observations=existing_observations, token_usage=usage) + + # Build memory content map for quote verification + memory_content_map: dict[str, str] = {} + for result in valid_results: + for mem in result.supporting_memories + result.contradicting_memories: + mem_id = mem.get("id", "") + mem_content = mem.get("content", mem.get("text", "")) + if mem_id and mem_content: + memory_content_map[mem_id] = mem_content + + # Process updated observations + updated_observations: list[dict] = [] + contradicted_titles: list[str] = [] + total_new_evidence = 0 + + for i, updated in enumerate(parsed_response.updated_observations): + if i >= len(existing_observations): + break + + original_obs = existing_observations[i] + + # Check for contradiction + if updated.has_contradiction: + title = original_obs.get("title", f"Observation {i + 1}") + contradicted_titles.append(title) + logger.info(f"[MM-REFLECT {reflect_id}] Observation '{title}' flagged for contradiction") + + # Verify and add new evidence + existing_evidence = original_obs.get("evidence", []) + verified_new_evidence: list[dict] = [] + + for ev in updated.new_evidence: + memory_content = memory_content_map.get(ev.memory_id, "") + + # Verify quote exists in memory + if ( + ev.quote + and memory_content + and (ev.quote in memory_content or _fuzzy_quote_match(ev.quote, memory_content)) + ): + verified_new_evidence.append( + { + "memory_id": ev.memory_id, + "quote": ev.quote, + "relevance": ev.relevance, + "timestamp": ev.timestamp, + } + ) + + total_new_evidence += len(verified_new_evidence) + + # Merge existing and new evidence + merged_evidence = existing_evidence + verified_new_evidence + + # Create updated observation + updated_obs = { + **original_obs, + "evidence": merged_evidence, + } + updated_observations.append(updated_obs) + + # For any observations not in the response, keep them unchanged + for i in range(len(parsed_response.updated_observations), len(existing_observations)): + updated_observations.append(existing_observations[i]) + + logger.info( + f"[MM-REFLECT {reflect_id}] Updated {len(updated_observations)} observations, " + f"added {total_new_evidence} new evidence items" + ) + + return UpdateExistingResult( + updated_observations=updated_observations, + contradicted_titles=contradicted_titles, + token_usage=usage, + ) + + except Exception as e: + logger.error(f"[MM-REFLECT {reflect_id}] Update existing phase failed: {e}", exc_info=True) + return UpdateExistingResult(updated_observations=existing_observations) + + +async def _run_evidence_hunt_phase( + candidates: list[CandidateObservation], + recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]], + reflect_id: str, +) -> list[CandidateWithEvidence]: + """Phase 2: For each candidate, search for supporting and contradicting evidence.""" + results: list[CandidateWithEvidence] = [] + + # Run evidence searches in parallel for all candidates + async def search_evidence_for_candidate(candidate: CandidateObservation) -> CandidateWithEvidence: + # Search for supporting evidence + supporting_query = f"evidence supporting: {candidate.content}" + contradicting_query = f"evidence against: {candidate.content}" + + supporting_result, contradicting_result = await asyncio.gather( + recall_fn(supporting_query, 2048), + recall_fn(contradicting_query, 2048), + return_exceptions=True, + ) + + supporting_memories = [] + contradicting_memories = [] + + if isinstance(supporting_result, dict) and "memories" in supporting_result: + supporting_memories = supporting_result["memories"] + if isinstance(contradicting_result, dict) and "memories" in contradicting_result: + contradicting_memories = contradicting_result["memories"] + + return CandidateWithEvidence( + candidate=candidate, + supporting_memories=supporting_memories, + contradicting_memories=contradicting_memories, + ) + + # Run all searches in parallel + tasks = [search_evidence_for_candidate(c) for c in candidates] + gather_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Filter out exceptions + valid_results: list[CandidateWithEvidence] = [r for r in gather_results if isinstance(r, CandidateWithEvidence)] + + logger.info(f"[MM-REFLECT {reflect_id}] Evidence hunt: {len(valid_results)}/{len(candidates)} candidates processed") + return valid_results + + +async def _run_validate_phase( + llm_config: "LLMProvider", + candidates_with_evidence: list[CandidateWithEvidence], + reflect_id: str, +) -> ValidatePhaseResult: + """Phase 3: Validate candidates and extract exact quotes.""" + # Convert to dict format for prompt + candidates_data = [ + { + "candidate": { + "content": c.candidate.content, + }, + "supporting_memories": c.supporting_memories, + "contradicting_memories": c.contradicting_memories, + } + for c in candidates_with_evidence + ] + + prompt = build_validate_phase_prompt(candidates_data) + + try: + response, token_usage = await llm_config.call( + messages=[ + {"role": "system", "content": VALIDATE_PHASE_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + response_format=ValidatePhaseOutput, + scope="mm_reflect_validate", + return_usage=True, + ) + + usage = PhaseTokenUsage( + input_tokens=token_usage.input_tokens if token_usage else 0, + output_tokens=token_usage.output_tokens if token_usage else 0, + total_tokens=token_usage.total_tokens if token_usage else 0, + ) + + # Parse response + if hasattr(response, "observations"): + observations = response.observations + elif isinstance(response, dict) and "observations" in response: + observations = response["observations"] + else: + # Try to parse as JSON + try: + data = json.loads(str(response)) + observations = data.get("observations", []) + except (json.JSONDecodeError, TypeError): + logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse validate phase response") + observations = [] + + # Build memory content map for quote verification + memory_content_map: dict[str, str] = {} + for cwe in candidates_with_evidence: + for mem in cwe.supporting_memories + cwe.contradicting_memories: + mem_id = mem.get("id", "") + content = mem.get("content", mem.get("text", "")) + if mem_id and content: + memory_content_map[mem_id] = content + + # Verify quotes in observations + verified_observations = [] + for obs in observations: + evidence = obs.get("evidence", []) + verified_evidence = [] + + for ev in evidence: + mem_id = ev.get("memory_id", "") + quote = ev.get("quote", "") + memory_content = memory_content_map.get(mem_id, "") + + # Check if quote exists in memory (allow partial match for flexibility) + if quote and memory_content and (quote in memory_content or _fuzzy_quote_match(quote, memory_content)): + verified_evidence.append(ev) + else: + logger.debug(f"[MM-REFLECT {reflect_id}] Quote verification failed for memory {mem_id}") + + if verified_evidence: + obs["evidence"] = verified_evidence + verified_observations.append(obs) + else: + logger.debug( + f"[MM-REFLECT {reflect_id}] Observation discarded - no verified evidence: {obs.get('content', '')[:50]}" + ) + + return ValidatePhaseResult(verified_observations=verified_observations, token_usage=usage) + + except Exception as e: + logger.error(f"[MM-REFLECT {reflect_id}] Validate phase failed: {e}") + return ValidatePhaseResult() + + +def _fuzzy_quote_match(quote: str, content: str, threshold: float = 0.8) -> bool: + """Check if a quote roughly matches content (handles minor LLM variations).""" + # Normalize both strings + quote_words = set(quote.lower().split()) + content_words = set(content.lower().split()) + + if not quote_words: + return False + + # Check word overlap + overlap = len(quote_words & content_words) + similarity = overlap / len(quote_words) + + return similarity >= threshold + + +async def _run_compare_phase( + llm_config: "LLMProvider", + existing_observations: list[dict], + new_observations: list[dict], + reflect_id: str, +) -> ComparePhaseResult: + """Phase 4: Merge new observations with existing mental model.""" + # If no existing observations, just convert new ones + if not existing_observations: + final_obs = [_dict_to_observation(obs) for obs in new_observations] + changes = { + "added": [obs.get("content", "") for obs in new_observations], + "kept": [], + "updated": [], + "removed": [], + "merged": [], + } + return ComparePhaseResult(observations=final_obs, changes=changes) + + # If no new observations, keep existing + if not new_observations: + final_obs = [_dict_to_observation(obs) for obs in existing_observations] + changes = { + "added": [], + "kept": [obs.get("content", obs.get("text", "")) for obs in existing_observations], + "updated": [], + "removed": [], + "merged": [], + } + return ComparePhaseResult(observations=final_obs, changes=changes) + + prompt = build_compare_phase_prompt(existing_observations, new_observations) + + try: + response, token_usage = await llm_config.call( + messages=[ + {"role": "system", "content": COMPARE_PHASE_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + response_format=ComparePhaseOutput, + scope="mm_reflect_compare", + return_usage=True, + ) + + usage = PhaseTokenUsage( + input_tokens=token_usage.input_tokens if token_usage else 0, + output_tokens=token_usage.output_tokens if token_usage else 0, + total_tokens=token_usage.total_tokens if token_usage else 0, + ) + + # Parse response + if hasattr(response, "observations"): + observations_data = response.observations + changes = response.changes if hasattr(response, "changes") else {} + elif isinstance(response, dict): + observations_data = response.get("observations", []) + changes = response.get("changes", {}) + else: + # Try to parse as JSON + try: + data = json.loads(str(response)) + observations_data = data.get("observations", []) + changes = data.get("changes", {}) + except (json.JSONDecodeError, TypeError): + logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse compare phase response") + # Fallback: just use new observations + observations_data = new_observations + changes = {"note": "Compare phase parsing failed, using new observations"} + + # Convert to Observation objects + final_observations = [_dict_to_observation(obs) for obs in observations_data] + return ComparePhaseResult(observations=final_observations, changes=changes, token_usage=usage) + + except Exception as e: + logger.error(f"[MM-REFLECT {reflect_id}] Compare phase failed: {e}") + # Fallback: merge by keeping all + all_obs = existing_observations + new_observations + final_obs = [_dict_to_observation(obs) for obs in all_obs] + changes = {"note": f"Compare phase failed: {e}, keeping all observations"} + return ComparePhaseResult(observations=final_obs, changes=changes) + + +def _dict_to_observation(data: dict) -> Observation: + """Convert a dict to an Observation model.""" + # Handle both new format (title, content, evidence) and legacy format (title, text, memory_ids) + title = data.get("title", "") + content = data.get("content", "") + + if not content: + # Legacy format: use text as content + text = data.get("text", "") + content = text + + if not title: + # Generate title from content (first ~50 chars) + title = content[:50].strip() + ("..." if len(content) > 50 else "") + + # Parse evidence + evidence: list[ObservationEvidence] = [] + evidence_data = data.get("evidence", []) + + if evidence_data: + for ev in evidence_data: + try: + timestamp = ev.get("timestamp") + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + elif timestamp is None: + timestamp = datetime.now(timezone.utc) + + evidence.append( + ObservationEvidence( + memory_id=ev.get("memory_id", ""), + quote=ev.get("quote", ""), + relevance=ev.get("relevance", ""), + timestamp=timestamp, + ) + ) + except Exception: + pass + else: + # Legacy format: memory_ids without quotes + memory_ids = data.get("memory_ids", []) or data.get("fact_ids", []) + for mid in memory_ids: + evidence.append( + ObservationEvidence( + memory_id=mid, + quote="[migrated - quote not available]", + relevance="[migrated]", + timestamp=datetime.now(timezone.utc), + ) + ) + + # Parse created_at + created_at = data.get("created_at") + if isinstance(created_at, str): + try: + created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + except ValueError: + created_at = datetime.now(timezone.utc) + elif created_at is None: + created_at = datetime.now(timezone.utc) + + return Observation( + title=title, + content=content, + evidence=evidence, + created_at=created_at, + ) diff --git a/hindsight-api/hindsight_api/engine/reflect/models.py b/hindsight-api/hindsight_api/engine/reflect/models.py index 54156c0d..02a16868 100644 --- a/hindsight-api/hindsight_api/engine/reflect/models.py +++ b/hindsight-api/hindsight_api/engine/reflect/models.py @@ -87,21 +87,18 @@ class LLMCall(BaseModel): duration_ms: int = Field(description="Execution time in milliseconds") -class Observation(BaseModel): - """A single observation with supporting memories.""" +class DirectiveInfo(BaseModel): + """Information about a directive that was applied during reflect.""" - title: str = Field(description="Observation title/header") - text: str = Field(description="Observation content") - memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation") + id: str = Field(description="Directive mental model ID") + name: str = Field(description="Directive name") + rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied") class ReflectAgentResult(BaseModel): """Result from the reflect agent.""" text: str = Field(description="Final answer text") - observations: list[Observation] = Field( - default_factory=list, description="Structured observations (when output_mode=observations)" - ) structured_output: dict[str, Any] | None = Field( default=None, description="Structured output parsed according to provided response_schema" ) @@ -112,3 +109,6 @@ class ReflectAgentResult(BaseModel): 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") + directives_applied: list[DirectiveInfo] = Field( + default_factory=list, description="Directive mental models that affected this reflection" + ) diff --git a/hindsight-api/hindsight_api/engine/reflect/observations.py b/hindsight-api/hindsight_api/engine/reflect/observations.py new file mode 100644 index 00000000..67708b83 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/reflect/observations.py @@ -0,0 +1,248 @@ +""" +Models and utilities for evidence-grounded observations with computed trends. + +Observations are part of mental models and represent patterns/beliefs derived +from memories. Each observation must be grounded in specific evidence (quotes) +from memories, and trends are computed algorithmically from evidence timestamps. +""" + +from datetime import datetime, timedelta, timezone +from enum import Enum + +from pydantic import BaseModel, Field, computed_field, field_validator + + +class Trend(str, Enum): + """Computed trend for an observation based on evidence timestamps. + + Trends indicate how an observation's evidence is distributed over time: + - STABLE: Evidence spread across time, continues to present + - STRENGTHENING: More/denser evidence recently than before + - WEAKENING: Evidence mostly old, sparse recently + - NEW: All evidence within recent window + - STALE: No evidence in recent window (may no longer apply) + """ + + STABLE = "stable" + STRENGTHENING = "strengthening" + WEAKENING = "weakening" + NEW = "new" + STALE = "stale" + + +class ObservationEvidence(BaseModel): + """A single piece of evidence supporting an observation. + + Each evidence item must include an exact quote from the source memory + to ensure observations are grounded and verifiable. + """ + + memory_id: str = Field(description="ID of the memory unit this evidence comes from") + quote: str = Field(description="Exact quote from the memory supporting the observation") + relevance: str = Field(default="", description="Brief explanation of how this quote supports the observation") + timestamp: datetime = Field(description="When the source memory was created") + + @field_validator("timestamp", mode="before") + @classmethod + def ensure_timezone_aware(cls, v: datetime | str | None) -> datetime: + """Ensure timestamp is always timezone-aware UTC.""" + if v is None: + return datetime.now(timezone.utc) + if isinstance(v, str): + # Parse ISO format string, handling 'Z' suffix + v = datetime.fromisoformat(v.replace("Z", "+00:00")) + if isinstance(v, datetime): + if v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + raise ValueError(f"Invalid timestamp type: {type(v)}") + + +class Observation(BaseModel): + """A single observation within a mental model. + + Observations represent patterns, preferences, beliefs, or other insights + derived from memories. Each observation must be grounded in evidence + with exact quotes from source memories. + """ + + title: str = Field(description="Short summary title for the observation (5-10 words)") + content: str = Field(description="The observation content - detailed explanation of what we believe to be true") + evidence: list[ObservationEvidence] = Field(default_factory=list, description="Supporting evidence with quotes") + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), description="When this observation was first created" + ) + + @field_validator("created_at", mode="before") + @classmethod + def ensure_created_at_timezone_aware(cls, v: datetime | str | None) -> datetime: + """Ensure created_at is always timezone-aware UTC.""" + if v is None: + return datetime.now(timezone.utc) + if isinstance(v, str): + v = datetime.fromisoformat(v.replace("Z", "+00:00")) + if isinstance(v, datetime): + if v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + raise ValueError(f"Invalid created_at type: {type(v)}") + + @computed_field + @property + def trend(self) -> Trend: + """Compute trend from evidence timestamps.""" + return compute_trend(self.evidence) + + @computed_field + @property + def evidence_span(self) -> dict[str, str | None]: + """Get the time span covered by evidence.""" + if not self.evidence: + return {"from": None, "to": None} + timestamps = [e.timestamp for e in self.evidence] + return { + "from": min(timestamps).isoformat(), + "to": max(timestamps).isoformat(), + } + + @computed_field + @property + def evidence_count(self) -> int: + """Number of evidence items supporting this observation.""" + return len(self.evidence) + + +def compute_trend( + evidence: list[ObservationEvidence], + now: datetime | None = None, + recent_days: int = 30, + old_days: int = 90, +) -> Trend: + """Compute the trend for an observation based on evidence timestamps. + + The trend indicates how the evidence is distributed over time: + - STABLE: Evidence spread across time, continues to present + - STRENGTHENING: More evidence recently than historically + - WEAKENING: Evidence mostly old, sparse recently + - NEW: All evidence is recent (within recent_days) + - STALE: No evidence in recent window + + Args: + evidence: List of evidence items with timestamps + now: Reference time for calculations (defaults to current UTC time) + recent_days: Number of days to consider "recent" (default 30) + old_days: Number of days to consider "old" (default 90) + + Returns: + Computed Trend enum value + """ + if now is None: + now = datetime.now(timezone.utc) + + # Ensure now is timezone-aware + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + if not evidence: + return Trend.STALE + + recent_cutoff = now - timedelta(days=recent_days) + old_cutoff = now - timedelta(days=old_days) + + # Normalize timestamps to UTC for comparison + def normalize_ts(ts: datetime) -> datetime: + if ts.tzinfo is None: + return ts.replace(tzinfo=timezone.utc) + return ts + + recent = [e for e in evidence if normalize_ts(e.timestamp) > recent_cutoff] + old = [e for e in evidence if normalize_ts(e.timestamp) < old_cutoff] + middle = [e for e in evidence if old_cutoff <= normalize_ts(e.timestamp) <= recent_cutoff] + + # No recent evidence = stale + if not recent: + return Trend.STALE + + # All evidence is recent = new + if not old and not middle: + return Trend.NEW + + # Compare density (evidence per day) + recent_density = len(recent) / recent_days if recent_days > 0 else 0 + older_period = old_days - recent_days + older_density = (len(old) + len(middle)) / older_period if older_period > 0 else 0 + + # Avoid division by zero + if older_density == 0: + return Trend.NEW + + ratio = recent_density / older_density + + if ratio > 1.5: + return Trend.STRENGTHENING + elif ratio < 0.5: + return Trend.WEAKENING + else: + return Trend.STABLE + + +class CandidateObservation(BaseModel): + """A candidate observation generated during the seed phase. + + Candidates are preliminary observations that need evidence validation + before becoming full observations. + """ + + content: str = Field(description="The proposed observation content") + seed_memory_ids: list[str] = Field(default_factory=list, description="Memory IDs that inspired this candidate") + + +class CandidateWithEvidence(BaseModel): + """A candidate observation with gathered supporting and contradicting evidence.""" + + candidate: CandidateObservation + supporting_memories: list[dict] = Field(default_factory=list, description="Memories that support this observation") + contradicting_memories: list[dict] = Field( + default_factory=list, description="Memories that contradict this observation" + ) + + +class MentalModelSnapshot(BaseModel): + """A versioned snapshot of a mental model's observations. + + Used for tracking changes over time and enabling diff views. + """ + + version: int = Field(description="Version number (1-indexed)") + observations: list[Observation] = Field(default_factory=list, description="Observations at this version") + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), description="When this version was created" + ) + reflect_summary: str | None = Field(default=None, description="Summary of changes in this version") + + +def verify_evidence_quotes( + observation: Observation, + memories: dict[str, str], +) -> tuple[bool, list[str]]: + """Verify that all evidence quotes exist in the referenced memories. + + Args: + observation: The observation to verify + memories: Dict mapping memory_id to memory content + + Returns: + Tuple of (is_valid, list of error messages) + """ + errors = [] + + for evidence in observation.evidence: + memory_content = memories.get(evidence.memory_id) + if memory_content is None: + errors.append(f"Memory {evidence.memory_id} not found") + continue + + if evidence.quote not in memory_content: + errors.append(f"Quote not found in memory {evidence.memory_id}: '{evidence.quote[:50]}...'") + + return len(errors) == 0, errors diff --git a/hindsight-api/hindsight_api/engine/reflect/prompts.py b/hindsight-api/hindsight_api/engine/reflect/prompts.py index 77d741b6..8ff572d9 100644 --- a/hindsight-api/hindsight_api/engine/reflect/prompts.py +++ b/hindsight-api/hindsight_api/engine/reflect/prompts.py @@ -6,10 +6,111 @@ import json from typing import Any +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 + + Returns: + List of directive rule strings + """ + 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}") + return rules + + +def build_directives_section(directives: list[dict[str, Any]]) -> str: + """ + Build the directives section for the system prompt. + + Directives are hard rules that MUST be followed in all responses. + + Args: + directives: List of directive mental models with observations + """ + if not directives: + return "" + + rules = _extract_directive_rules(directives) + if not rules: + return "" + + parts = [ + "## DIRECTIVES (MANDATORY)", + "These are hard rules you MUST follow in ALL responses:", + "", + ] + + for rule in rules: + parts.append(f"- {rule}") + + parts.extend( + [ + "", + "NEVER violate these directives, even if other context suggests otherwise.", + "IMPORTANT: Do NOT explain or justify how you handled directives in your answer. Just follow them silently.", + "", + ] + ) + return "\n".join(parts) + + +def build_directives_reminder(directives: list[dict[str, Any]]) -> str: + """ + Build a reminder section for directives to place at the end of the prompt. + + Args: + directives: List of directive mental models with observations + """ + if not directives: + return "" + + rules = _extract_directive_rules(directives) + if not rules: + return "" + + parts = [ + "", + "## REMINDER: MANDATORY DIRECTIVES", + "Before responding, ensure your answer complies with ALL of these directives:", + "", + ] + + for i, rule in enumerate(rules, 1): + parts.append(f"{i}. {rule}") + + parts.append("") + parts.append("Your response will be REJECTED if it violates any directive above.") + parts.append("Do NOT include any commentary about how you handled directives - just follow them.") + return "\n".join(parts) + + def build_system_prompt_for_tools( bank_profile: dict[str, Any], context: str | None = None, - output_mode: str = "answer", + directives: list[dict[str, Any]] | None = None, ) -> str: """ Build the system prompt for tool-calling reflect agent. @@ -19,128 +120,90 @@ def build_system_prompt_for_tools( Args: bank_profile: Bank profile with name and mission context: Optional additional context - output_mode: "answer" for plain text response, "observations" for structured observations + directives: Optional list of directive mental models to inject as hard rules """ name = bank_profile.get("name", "Assistant") mission = bank_profile.get("mission", "") - # Build critical rules based on mode - if output_mode == "observations": - no_info_rule = "- Only say 'I don't have information' AFTER trying recall with no relevant results" - else: - no_info_rule = ( - "- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results" - ) + no_info_rule = ( + "- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results" + ) - parts = [ - "You are a reflection agent that answers questions by reasoning over retrieved memories.", - "", - "## 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, - "", - "## How to Reason", - "- If memories mention someone did an activity, you can infer they likely enjoyed it", - "- Synthesize a coherent narrative from related memories", - "- 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)", - "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')", - "GOOD: Break it down into component searches:", - " 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", - ] + parts = [] - # Mode-specific workflow and output format - if output_mode == "observations": - # Observations mode: for mental model generation - no mental model lookup tools - parts.extend( - [ - "1. DECOMPOSE the topic into component searches (see Query Strategy above)", - " - Don't search for the topic name itself - search for related concepts", - " - Example for 'Coffee preferences': search 'coffee', 'drinks', 'morning routine', 'caffeine'", - "2. Run multiple recall() calls with varied, targeted queries", - "3. IMPORTANT: Use expand(memory_ids, 'chunk') to verify memories before using them", - " - Always verify the source chunk to confirm the memory is actually relevant", - " - Don't assume a memory is relevant based on the summary alone", - " - Only include memories you've verified via expand()", - "4. When ready, call done() with MULTIPLE structured observations", - "", - "## Output Format: MULTIPLE Structured Observations", - "", - "CRITICAL: You MUST create MULTIPLE separate observations in the array - one for each theme.", - "Do NOT put all content in a single observation!", - "", - "- Create 3-8 separate observations, each as its OWN item in the observations array", - "- Each observation covers ONE specific theme (preferences, history, relationships, etc.)", - "- Each observation has: title (short header), text (content), memory_ids (full UUIDs)", - "", - "Text format for each observation:", - "- Main insight or finding (no markdown headers)", - "- End with 'Key evidence:' section containing DIRECT QUOTES from memories in *italics*", - "- Quote the actual memory text, don't summarize - use *italics* for citations", - "", - "Example done() call with MULTIPLE observations:", - "```json", - "{", - ' "observations": [', - " {", - ' "title": "Work Preferences",', - ' "text": "Prefers async communication and flexible schedules.\\n\\nKey evidence:\\n- *I prefer Slack over calls for most communication*\\n- *Flexible hours help me do my best work*",', - ' "memory_ids": ["abc123-full-uuid", "def456-full-uuid"]', - " },", - " {", - ' "title": "Technical Background",', - ' "text": "Has extensive ML experience spanning a decade.\\n\\nKey evidence:\\n- *I have 10 years of experience in machine learning*\\n- *Led the ML team at my previous company*",', - ' "memory_ids": ["ghi789-full-uuid"]', - " }", - " ]", - "}", - "```", - ] - ) - else: - # Answer mode: include mental model lookup in workflow - 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. If you discover an important recurring topic worth tracking, use learn() to create a mental model", - "7. When ready, call done() with your answer and supporting memory_ids", - "", - "## When to Use learn()", - "Use learn() to create a new mental model when you discover:", - "- A person, project, or concept that appears frequently in memories", - "- An important topic the user seems to care about but has no mental model for", - "- A pattern or relationship worth synthesizing for future reference", - "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", - ] - ) + # Inject directives at the VERY START for maximum prominence + if directives: + parts.append(build_directives_section(directives)) + + parts.extend( + [ + "You are a reflection agent that answers questions by reasoning over retrieved memories.", + "", + ] + ) + + parts.extend( + [ + "## 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, + "", + "## How to Reason", + "- If memories mention someone did an activity, you can infer they likely enjoyed it", + "- Synthesize a coherent narrative from related memories", + "- 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)", + "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')", + "GOOD: Break it down into component searches:", + " 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 + 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", + ] + ) parts.append("") parts.append(f"## Memory Bank: {name}") @@ -164,6 +227,10 @@ def build_system_prompt_for_tools( if context: parts.append(f"\n## Additional Context\n{context}") + # Add directive reminder at the END for recency effect + if directives: + parts.append(build_directives_reminder(directives)) + return "\n".join(parts) @@ -310,3 +377,386 @@ Your approach: Only say "I don't have information" if the retrieved data is truly unrelated to the question. Do NOT fabricate information that has no basis in the retrieved data.""" + + +# ============================================================================= +# 4-Phase Mental Model Reflect Prompts +# ============================================================================= + +SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations. + +Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic. + +## Important: Avoid Redundancy +If existing observations are provided, DO NOT generate candidates that are essentially the same. +Focus on discovering NEW patterns not already covered by existing observations. + +## Rules +- Generate 5-15 candidate observations for NEW patterns only +- Each candidate should be specific and testable (can be supported or contradicted by evidence) +- Note which memory IDs inspired each candidate (these are seeds, not final evidence) +- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better +- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for +- Skip patterns that are already covered by existing observations + +## Output Format +Return a JSON array of candidate observations: +```json +{ + "candidates": [ + { + "content": "The specific observation/belief/pattern - be detailed and specific", + "seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"] + } + ] +} +``` + +Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations. +Prefer candidates with MORE seed memories - they're more likely to be real patterns. +Return an empty candidates array if no genuinely new patterns are found.""" + + +def build_seed_phase_prompt( + memories: list[dict], + topic: str | None = None, + existing_observations: list[dict] | None = None, +) -> str: + """Build the user prompt for the seed phase. + + Args: + memories: List of memories to analyze + topic: Optional topic focus for the mental model + existing_observations: Optional list of existing observations to avoid rediscovering + """ + parts = [] + + if topic: + parts.append(f"## Topic Focus\n{topic}\n") + + # Include existing observations so we don't rediscover them + if existing_observations: + parts.append("## Existing Observations (DO NOT regenerate these)") + parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n") + for i, obs in enumerate(existing_observations, 1): + title = obs.get("title", "") + content = obs.get("content", "") + parts.append(f"{i}. **{title}**: {content}\n") + parts.append("") + + parts.append("## Memories to Analyze") + parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n") + + for mem in memories: + mem_id = mem.get("id", "unknown") + content = mem.get("content", mem.get("text", "")) + timestamp = mem.get("timestamp", mem.get("created_at", "")) + parts.append(f"[{mem_id}] ({timestamp}): {content}\n") + + parts.append("\n## Instructions") + if existing_observations: + parts.append("Generate candidate observations for NEW patterns not already covered above.") + parts.append("If all patterns are already covered by existing observations, return an empty candidates array.") + else: + parts.append("Generate candidate observations based on patterns you see in these memories.") + parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.") + + return "\n".join(parts) + + +VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence. + +For each candidate, you have: +- Supporting memories (evidence FOR the observation) +- Contradicting memories (evidence AGAINST the observation) + +## Your Task +1. Evaluate each candidate based on the evidence +2. For valid candidates, extract EXACT QUOTES from supporting memories +3. Discard candidates with insufficient or contradicting evidence +4. Merge similar candidates into single, refined observations + +## Rules for Quotes +- Quotes must be EXACT text from the memory, not paraphrased +- Each quote should directly support the observation +- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+) +- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong +- Stronger observations have more supporting evidence - aim for comprehensive coverage + +## Output Format +Return validated observations with evidence: +```json +{ + "observations": [ + { + "title": "Short descriptive title (3-8 words) - like a headline", + "content": "The full observation content - detailed explanation of the pattern/belief", + "evidence": [ + { + "memory_id": "exact_memory_id", + "quote": "Exact quote from the memory text", + "relevance": "Brief explanation of how this supports the observation", + "timestamp": "2024-01-15T10:00:00Z" + } + ] + } + ], + "discarded": [ + { + "content": "The discarded candidate", + "reason": "Why it was discarded (insufficient evidence, contradicted, etc.)" + } + ], + "merged": [ + { + "from": ["candidate 1 content", "candidate 2 content"], + "into": "The merged observation content" + } + ] +} +``` + +## Title Guidelines +- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast") +- NOT a truncated version of the content +- Think of it as a category/tag for the observation + +Be rigorous: only keep observations with clear, verifiable evidence from multiple memories.""" + + +def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str: + """Build the user prompt for the validate phase.""" + parts = ["## Candidates to Validate\n"] + + for i, item in enumerate(candidates_with_evidence, 1): + candidate = item.get("candidate", {}) + supporting = item.get("supporting_memories", []) + contradicting = item.get("contradicting_memories", []) + + parts.append(f"### Candidate {i}: {candidate.get('content', '')}") + + if supporting: + parts.append("\n**Supporting Evidence:**") + for mem in supporting: + mem_id = mem.get("id", "unknown") + content = mem.get("content", mem.get("text", "")) + timestamp = mem.get("timestamp", mem.get("created_at", "")) + parts.append(f"- [{mem_id}] ({timestamp}): {content}") + + if contradicting: + parts.append("\n**Contradicting Evidence:**") + for mem in contradicting: + mem_id = mem.get("id", "unknown") + content = mem.get("content", mem.get("text", "")) + timestamp = mem.get("timestamp", mem.get("created_at", "")) + parts.append(f"- [{mem_id}] ({timestamp}): {content}") + + if not supporting and not contradicting: + parts.append("\n*No additional evidence found*") + + parts.append("") + + parts.append("## Instructions") + parts.append("1. Evaluate each candidate based on its evidence") + parts.append("2. Keep candidates with strong supporting evidence") + parts.append("3. Discard candidates with no evidence or strong contradictions") + parts.append("4. Merge similar candidates") + parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence") + + return "\n".join(parts) + + +COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model. + +You have: +- EXISTING observations (from the current mental model) +- NEW observations (from this reflect cycle) + +## Your Task +Produce the final, complete mental model by: +1. Keeping existing observations that are still valid +2. Updating existing observations with new evidence (ADD new evidence to existing) +3. Adding new observations that don't overlap with existing +4. Removing existing observations that are contradicted by new evidence +5. Merging overlapping observations + +## Rules +- The final model should have no contradictions +- Each observation must have evidence with exact quotes +- COMBINE evidence from both existing and new observations +- If an existing observation has new supporting evidence, ADD ALL the new evidence to it +- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great) +- Observations with more evidence are more reliable - don't limit the number of quotes + +## Output Format +Return the complete, final mental model: +```json +{ + "observations": [ + { + "title": "Short descriptive title (3-8 words)", + "content": "Full observation content - detailed explanation", + "evidence": [ + { + "memory_id": "id", + "quote": "exact quote", + "relevance": "explanation", + "timestamp": "ISO timestamp" + } + ], + "created_at": "ISO timestamp of when observation was first created" + } + ], + "changes": { + "kept": ["Observation that was kept unchanged"], + "updated": [{"from": "old content", "to": "new content", "reason": "why"}], + "added": ["New observation that was added"], + "removed": [{"content": "removed observation", "reason": "why removed"}], + "merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}] + } +} +```""" + + +def build_compare_phase_prompt( + existing_observations: list[dict], + new_observations: list[dict], +) -> str: + """Build the user prompt for the compare phase.""" + parts = [] + + parts.append("## Existing Mental Model Observations") + if existing_observations: + for i, obs in enumerate(existing_observations, 1): + title = obs.get("title", "") + content = obs.get("content", obs.get("text", "")) + evidence = obs.get("evidence", []) + parts.append(f"\n### Existing {i}: {title}") + parts.append(f"Content: {content}") + if evidence: + parts.append(f"Evidence ({len(evidence)} items):") + for ev in evidence[:5]: # Show max 5 evidence items + parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"') + if len(evidence) > 5: + parts.append(f" ... and {len(evidence) - 5} more") + else: + parts.append("*No existing observations*") + + parts.append("\n## New Observations from This Reflect") + if new_observations: + for i, obs in enumerate(new_observations, 1): + title = obs.get("title", "") + content = obs.get("content", "") + evidence = obs.get("evidence", []) + parts.append(f"\n### New {i}: {title}") + parts.append(f"Content: {content}") + if evidence: + parts.append(f"Evidence ({len(evidence)} items):") + for ev in evidence: + parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"') + else: + parts.append("*No new observations*") + + parts.append("\n## Instructions") + parts.append("Merge these into a coherent, non-contradictory mental model.") + parts.append("Preserve all valid evidence. Remove stale or contradicted observations.") + + return "\n".join(parts) + + +# ============================================================================= +# UPDATE EXISTING Phase Prompts (for diff-based refresh) +# ============================================================================= + +UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence. + +For each existing observation, you have been given: +- The original observation (title, content, existing evidence) +- Newly found supporting memories +- Newly found contradicting memories + +## Your Task +1. Extract EXACT QUOTES from new supporting memories to add to the observation +2. Flag observations with strong contradicting evidence for potential removal +3. Keep existing evidence intact - only ADD new evidence + +## Rules for Quotes +- Quotes must be EXACT text from the memory, not paraphrased +- Each quote should directly support the observation +- Include ALL relevant quotes from the new memories + +## Output Format +Return updated observations with new evidence: +```json +{ + "updated_observations": [ + { + "title": "Original title", + "content": "Original content", + "existing_evidence_count": 5, + "new_evidence": [ + { + "memory_id": "exact_memory_id", + "quote": "Exact quote from the memory text", + "relevance": "Brief explanation of how this supports the observation", + "timestamp": "2024-01-15T10:00:00Z" + } + ], + "has_contradiction": false, + "contradiction_note": null + } + ] +} +``` + +If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note.""" + + +def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str: + """Build the user prompt for the update existing phase. + + Args: + observations_with_evidence: List of existing observations with new evidence found + """ + parts = ["## Existing Observations to Update\n"] + + for i, item in enumerate(observations_with_evidence, 1): + obs = item.get("observation", {}) + supporting = item.get("supporting_memories", []) + contradicting = item.get("contradicting_memories", []) + + title = obs.get("title", "") + content = obs.get("content", "") + existing_evidence = obs.get("evidence", []) + + parts.append(f"### Observation {i}: {title}") + parts.append(f"Content: {content}") + parts.append(f"Existing evidence count: {len(existing_evidence)}") + + if supporting: + parts.append("\n**New Supporting Memories:**") + for mem in supporting: + mem_id = mem.get("id", "unknown") + mem_content = mem.get("content", mem.get("text", "")) + timestamp = mem.get("timestamp", mem.get("created_at", "")) + parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}") + + if contradicting: + parts.append("\n**New Contradicting Memories:**") + for mem in contradicting: + mem_id = mem.get("id", "unknown") + mem_content = mem.get("content", mem.get("text", "")) + timestamp = mem.get("timestamp", mem.get("created_at", "")) + parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}") + + if not supporting and not contradicting: + parts.append("\n*No new evidence found*") + + parts.append("") + + parts.append("## Instructions") + parts.append("1. Extract EXACT quotes from new supporting memories") + parts.append("2. Flag observations with strong contradictions") + parts.append("3. Return the updated observations with new evidence added") + + return "\n".join(parts) diff --git a/hindsight-api/hindsight_api/engine/reflect/tools.py b/hindsight-api/hindsight_api/engine/reflect/tools.py index 29216c99..c7fba3ff 100644 --- a/hindsight-api/hindsight_api/engine/reflect/tools.py +++ b/hindsight-api/hindsight_api/engine/reflect/tools.py @@ -5,9 +5,11 @@ Tool implementations for the reflect agent. import logging import re import uuid +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from .models import MentalModelInput +from .observations import Observation, ObservationEvidence, Trend if TYPE_CHECKING: from asyncpg import Connection @@ -26,6 +28,37 @@ def generate_model_id(name: str) -> str: return normalized[:50] +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( conn: "Connection", bank_id: str, @@ -66,18 +99,8 @@ async def tool_lookup( obs_data = json.loads(obs_data) observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data - # Normalize observation format: map memory_ids/fact_ids to based_on - observations = [] - for obs in observations_raw: - if isinstance(obs, dict): - based_on = obs.get("memory_ids") or obs.get("fact_ids") or [] - observations.append( - { - "title": obs.get("title", ""), - "text": obs.get("text", ""), - "based_on": based_on, - } - ) + # Parse observations into typed models + observations = _parse_observations(observations_raw) return { "found": True, @@ -86,7 +109,7 @@ async def tool_lookup( "subtype": row["subtype"], "name": row["name"], "description": row["description"], - "observations": observations, # [{title, text, based_on}, ...] + "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, }, @@ -95,6 +118,8 @@ async def tool_lookup( 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": @@ -103,7 +128,7 @@ async def tool_lookup( """ SELECT id, subtype, name, description FROM mental_models - WHERE bank_id = $1 AND tags @> $2::varchar[] + WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive' ORDER BY last_updated DESC NULLS LAST, created_at DESC """, bank_id, @@ -115,7 +140,7 @@ async def tool_lookup( """ SELECT id, subtype, name, description FROM mental_models - WHERE bank_id = $1 AND tags && $2::varchar[] + WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive' ORDER BY last_updated DESC NULLS LAST, created_at DESC """, bank_id, @@ -126,7 +151,7 @@ async def tool_lookup( """ SELECT id, subtype, name, description FROM mental_models - WHERE bank_id = $1 + WHERE bank_id = $1 AND subtype != 'directive' ORDER BY last_updated DESC NULLS LAST, created_at DESC """, bank_id, diff --git a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py index ce4fe081..060e3bdd 100644 --- a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py +++ b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py @@ -4,8 +4,6 @@ Tool schema definitions for the reflect agent. These are OpenAI-format tool definitions used with native tool calling. """ -from typing import Literal - # Tool definitions in OpenAI format TOOL_LIST_MENTAL_MODELS = { "type": "function", @@ -134,68 +132,76 @@ TOOL_DONE_ANSWER = { }, } -TOOL_DONE_OBSERVATIONS = { - "type": "function", - "function": { - "name": "done", - "description": "Signal completion with MULTIPLE structured observations. Each observation must be a SEPARATE item in the array covering ONE theme. Do NOT combine all content into a single observation.", - "parameters": { - "type": "object", - "properties": { - "observations": { - "type": "array", - "minItems": 3, - "items": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Short header for this observation's theme (e.g., 'Work Style', 'Technical Skills')", - }, - "text": { - "type": "string", - "description": "Observation content about ONE theme. End with 'Key evidence:' containing text citations (summaries of what memories say), NOT memory IDs.", - }, - "memory_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "Full UUIDs of memories supporting this observation (put IDs here, not in text)", - }, - }, - "required": ["title", "text", "memory_ids"], + +def _build_done_tool_with_directives(directive_rules: list[str]) -> dict: + """ + Build the done tool schema with directive compliance field. + + When directives are present, adds a required field that forces the agent + to confirm compliance with each directive before submitting. + + 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)) + + # Build the tool with directive compliance field + return { + "type": "function", + "function": { + "name": "done", + "description": ( + "Signal completion with your final answer. IMPORTANT: You must confirm directive compliance before submitting. " + "Your answer will be REJECTED if it violates any directive." + ), + "parameters": { + "type": "object", + "properties": { + "answer": { + "type": "string", + "description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.", + }, + "memory_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)", + }, + "model_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of mental model IDs that support your answer", + }, + "directive_compliance": { + "type": "string", + "description": f"REQUIRED: Confirm your answer complies with ALL directives. List each directive and how your answer follows it:\n{rules_list}\n\nFormat: 'Directive 1: [how answer complies]. Directive 2: [how answer complies]...'", }, - "description": "Array of 3-8 observations, each covering a DIFFERENT aspect/theme. Do NOT put everything in one observation.", }, + "required": ["answer", "directive_compliance"], }, - "required": ["observations"], }, - }, -} + } -def get_reflect_tools( - enable_learn: bool = True, output_mode: Literal["answer", "observations"] = "answer" -) -> list[dict]: +def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]: """ Get the list of tools for the reflect agent. Args: enable_learn: Whether to include the learn tool - output_mode: "answer" or "observations" - determines done tool format - In observations mode, mental model tools are excluded to avoid - using potentially outdated models during regeneration. + 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 = [] - # In answer mode, include mental model tools for lookup - # In observations mode (mental model generation), exclude them to avoid circular references - if output_mode == "answer": - tools.append(TOOL_LIST_MENTAL_MODELS) - tools.append(TOOL_GET_MENTAL_MODEL) - + # 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: @@ -203,9 +209,9 @@ def get_reflect_tools( tools.append(TOOL_EXPAND) - # Add appropriate done tool based on output mode - if output_mode == "observations": - tools.append(TOOL_DONE_OBSERVATIONS) + # Use directive-aware done tool if directives are present + if directive_rules: + tools.append(_build_done_tool_with_directives(directive_rules)) else: tools.append(TOOL_DONE_ANSWER) diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py index 4b9944e8..42fa4cff 100644 --- a/hindsight-api/hindsight_api/engine/response_models.py +++ b/hindsight-api/hindsight_api/engine/response_models.py @@ -58,6 +58,14 @@ class MentalModelRef(BaseModel): summary: str | None = Field(default=None, description="Full summary (when looked up in detail)") +class DirectiveRef(BaseModel): + """Reference to a directive that was applied during reflect.""" + + id: str = Field(description="Directive mental model ID") + name: str = Field(description="Directive name") + rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied") + + class TokenUsage(BaseModel): """ Token usage metrics for LLM calls. @@ -252,7 +260,11 @@ class ReflectResult(BaseModel): ) mental_models: list[MentalModelRef] = Field( default_factory=list, - description="Mental models accessed during reflection. Only present when include.facts is enabled.", + description="Mental models accessed during reflection, including directives (subtype='directive').", + ) + directives_applied: list[DirectiveRef] = Field( + default_factory=list, + description="Directive mental models that were applied during this reflection.", ) diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py index fd84a272..87a271e3 100644 --- a/hindsight-api/hindsight_api/extensions/__init__.py +++ b/hindsight-api/hindsight_api/extensions/__init__.py @@ -27,6 +27,8 @@ from hindsight_api.extensions.operation_validator import ( RecallResult, ReflectContext, ReflectResultContext, + RefreshMentalModelContext, + RefreshMentalModelResult, RetainContext, RetainResult, ValidationResult, @@ -54,6 +56,8 @@ __all__ = [ "RecallResult", "ReflectContext", "ReflectResultContext", + "RefreshMentalModelContext", + "RefreshMentalModelResult", "RetainContext", "RetainResult", "ValidationResult", diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py index a1dec0eb..8dd88eaa 100644 --- a/hindsight-api/hindsight_api/extensions/operation_validator.py +++ b/hindsight-api/hindsight_api/extensions/operation_validator.py @@ -97,6 +97,18 @@ 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) # ============================================================================= @@ -164,6 +176,27 @@ 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. @@ -265,6 +298,25 @@ 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) # ========================================================================= @@ -325,3 +377,28 @@ class OperationValidatorExtension(Extension, ABC): - error: Error message (if failed) """ pass + + async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None: + """ + Called after a refresh mental model operation completes (success or failure). + + Override this method to implement post-operation logic such as: + - Token usage tracking and billing + - Audit logging + - Metrics collection + + Args: + result: Result context containing: + - bank_id: Bank identifier + - model_id: Mental model ID + - request_context: Request context with auth info + - model_name: Name of the mental model (if success) + - observations_count: Number of observations generated + - input_tokens: Number of input tokens used + - output_tokens: Number of output tokens used + - total_tokens: Total tokens used (input + output) + - duration_ms: Total operation duration in milliseconds + - success: Whether the operation succeeded + - error: Error message (if failed) + """ + pass diff --git a/hindsight-api/tests/test_extensions.py b/hindsight-api/tests/test_extensions.py index 88a2f5d3..1b7bf103 100644 --- a/hindsight-api/tests/test_extensions.py +++ b/hindsight-api/tests/test_extensions.py @@ -17,6 +17,8 @@ from hindsight_api.extensions import ( RecallResult, ReflectContext, ReflectResultContext, + RefreshMentalModelContext, + RefreshMentalModelResult, RequestContext, RetainContext, RetainResult, @@ -93,6 +95,7 @@ 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 @@ -118,6 +121,16 @@ 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): """ @@ -132,10 +145,12 @@ 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) @@ -149,6 +164,12 @@ 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) @@ -158,6 +179,11 @@ 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. @@ -515,6 +541,105 @@ class TestOperationHooksParameters: assert len(validator.pre_recall_calls) == 1 assert len(validator.post_recall_calls) == 1 + @pytest.mark.asyncio + async def test_refresh_mental_model_pre_hook_receives_all_parameters( + self, memory_with_tracking_validator + ): + """Pre-refresh-mental-model hook receives all user-provided parameters.""" + import uuid + + memory, validator = memory_with_tracking_validator + bank_id = f"test-refresh-mm-params-{uuid.uuid4().hex[:8]}" + ctx = RequestContext(api_key="test-key") + + # Create bank first (get_bank_profile auto-creates if needed) + await memory.get_bank_profile(bank_id, request_context=ctx) + + # Create a pinned mental model + model = await memory.create_mental_model( + bank_id=bank_id, + name="Test Model", + description="Test description", + subtype="pinned", + request_context=ctx, + ) + + assert model is not None + model_id = model["id"] + + # Attempt to refresh (may not actually refresh if no data, but hook should be called) + try: + await memory.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + request_context=ctx, + ) + except Exception: + pass # May fail if no data + + # Check pre-hook was called + assert len(validator.pre_refresh_mental_model_calls) == 1 + pre_ctx = validator.pre_refresh_mental_model_calls[0] + assert pre_ctx.bank_id == bank_id + assert pre_ctx.model_id == model_id + assert pre_ctx.request_context == ctx + + @pytest.mark.asyncio + async def test_refresh_mental_model_post_hook_receives_token_usage( + self, memory_with_tracking_validator + ): + """Post-refresh-mental-model hook receives token usage information.""" + import uuid + + memory, validator = memory_with_tracking_validator + bank_id = f"test-refresh-mm-tokens-{uuid.uuid4().hex[:8]}" + ctx = RequestContext(api_key="test-key") + + # Store some content first + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + {"content": "Alice is a software engineer who works on machine learning."}, + {"content": "Alice enjoys hiking and outdoor activities on weekends."}, + {"content": "Alice has been working at the company for 5 years."}, + ], + request_context=ctx, + ) + + # Create a pinned mental model + model = await memory.create_mental_model( + bank_id=bank_id, + name="Alice Profile", + description="Profile of Alice including work and hobbies", + subtype="pinned", + request_context=ctx, + ) + + if model: + model_id = model["id"] + + # Refresh the mental model + result = await memory.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + request_context=ctx, + ) + + # Check post-hook was called with token usage + if validator.post_refresh_mental_model_calls: + post_result = validator.post_refresh_mental_model_calls[0] + assert post_result.bank_id == bank_id + assert post_result.model_id == model_id + assert post_result.request_context == ctx + assert post_result.success is True + assert post_result.error is None + + # Token usage should be populated (may be 0 if refresh was skipped) + assert post_result.total_tokens >= 0 + assert post_result.input_tokens >= 0 + assert post_result.output_tokens >= 0 + assert post_result.duration_ms >= 0 + class TestTenantExtension: """Tests for TenantExtension and ApiKeyTenantExtension.""" diff --git a/hindsight-api/tests/test_llm_tools.py b/hindsight-api/tests/test_llm_tools.py index 6eeab26a..6947eb2e 100644 --- a/hindsight-api/tests/test_llm_tools.py +++ b/hindsight-api/tests/test_llm_tools.py @@ -259,23 +259,11 @@ class TestReflectToolSchemas: assert "recall" in tool_names assert "done" in tool_names - def test_get_reflect_tools_observations_mode(self): - """Test getting reflect tools with observations output mode.""" - from hindsight_api.engine.reflect.tools_schema import get_reflect_tools - - tools = get_reflect_tools(output_mode="observations") - - done_tool = next(t for t in tools if t["function"]["name"] == "done") - params = done_tool["function"]["parameters"]["properties"] - - assert "observations" in params - assert "answer" not 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 - tools = get_reflect_tools(output_mode="answer") + tools = get_reflect_tools() done_tool = next(t for t in tools if t["function"]["name"] == "done") params = done_tool["function"]["parameters"]["properties"] diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py index 0923fad0..7faccae7 100644 --- a/hindsight-api/tests/test_main_module.py +++ b/hindsight-api/tests/test_main_module.py @@ -363,6 +363,7 @@ from hindsight_api.extensions import ( RetainContext, RecallContext, ReflectContext, + RefreshMentalModelContext, ) @@ -394,3 +395,6 @@ class MockOperationValidator(OperationValidatorExtension): async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: return ValidationResult.accept() + + async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult: + return ValidationResult.accept() diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py index 0dfb31f5..14f95240 100644 --- a/hindsight-api/tests/test_mental_models.py +++ b/hindsight-api/tests/test_mental_models.py @@ -793,3 +793,642 @@ class TestMentalModelTags: ) assert "tags" in model assert isinstance(model["tags"], list) + + +class TestDirectives: + """Test directive mental model functionality.""" + + async def test_create_directive(self, memory: MemoryEngine, request_context): + """Test creating a directive mental model with user-provided observations.""" + bank_id = f"test-directive-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create a directive with observations + model = await memory.create_mental_model( + bank_id=bank_id, + name="Competitor Policy", + description="Rules about mentioning competitors", + subtype="directive", + observations=[ + {"title": "Never mention", "content": "Never mention competitor product names directly"}, + {"title": "Redirect", "content": "If asked about competitors, redirect to our features"}, + ], + request_context=request_context, + ) + + assert model["name"] == "Competitor Policy" + assert model["description"] == "Rules about mentioning competitors" + assert model["subtype"] == "directive" + assert len(model["observations"]) == 2 + assert model["observations"][0].title == "Never mention" + assert model["observations"][0].content == "Never mention competitor product names directly" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_included_in_list(self, memory: MemoryEngine, request_context): + """Test that directives are included in list_mental_models for admin visibility.""" + bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}" + + # Set up bank with mission + await memory.set_bank_mission( + bank_id=bank_id, + mission="Test mission", + request_context=request_context, + ) + + # Create a directive + directive = await memory.create_mental_model( + bank_id=bank_id, + name="Test Directive", + description="A test directive", + subtype="directive", + observations=[{"title": "Rule", "content": "Follow this rule"}], + request_context=request_context, + ) + + # Create a pinned model + pinned = await memory.create_mental_model( + bank_id=bank_id, + name="Test Pinned", + description="A test pinned model", + request_context=request_context, + ) + + # List without subtype filter - both should appear + models = await memory.list_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + + # Both should appear (directives included in API listing for admin visibility) + model_ids = [m["id"] for m in models] + assert pinned["id"] in model_ids + assert directive["id"] in model_ids + + # List with directive subtype filter - should find only directive + directives = await memory.list_mental_models( + bank_id=bank_id, + subtype="directive", + request_context=request_context, + ) + assert len(directives) == 1 + assert directives[0]["id"] == directive["id"] + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context): + """Test that getting a directive returns its user-provided observations.""" + bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create a directive with observations + created = await memory.create_mental_model( + bank_id=bank_id, + name="Meeting Rules", + description="Rules for scheduling meetings", + subtype="directive", + observations=[ + {"title": "No mornings", "content": "Never schedule meetings before noon"}, + {"title": "Max duration", "content": "Meetings should be 30 minutes max"}, + ], + request_context=request_context, + ) + + # Get the directive + retrieved = await memory.get_mental_model( + bank_id=bank_id, + model_id=created["id"], + request_context=request_context, + ) + + assert retrieved is not None + assert retrieved["subtype"] == "directive" + assert len(retrieved["observations"]) == 2 + assert retrieved["observations"][0].title == "No mornings" + assert retrieved["observations"][1].title == "Max duration" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context): + """Test that directives are not modified during refresh_mental_models.""" + bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}" + + # Set up bank with mission + await memory.set_bank_mission( + bank_id=bank_id, + mission="Test mission", + request_context=request_context, + ) + + # Add some test data + await memory.retain_batch_async( + bank_id=bank_id, + contents=[{"content": "Alice is the engineer."}], + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + # Create a directive + directive = await memory.create_mental_model( + bank_id=bank_id, + name="Important Rule", + description="A critical rule", + subtype="directive", + observations=[{"title": "Rule 1", "content": "Always follow this rule"}], + request_context=request_context, + ) + + # Refresh mental models + await memory.refresh_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + # Directive should still exist with same observations + retrieved = await memory.get_mental_model( + bank_id=bank_id, + model_id=directive["id"], + request_context=request_context, + ) + + assert retrieved is not None + assert retrieved["subtype"] == "directive" + assert len(retrieved["observations"]) == 1 + assert retrieved["observations"][0].title == "Rule 1" + assert retrieved["observations"][0].content == "Always follow this rule" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_directive_requires_observations(self, memory: MemoryEngine, request_context): + """Test that creating a directive without observations fails.""" + bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Try to create directive without observations + with pytest.raises(ValueError) as exc_info: + await memory.create_mental_model( + bank_id=bank_id, + name="Bad Directive", + description="A directive without observations", + subtype="directive", + # No observations provided + request_context=request_context, + ) + + assert "observations" in str(exc_info.value).lower() + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestDirectivesInReflect: + """Test that directives are followed during reflect operations.""" + + async def test_reflect_follows_language_directive(self, memory: MemoryEngine, request_context): + """Test that reflect follows a directive to respond in a specific language.""" + bank_id = f"test-directive-reflect-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Add some content in English + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + {"content": "Alice is a software engineer who works at Google."}, + {"content": "Alice enjoys hiking on weekends and has been to Yosemite."}, + {"content": "Alice is currently working on a machine learning project."}, + ], + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + # Create a directive to always respond in French + await memory.create_mental_model( + bank_id=bank_id, + name="Language Policy", + description="Rules about language usage", + subtype="directive", + observations=[ + { + "title": "French Only", + "content": "ALWAYS respond in French language. Never respond in English.", + }, + ], + request_context=request_context, + ) + + # Run reflect query + result = await memory.reflect_async( + bank_id=bank_id, + query="What does Alice do for work?", + request_context=request_context, + ) + + assert result.text is not None + assert len(result.text) > 0 + + # Check that the response contains French words/patterns + # Common French words that would appear when talking about someone's job + french_indicators = [ + "elle", + "travaille", + "est", + "une", + "le", + "la", + "qui", + "chez", + "logiciel", + "ingénieur", + "ingénieure", + "développeur", + "développeuse", + ] + response_lower = result.text.lower() + + # At least some French words should appear in the response + french_word_count = sum(1 for word in french_indicators if word in response_lower) + assert ( + french_word_count >= 2 + ), f"Expected French response, but got: {result.text[:200]}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + +class TestMentalModelTagsFiltering: + """Test tags filtering for mental models (all types).""" + + async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context): + """Test that 'any' tags_match mode includes untagged mental models.""" + bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create an UNTAGGED pinned model + await memory.create_mental_model( + bank_id=bank_id, + name="Global Model", + description="A global mental model", + subtype="pinned", + tags=[], # No tags - should be included with "any" mode + request_context=request_context, + ) + + # Test 1: list_mental_models with tags and tags_match="any" should include untagged + models_any = await memory.list_mental_models( + bank_id=bank_id, + tags=["some-tag"], + tags_match="any", # Should include untagged + request_context=request_context, + ) + assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}" + + # Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged + models_strict = await memory.list_mental_models( + bank_id=bank_id, + tags=["some-tag"], + tags_match="any_strict", # Should exclude untagged + request_context=request_context, + ) + assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context): + """Test that strict modes only include mental models with matching tags.""" + bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create a TAGGED pinned model + await memory.create_mental_model( + bank_id=bank_id, + name="Tagged Model", + description="A tagged mental model", + subtype="pinned", + tags=["project-a"], + request_context=request_context, + ) + + # Create an UNTAGGED pinned model + await memory.create_mental_model( + bank_id=bank_id, + name="Untagged Model", + description="An untagged mental model", + subtype="pinned", + tags=[], # No tags + request_context=request_context, + ) + + # Test 1: any_strict with matching tag - should get ONLY the tagged model + models_match = await memory.list_mental_models( + bank_id=bank_id, + tags=["project-a"], + tags_match="any_strict", + request_context=request_context, + ) + assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}" + assert models_match[0]["name"] == "Tagged Model" + + # Test 2: any_strict with different tag - should get NO models + models_no_match = await memory.list_mental_models( + bank_id=bank_id, + tags=["project-b"], + tags_match="any_strict", + request_context=request_context, + ) + assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}" + + # Test 3: any (non-strict) with any tag - should get BOTH models + models_any = await memory.list_mental_models( + bank_id=bank_id, + tags=["project-a"], + tags_match="any", + request_context=request_context, + ) + assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context): + """Test that 'all_strict' requires ALL tags to be present.""" + bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}" + + # Ensure bank exists + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Create a model with multiple tags + await memory.create_mental_model( + bank_id=bank_id, + name="Multi-Tag Model", + description="Has project-a and project-b tags", + subtype="pinned", + tags=["project-a", "project-b"], + request_context=request_context, + ) + + # Create a model with only one tag + await memory.create_mental_model( + bank_id=bank_id, + name="Single-Tag Model", + description="Has only project-a tag", + subtype="pinned", + tags=["project-a"], + request_context=request_context, + ) + + # Test 1: all_strict with both tags - should get ONLY the multi-tag model + models_all = await memory.list_mental_models( + bank_id=bank_id, + tags=["project-a", "project-b"], + tags_match="all_strict", + request_context=request_context, + ) + assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}" + assert models_all[0]["name"] == "Multi-Tag Model" + + # Test 2: all (non-strict) with both tags - should include untagged too + # Add an untagged model + await memory.create_mental_model( + bank_id=bank_id, + name="Untagged Model", + description="No tags", + subtype="pinned", + tags=[], + request_context=request_context, + ) + + models_all_non_strict = await memory.list_mental_models( + bank_id=bank_id, + tags=["project-a", "project-b"], + tags_match="all", + request_context=request_context, + ) + # Should get Multi-Tag Model + Untagged Model + assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestDirectivesPromptInjection: + """Test that directives are properly injected into the system prompt.""" + + def test_build_directives_section_empty(self): + """Test that empty directives returns empty string.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + result = build_directives_section([]) + assert result == "" + + def test_build_directives_section_with_observations(self): + """Test that directives with observations are formatted correctly.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + directives = [ + { + "name": "Competitor Policy", + "observations": [ + {"title": "Never mention", "content": "Never mention competitor names"}, + {"title": "Redirect", "content": "Redirect to our features"}, + ], + } + ] + + result = build_directives_section(directives) + + assert "## DIRECTIVES (MANDATORY)" in result + assert "**Never mention**: Never mention competitor names" in result + assert "**Redirect**: Redirect to our features" in result + assert "NEVER violate these directives" in result + + def test_build_directives_section_fallback_to_description(self): + """Test that directives without observations fall back to description.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + directives = [ + { + "name": "Simple Rule", + "description": "Just a simple rule description", + "observations": [], + } + ] + + result = build_directives_section(directives) + + assert "**Simple Rule**: Just a simple rule description" in result + + def test_system_prompt_includes_directives(self): + """Test that build_system_prompt_for_tools includes directives.""" + from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools + + bank_profile = {"name": "Test Bank", "mission": "Test mission"} + directives = [ + { + "name": "Test Directive", + "observations": [{"title": "Rule", "content": "Follow this rule"}], + } + ] + + prompt = build_system_prompt_for_tools( + bank_profile=bank_profile, + directives=directives, + ) + + assert "## DIRECTIVES (MANDATORY)" in prompt + assert "**Rule**: Follow this rule" in prompt + # Directives should appear before CRITICAL RULES + directives_pos = prompt.find("## DIRECTIVES") + critical_rules_pos = prompt.find("## CRITICAL RULES") + assert directives_pos < critical_rules_pos + + +class TestMentalModelVersioning: + """Test mental model versioning functionality.""" + + async def test_refresh_creates_version(self, memory_with_mission, request_context): + """Test that refreshing a mental model creates a version entry.""" + memory, bank_id = memory_with_mission + + # First create a mental model via refresh_mental_models + await memory.refresh_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + # Get the created models + models = await memory.list_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + assert len(models) > 0 + + model_id = models[0]["id"] + + # Refresh the specific model to trigger versioning + result = await memory.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + + assert result is not None + # Version should be incremented + assert result.get("version", 0) >= 1 + + # Check version history + versions = await memory.get_mental_model_versions( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + + assert len(versions) >= 1 + assert versions[0]["version"] >= 1 + assert "created_at" in versions[0] + assert "observation_count" in versions[0] + + async def test_get_specific_version(self, memory_with_mission, request_context): + """Test retrieving a specific version of a mental model.""" + memory, bank_id = memory_with_mission + + # Create and refresh a mental model + await memory.refresh_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + models = await memory.list_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + assert len(models) > 0 + + model_id = models[0]["id"] + + # Refresh to create version + await memory.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + + # Get versions + versions = await memory.get_mental_model_versions( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + assert len(versions) >= 1 + + # Get specific version + version_num = versions[0]["version"] + version_data = await memory.get_mental_model_version( + bank_id=bank_id, + model_id=model_id, + version=version_num, + request_context=request_context, + ) + + assert version_data is not None + assert version_data["version"] == version_num + assert "observations" in version_data + + async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context): + """Test that old versions are cleaned up when max is exceeded.""" + memory, bank_id = memory_with_mission + + # Create a mental model + await memory.refresh_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + await memory.wait_for_background_tasks() + + models = await memory.list_mental_models( + bank_id=bank_id, + request_context=request_context, + ) + assert len(models) > 0 + + model_id = models[0]["id"] + + # Refresh multiple times to create versions + for _ in range(3): + await memory.refresh_mental_model( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + + # Get versions - should have multiple but within max limit + versions = await memory.get_mental_model_versions( + bank_id=bank_id, + model_id=model_id, + request_context=request_context, + ) + + # Should have versions (exact count depends on config, but at least some) + assert len(versions) >= 1 + # Versions should be in descending order + if len(versions) > 1: + assert versions[0]["version"] > versions[1]["version"] + diff --git a/hindsight-api/tests/test_observation_trends.py b/hindsight-api/tests/test_observation_trends.py new file mode 100644 index 00000000..7510ae1b --- /dev/null +++ b/hindsight-api/tests/test_observation_trends.py @@ -0,0 +1,405 @@ +"""Tests for observation trend computation and evidence-grounded models.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from hindsight_api.engine.reflect.observations import ( + CandidateObservation, + Observation, + ObservationEvidence, + Trend, + compute_trend, + verify_evidence_quotes, +) + + +class TestComputeTrend: + """Tests for the compute_trend function.""" + + def test_empty_evidence_returns_stale(self): + """No evidence should return STALE trend.""" + trend = compute_trend([]) + assert trend == Trend.STALE + + def test_all_recent_evidence_returns_new(self): + """All evidence within recent window (30 days) should return NEW trend. + + Scenario: User just started using the app and mentioned they like coffee twice. + Both mentions are within the last 2 weeks, so this is a NEW observation. + """ + now = datetime.now(timezone.utc) + evidence = [ + ObservationEvidence( + memory_id="mem-coffee-morning", + quote="I always start my day with a large black coffee", + relevance="Shows preference for coffee and morning routine", + timestamp=now - timedelta(days=5), + ), + ObservationEvidence( + memory_id="mem-coffee-meeting", + quote="grabbed coffee before the standup meeting", + relevance="Confirms regular coffee consumption", + timestamp=now - timedelta(days=10), + ), + ] + + trend = compute_trend(evidence, now=now) + assert trend == Trend.NEW + + def test_no_recent_evidence_returns_stale(self): + """No evidence in recent window should return STALE trend. + + Scenario: User mentioned running 3 months ago but hasn't mentioned it since. + The observation about running as a hobby may no longer be accurate. + """ + now = datetime.now(timezone.utc) + evidence = [ + ObservationEvidence( + memory_id="mem-running-march", + quote="training for a half marathon in the spring", + relevance="Shows interest in running", + timestamp=now - timedelta(days=60), + ), + ObservationEvidence( + memory_id="mem-running-feb", + quote="went for a 10k run this morning", + relevance="Active runner", + timestamp=now - timedelta(days=100), + ), + ] + + trend = compute_trend(evidence, now=now) + assert trend == Trend.STALE + + def test_stable_evidence_distribution(self): + """Evidence spread evenly across time should return STABLE trend. + + Scenario: User has consistently mentioned working remotely over 4 months. + Evidence is well-distributed, indicating a stable, ongoing preference. + """ + now = datetime.now(timezone.utc) + evidence = [ + # Recent (within 30 days) + ObservationEvidence( + memory_id="mem-remote-jan", + quote="working from my home office today", + relevance="Current remote work", + timestamp=now - timedelta(days=5), + ), + ObservationEvidence( + memory_id="mem-remote-dec", + quote="the flexibility of remote work is great", + relevance="Values remote work", + timestamp=now - timedelta(days=15), + ), + # Middle period (30-90 days) + ObservationEvidence( + memory_id="mem-remote-nov", + quote="set up a standing desk at home", + relevance="Invested in home office", + timestamp=now - timedelta(days=45), + ), + ObservationEvidence( + memory_id="mem-remote-oct", + quote="prefer async communication over meetings", + relevance="Remote work style preference", + timestamp=now - timedelta(days=60), + ), + # Older (90+ days) + ObservationEvidence( + memory_id="mem-remote-sep", + quote="switched to fully remote last quarter", + relevance="Original transition to remote", + timestamp=now - timedelta(days=100), + ), + ObservationEvidence( + memory_id="mem-remote-aug", + quote="negotiated remote work in my new contract", + relevance="Intentional choice for remote", + timestamp=now - timedelta(days=120), + ), + ] + + trend = compute_trend(evidence, now=now) + assert trend == Trend.STABLE + + def test_strengthening_trend(self): + """Much more recent evidence than older should return STRENGTHENING trend. + + Scenario: User has been increasingly talking about learning Python recently + after mentioning it once months ago. Interest appears to be growing. + """ + now = datetime.now(timezone.utc) + evidence = [ + # Lots of recent evidence - actively learning + ObservationEvidence( + memory_id="mem-python-project", + quote="finished my first Python project - a web scraper", + relevance="Completed Python project", + timestamp=now - timedelta(days=2), + ), + ObservationEvidence( + memory_id="mem-python-course", + quote="halfway through the Python bootcamp", + relevance="Active learning", + timestamp=now - timedelta(days=5), + ), + ObservationEvidence( + memory_id="mem-python-book", + quote="reading Fluent Python, it's excellent", + relevance="Deepening knowledge", + timestamp=now - timedelta(days=10), + ), + ObservationEvidence( + memory_id="mem-python-practice", + quote="solved 50 LeetCode problems in Python", + relevance="Practicing skills", + timestamp=now - timedelta(days=15), + ), + ObservationEvidence( + memory_id="mem-python-ide", + quote="set up VS Code with all the Python extensions", + relevance="Setting up environment", + timestamp=now - timedelta(days=20), + ), + # Only one old mention - initial interest + ObservationEvidence( + memory_id="mem-python-start", + quote="thinking about learning Python someday", + relevance="Initial interest", + timestamp=now - timedelta(days=100), + ), + ] + + trend = compute_trend(evidence, now=now) + assert trend == Trend.STRENGTHENING + + def test_weakening_trend(self): + """Much less recent evidence than older should return WEAKENING trend. + + Scenario: User was very active in a book club last year but mentions + have tapered off. The observation about being a book club member + may be becoming less relevant. + """ + now = datetime.now(timezone.utc) + evidence = [ + # Only one recent mention + ObservationEvidence( + memory_id="mem-book-recent", + quote="haven't had time for book club lately", + relevance="Reduced participation", + timestamp=now - timedelta(days=10), + ), + # Lots of older evidence - was very active + ObservationEvidence( + memory_id="mem-book-aug", + quote="hosting book club at my place next week", + relevance="Active organizer", + timestamp=now - timedelta(days=40), + ), + ObservationEvidence( + memory_id="mem-book-july", + quote="leading the discussion on 1984", + relevance="Active participant", + timestamp=now - timedelta(days=50), + ), + ObservationEvidence( + memory_id="mem-book-june", + quote="we picked The Midnight Library for June", + relevance="Regular member", + timestamp=now - timedelta(days=60), + ), + ObservationEvidence( + memory_id="mem-book-may", + quote="book club was amazing tonight", + relevance="Enthusiastic member", + timestamp=now - timedelta(days=100), + ), + ObservationEvidence( + memory_id="mem-book-april", + quote="joined a new book club in my neighborhood", + relevance="Started participation", + timestamp=now - timedelta(days=110), + ), + ObservationEvidence( + memory_id="mem-book-march", + quote="excited to finally join a book club", + relevance="Initial enthusiasm", + timestamp=now - timedelta(days=120), + ), + ] + + trend = compute_trend(evidence, now=now) + assert trend == Trend.WEAKENING + + +class TestObservationModel: + """Tests for the Observation model.""" + + def test_observation_computed_trend(self): + """Observation should have computed trend property based on evidence.""" + now = datetime.now(timezone.utc) + obs = Observation( + title="Morning meeting preference", + content="Prefers morning meetings over afternoon ones", + evidence=[ + ObservationEvidence( + memory_id="mem-morning-standup", + quote="I'm most productive in morning meetings", + relevance="Direct preference statement", + timestamp=now - timedelta(days=5), + ), + ], + created_at=now, + ) + + assert obs.trend == Trend.NEW + assert obs.evidence_count == 1 + + def test_observation_evidence_span(self): + """Observation should compute evidence span correctly. + + The span shows the date range of supporting evidence, helping + understand how long this pattern has been observed. + """ + now = datetime.now(timezone.utc) + old_time = now - timedelta(days=100) + recent_time = now - timedelta(days=5) + + obs = Observation( + title="Values work-life balance", + content="Values work-life balance highly", + evidence=[ + ObservationEvidence( + memory_id="mem-balance-old", + quote="turned down a promotion because of the hours", + relevance="Prioritized balance over advancement", + timestamp=old_time, + ), + ObservationEvidence( + memory_id="mem-balance-recent", + quote="always log off by 6pm no matter what", + relevance="Maintains boundaries", + timestamp=recent_time, + ), + ], + created_at=now, + ) + + evidence_span = obs.evidence_span + assert evidence_span["from"] == old_time.isoformat() + assert evidence_span["to"] == recent_time.isoformat() + + def test_observation_empty_evidence_span(self): + """Observation with no evidence should have null span.""" + obs = Observation( + title="Test observation", + content="Test observation without evidence", + evidence=[], + ) + + evidence_span = obs.evidence_span + assert evidence_span["from"] is None + assert evidence_span["to"] is None + + +class TestVerifyEvidenceQuotes: + """Tests for evidence quote verification. + + This ensures the LLM isn't hallucinating quotes - every quote + must actually appear in the source memory. + """ + + def test_valid_quotes(self): + """Should return True when quotes exist in their source memories.""" + obs = Observation( + title="Enjoys hiking", + content="Enjoys hiking on weekends", + evidence=[ + ObservationEvidence( + memory_id="mem-hiking-trip", + quote="went hiking at Mount Tam", + relevance="Shows hiking activity", + timestamp=datetime.now(timezone.utc), + ), + ], + ) + + memories = { + "mem-hiking-trip": "Had a great Saturday - went hiking at Mount Tam with friends and saw amazing views." + } + is_valid, errors = verify_evidence_quotes(obs, memories) + + assert is_valid is True + assert len(errors) == 0 + + def test_invalid_quote(self): + """Should return False when quote doesn't exist in memory. + + This catches LLM hallucinations where it fabricates quotes. + """ + obs = Observation( + title="Loves spicy food", + content="Loves spicy food", + evidence=[ + ObservationEvidence( + memory_id="mem-dinner", + quote="I love extra hot salsa", + relevance="Shows spicy food preference", + timestamp=datetime.now(timezone.utc), + ), + ], + ) + + memories = {"mem-dinner": "Had tacos for dinner. The guacamole was really fresh."} + is_valid, errors = verify_evidence_quotes(obs, memories) + + assert is_valid is False + assert len(errors) == 1 + assert "Quote not found" in errors[0] + + def test_missing_memory(self): + """Should return False when referenced memory doesn't exist. + + This catches cases where the LLM references a memory ID that + was never actually retrieved. + """ + obs = Observation( + title="Has a dog named Max", + content="Has a dog named Max", + evidence=[ + ObservationEvidence( + memory_id="mem-pet-story", + quote="took Max to the vet", + relevance="Shows pet ownership", + timestamp=datetime.now(timezone.utc), + ), + ], + ) + + memories = {"mem-different-id": "Some unrelated memory content"} + is_valid, errors = verify_evidence_quotes(obs, memories) + + assert is_valid is False + assert len(errors) == 1 + assert "not found" in errors[0] + + +class TestCandidateObservation: + """Tests for candidate observation model. + + Candidates are generated in the SEED phase and validated + before becoming full observations. + """ + + def test_create_candidate(self): + """Should create candidate with content and seed memories.""" + candidate = CandidateObservation( + content="User prefers async communication over meetings", + seed_memory_ids=["mem-slack-pref", "mem-meeting-decline"], + ) + + assert candidate.content == "User prefers async communication over meetings" + assert len(candidate.seed_memory_ids) == 2 + assert "mem-slack-pref" in candidate.seed_memory_ids diff --git a/hindsight-api/tests/test_reflect_agent.py b/hindsight-api/tests/test_reflect_agent.py index ca090384..132022f8 100644 --- a/hindsight-api/tests/test_reflect_agent.py +++ b/hindsight-api/tests/test_reflect_agent.py @@ -88,7 +88,19 @@ class TestToolLookup: "subtype": "learned", "name": "Model 1", "description": "First model", - "observations": {"observations": [{"title": "Overview", "text": "Full summary of model 1", "memory_ids": ["mem-1", "mem-2"]}]}, + "observations": { + "observations": [ + { + "title": "Overview", + "content": "Full summary of model 1", + "evidence": [ + {"memory_id": "mem-1", "quote": "quote 1", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"}, + {"memory_id": "mem-2", "quote": "quote 2", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"}, + ], + "created_at": "2024-01-01T00:00:00Z", + } + ] + }, "entity_id": None, "last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"), } @@ -98,9 +110,12 @@ class TestToolLookup: assert result["found"] is True assert result["model"]["id"] == "model-1" assert len(result["model"]["observations"]) == 1 - assert result["model"]["observations"][0]["text"] == "Full summary of model 1" - # Verify memory_ids are mapped to based_on - assert result["model"]["observations"][0]["based_on"] == ["mem-1", "mem-2"] + # Observations are now Observation objects + obs = result["model"]["observations"][0] + assert obs.content == "Full summary of model 1" + assert obs.title == "Overview" + assert len(obs.evidence) == 2 + assert obs.evidence[0].memory_id == "mem-1" async def test_model_not_found(self, mock_conn): """Test looking up non-existent model.""" @@ -840,6 +855,158 @@ class TestReflectAgent: assert result.text == "The answer is simple and direct." + async def test_agent_includes_directives_in_system_prompt(self, mock_llm, bank_profile, mock_tools): + """Test that directives are included in the system prompt.""" + from hindsight_api.engine.reflect.observations import Observation + + # Create directive with Observation objects (new format) + directives = [ + { + "id": "response-rules", + "name": "Response Rules", + "description": "Rules for responses", + "subtype": "directive", + "observations": [ + Observation( + title="No Speculation", + content="Never speculate about information not in the memories.", + evidence=[], + ), + Observation( + title="Be Concise", + content="Always keep responses under 100 words.", + evidence=[], + ), + ], + }, + ] + + # Capture the system prompt + captured_messages = [] + + async def capture_call(*args, **kwargs): + if "messages" in kwargs: + captured_messages.extend(kwargs["messages"]) + return self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]) + + mock_llm.call_with_tools.side_effect = [ + # First: gather evidence (guardrail requirement) + self._make_tool_result([{"name": "recall", "arguments": {"query": "test"}}]), + # Then: done + self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]), + ] + + # Store original to check messages + original_call = mock_llm.call_with_tools + + async def wrapped_call(*args, **kwargs): + if "messages" in kwargs: + captured_messages.extend(kwargs["messages"]) + return await original_call(*args, **kwargs) + + mock_llm.call_with_tools = wrapped_call + + result = await run_reflect_agent( + llm_config=mock_llm, + bank_id="test-bank", + query="What do we know?", + bank_profile=bank_profile, + directives=directives, + **mock_tools, + ) + + # Find the system message + system_messages = [m for m in captured_messages if m.get("role") == "system"] + assert len(system_messages) > 0, "No system message found" + + system_content = system_messages[0]["content"] + + # Verify directives are in the system prompt + assert "DIRECTIVES" in system_content, "Directives section not found in system prompt" + assert "No Speculation" in system_content, "Directive title not found" + assert "Never speculate" in system_content, "Directive content not found" + assert "Be Concise" in system_content, "Second directive title not found" + assert "100 words" in system_content, "Second directive content not found" + assert "NEVER violate these directives" in system_content, "Directive warning not found" + + +class TestDirectivesSection: + """Test the directives section builder.""" + + def test_build_directives_section_with_observation_objects(self): + """Test building directives section with Observation objects.""" + from hindsight_api.engine.reflect.observations import Observation + from hindsight_api.engine.reflect.prompts import build_directives_section + + directives = [ + { + "name": "Safety Rules", + "observations": [ + Observation( + title="No Harmful Content", + content="Never generate harmful or dangerous content.", + evidence=[], + ), + ], + }, + ] + + result = build_directives_section(directives) + + assert "DIRECTIVES" in result + assert "No Harmful Content" in result + assert "Never generate harmful" in result + assert "NEVER violate" in result + + def test_build_directives_section_with_dicts(self): + """Test building directives section with dict observations.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + directives = [ + { + "name": "Safety Rules", + "observations": [ + { + "title": "No Harmful Content", + "content": "Never generate harmful or dangerous content.", + }, + ], + }, + ] + + result = build_directives_section(directives) + + assert "DIRECTIVES" in result + assert "No Harmful Content" in result + assert "Never generate harmful" in result + + def test_build_directives_section_fallback_to_description(self): + """Test that directives without observations use description.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + directives = [ + { + "name": "Simple Rule", + "description": "This is a simple rule to follow.", + "observations": [], + }, + ] + + result = build_directives_section(directives) + + assert "Simple Rule" in result + assert "simple rule to follow" in result + + def test_build_directives_section_empty(self): + """Test that empty directives returns empty string.""" + from hindsight_api.engine.reflect.prompts import build_directives_section + + result = build_directives_section([]) + assert result == "" + + result = build_directives_section(None) + assert result == "" + @pytest.mark.integration class TestReflectIntegration: diff --git a/hindsight-api/tests/test_retain.py b/hindsight-api/tests/test_retain.py index 3fb7c57a..68ca7283 100644 --- a/hindsight-api/tests/test_retain.py +++ b/hindsight-api/tests/test_retain.py @@ -2058,3 +2058,26 @@ async def test_user_provided_entities(memory, request_context): finally: await memory.delete_bank(bank_id, request_context=request_context) + + +def test_recall_result_model_empty_construction(): + """ + Test that RecallResultModel can be constructed with empty results. + + This is a regression test for the bug where constructing an empty RecallResultModel + would cause an UnboundLocalError because RecallResult was imported as RecallResultModel + but the code mistakenly used the wrong name. + + The fix ensures RecallResultModel is used consistently throughout memory_engine.py. + """ + from hindsight_api.engine.response_models import RecallResult + + # This should not raise any errors + result = RecallResult(results=[], entities={}, chunks={}) + + assert result is not None, "Should create a result object" + assert result.results == [], "Should have empty results" + assert result.entities == {}, "Should have empty entities" + assert result.chunks == {}, "Should have empty chunks" + + logger.info("✓ RecallResult empty construction works correctly") diff --git a/hindsight-api/tests/test_server_module.py b/hindsight-api/tests/test_server_module.py index bc1d29bf..0d2d95a2 100644 --- a/hindsight-api/tests/test_server_module.py +++ b/hindsight-api/tests/test_server_module.py @@ -257,6 +257,7 @@ from hindsight_api.extensions import ( RetainContext, RecallContext, ReflectContext, + RefreshMentalModelContext, ) @@ -288,3 +289,6 @@ class MockOperationValidator(OperationValidatorExtension): async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: return ValidationResult.accept() + + async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult: + return ValidationResult.accept() diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 1fa84698..1b9dc649 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -45,9 +45,12 @@ 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 @@ -70,6 +73,7 @@ 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_disposition_request.py +hindsight_client_api/models/update_mental_model_request.py hindsight_client_api/models/validation_error.py hindsight_client_api/models/validation_error_loc_inner.py hindsight_client_api/rest.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index b79ccb64..a00f92d8 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -70,9 +70,12 @@ 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 @@ -95,5 +98,6 @@ 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_disposition_request import UpdateDispositionRequest +from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.validation_error import ValidationError from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner diff --git a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py index 25da24f9..66aa1d50 100644 --- a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py @@ -16,8 +16,8 @@ from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import Field, StrictStr, field_validator -from typing import List, Optional +from pydantic import Field, StrictInt, StrictStr, field_validator +from typing import Any, List, Optional from typing_extensions import Annotated from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest @@ -25,6 +25,7 @@ from hindsight_client_api.models.delete_response import DeleteResponse from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse from hindsight_client_api.models.mental_model_response import MentalModelResponse from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest +from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse @@ -65,7 +66,7 @@ class MentalModelsApi: ) -> MentalModelResponse: """Create mental model - Create a pinned mental model. Pinned models are user-defined and persist across refreshes. + 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 :param bank_id: (required) :type bank_id: str @@ -141,7 +142,7 @@ class MentalModelsApi: ) -> ApiResponse[MentalModelResponse]: """Create mental model - Create a pinned mental model. Pinned models are user-defined and persist across refreshes. + 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 :param bank_id: (required) :type bank_id: str @@ -217,7 +218,7 @@ class MentalModelsApi: ) -> RESTResponseType: """Create mental model - Create a pinned mental model. Pinned models are user-defined and persist across refreshes. + 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 :param bank_id: (required) :type bank_id: str @@ -643,299 +644,6 @@ class MentalModelsApi: - @validate_call - async def generate_mental_model( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> AsyncOperationSubmitResponse: - """Generate mental model content (async) - - Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._generate_mental_model_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", - '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 generate_mental_model_with_http_info( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[AsyncOperationSubmitResponse]: - """Generate mental model content (async) - - Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._generate_mental_model_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", - '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 generate_mental_model_without_preload_content( - self, - bank_id: StrictStr, - model_id: StrictStr, - authorization: Optional[StrictStr] = None, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """Generate mental model content (async) - - Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model. - - :param bank_id: (required) - :type bank_id: str - :param model_id: (required) - :type model_id: str - :param authorization: - :type authorization: str - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - - _param = self._generate_mental_model_serialize( - bank_id=bank_id, - model_id=model_id, - authorization=authorization, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "AsyncOperationSubmitResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _generate_mental_model_serialize( - self, - bank_id, - model_id, - authorization, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - if bank_id is not None: - _path_params['bank_id'] = bank_id - if model_id is not None: - _path_params['model_id'] = model_id - # process the query parameters - # process the header parameters - if authorization is not None: - _header_params['authorization'] = authorization - # process the form parameters - # process the body parameter - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='POST', - resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/generate', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - - - @validate_call async def get_mental_model( self, @@ -1229,6 +937,607 @@ class MentalModelsApi: + @validate_call + async def get_mental_model_version( + self, + bank_id: StrictStr, + model_id: StrictStr, + version: StrictInt, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """Get specific mental model version + + Get observations from a specific version of a mental model. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param version: (required) + :type version: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_mental_model_version_serialize( + bank_id=bank_id, + model_id=model_id, + version=version, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_mental_model_version_with_http_info( + self, + bank_id: StrictStr, + model_id: StrictStr, + version: StrictInt, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[object]: + """Get specific mental model version + + Get observations from a specific version of a mental model. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param version: (required) + :type version: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_mental_model_version_serialize( + bank_id=bank_id, + model_id=model_id, + version=version, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_mental_model_version_without_preload_content( + self, + bank_id: StrictStr, + model_id: StrictStr, + version: StrictInt, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get specific mental model version + + Get observations from a specific version of a mental model. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param version: (required) + :type version: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_mental_model_version_serialize( + bank_id=bank_id, + model_id=model_id, + version=version, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_mental_model_version_serialize( + self, + bank_id, + model_id, + version, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if model_id is not None: + _path_params['model_id'] = model_id + if version is not None: + _path_params['version'] = version + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def list_mental_model_versions( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """List mental model version history + + List all saved versions of a mental model's observations, ordered by version descending. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_mental_model_versions_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def list_mental_model_versions_with_http_info( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[object]: + """List mental model version history + + List all saved versions of a mental model's observations, ordered by version descending. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_mental_model_versions_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def list_mental_model_versions_without_preload_content( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """List mental model version history + + List all saved versions of a mental model's observations, ordered by version descending. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_mental_model_versions_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _list_mental_model_versions_serialize( + self, + bank_id, + model_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if model_id is not None: + _path_params['model_id'] = model_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def list_mental_models( self, @@ -1559,6 +1868,299 @@ class MentalModelsApi: + @validate_call + async def refresh_mental_model( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> AsyncOperationSubmitResponse: + """Refresh mental model content (async) + + 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. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._refresh_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AsyncOperationSubmitResponse", + '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 refresh_mental_model_with_http_info( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[AsyncOperationSubmitResponse]: + """Refresh mental model content (async) + + 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. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._refresh_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AsyncOperationSubmitResponse", + '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 refresh_mental_model_without_preload_content( + self, + bank_id: StrictStr, + model_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Refresh mental model content (async) + + 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. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._refresh_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "AsyncOperationSubmitResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _refresh_mental_model_serialize( + self, + bank_id, + model_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if model_id is not None: + _path_params['model_id'] = model_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def refresh_mental_models( self, @@ -1863,3 +2465,324 @@ class MentalModelsApi: ) + + + @validate_call + async def update_mental_model( + self, + bank_id: StrictStr, + model_id: StrictStr, + update_mental_model_request: UpdateMentalModelRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> MentalModelResponse: + """Update mental model + + Update a mental model's name and/or description. Useful for editing directives. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param update_mental_model_request: (required) + :type update_mental_model_request: UpdateMentalModelRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + update_mental_model_request=update_mental_model_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MentalModelResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def update_mental_model_with_http_info( + self, + bank_id: StrictStr, + model_id: StrictStr, + update_mental_model_request: UpdateMentalModelRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[MentalModelResponse]: + """Update mental model + + Update a mental model's name and/or description. Useful for editing directives. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param update_mental_model_request: (required) + :type update_mental_model_request: UpdateMentalModelRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + update_mental_model_request=update_mental_model_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MentalModelResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def update_mental_model_without_preload_content( + self, + bank_id: StrictStr, + model_id: StrictStr, + update_mental_model_request: UpdateMentalModelRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Update mental model + + Update a mental model's name and/or description. Useful for editing directives. + + :param bank_id: (required) + :type bank_id: str + :param model_id: (required) + :type model_id: str + :param update_mental_model_request: (required) + :type update_mental_model_request: UpdateMentalModelRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_mental_model_serialize( + bank_id=bank_id, + model_id=model_id, + update_mental_model_request=update_mental_model_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MentalModelResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _update_mental_model_serialize( + self, + bank_id, + model_id, + update_mental_model_request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if model_id is not None: + _path_params['model_id'] = model_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + if update_mental_model_request is not None: + _body_params = update_mental_model_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='PATCH', + resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index dbd42add..e3cc2c5b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -47,9 +47,12 @@ 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 @@ -72,5 +75,6 @@ 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_disposition_request import UpdateDispositionRequest +from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.validation_error import ValidationError from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py index 53e6225c..67032696 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py @@ -19,17 +19,20 @@ import json from pydantic import BaseModel, ConfigDict, Field, 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): """ - Request model for creating a pinned mental model. + Request model for creating a mental model. """ # 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", "tags"] + __properties: ClassVar[List[str]] = ["name", "description", "subtype", "observations", "tags"] model_config = ConfigDict( populate_by_name=True, @@ -70,6 +73,18 @@ 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 + # and model_fields_set contains the field + if self.observations is None and "observations" in self.model_fields_set: + _dict['observations'] = None + return _dict @classmethod @@ -84,6 +99,8 @@ 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, "tags": obj.get("tags") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py new file mode 100644 index 00000000..cda3661f --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py @@ -0,0 +1,98 @@ +# 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 MentalModelFreshnessResponse(BaseModel): + """ + Freshness information for a mental model. + """ # 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"] + + 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 MentalModelFreshnessResponse 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, + ) + # 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 + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MentalModelFreshnessResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + 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") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py index 1fe1cba3..4c013adc 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py @@ -17,19 +17,24 @@ 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, 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 memories. + An observation within a mental model with its supporting evidence. """ # noqa: E501 - title: StrictStr = Field(description="Observation header (empty for intro)") - text: StrictStr = Field(description="Observation content") - based_on: Optional[List[StrictStr]] = Field(default=None, description="Memory IDs supporting this observation") - __properties: ClassVar[List[str]] = ["title", "text", "based_on"] + 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, @@ -70,6 +75,13 @@ class MentalModelObservationResponse(BaseModel): 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 @@ -83,8 +95,12 @@ class MentalModelObservationResponse(BaseModel): _obj = cls.model_validate({ "title": obj.get("title"), - "text": obj.get("text"), - "based_on": obj.get("based_on") + "content": obj.get("content"), + "evidence": [ObservationEvidenceResponse.from_dict(_item) for _item in obj["evidence"]] if obj.get("evidence") is not None else None, + "created_at": obj.get("created_at"), + "trend": obj.get("trend"), + "evidence_count": obj.get("evidence_count"), + "evidence_span": obj.get("evidence_span") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py index ba510a2a..790fcad4 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py @@ -17,8 +17,9 @@ 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, 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 @@ -33,12 +34,15 @@ class MentalModelResponse(BaseModel): 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", "entity_id", "links", "tags", "last_updated", "created_at"] + __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, @@ -86,6 +90,9 @@ class MentalModelResponse(BaseModel): 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: @@ -96,6 +103,16 @@ class MentalModelResponse(BaseModel): 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 @@ -114,10 +131,13 @@ class MentalModelResponse(BaseModel): "name": obj.get("name"), "description": obj.get("description"), "observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None, + "version": obj.get("version") if obj.get("version") is not None else 0, "entity_id": obj.get("entity_id"), "links": obj.get("links"), "tags": obj.get("tags"), "last_updated": obj.get("last_updated"), + "last_refresh_at": obj.get("last_refresh_at"), + "freshness": MentalModelFreshnessResponse.from_dict(obj["freshness"]) if obj.get("freshness") is not None else None, "created_at": obj.get("created_at") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py b/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py new file mode 100644 index 00000000..e664abd6 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py @@ -0,0 +1,93 @@ +# 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 +from typing import Optional, Set +from typing_extensions import Self + +class ObservationEvidenceResponse(BaseModel): + """ + A single piece of evidence supporting an observation. + """ # 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"] + + 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 ObservationEvidenceResponse 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 ObservationEvidenceResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + 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") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_input.py b/hindsight-clients/python/hindsight_client_api/models/observation_input.py new file mode 100644 index 00000000..17fe584d --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_input.py @@ -0,0 +1,89 @@ +# 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 +from typing import Optional, Set +from typing_extensions import Self + +class ObservationInput(BaseModel): + """ + Input model for a single observation. + """ # 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"] + + 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 ObservationInput 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 ObservationInput 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") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py b/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py index dad6c2c8..076a3556 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py @@ -29,10 +29,9 @@ class ReflectMentalModel(BaseModel): id: StrictStr = Field(description="Mental model ID") name: StrictStr = Field(description="Mental model name") type: StrictStr = Field(description="Mental model type: entity, concept, event") - subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned") - description: StrictStr = Field(description="Brief description") - summary: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "description", "summary"] + subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned, directive") + observations: Optional[List[StrictStr]] = None + __properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "observations"] model_config = ConfigDict( populate_by_name=True, @@ -73,10 +72,10 @@ class ReflectMentalModel(BaseModel): exclude=excluded_fields, exclude_none=True, ) - # set to None if summary (nullable) is None + # set to None if observations (nullable) is None # and model_fields_set contains the field - if self.summary is None and "summary" in self.model_fields_set: - _dict['summary'] = None + if self.observations is None and "observations" in self.model_fields_set: + _dict['observations'] = None return _dict @@ -94,8 +93,7 @@ class ReflectMentalModel(BaseModel): "name": obj.get("name"), "type": obj.get("type"), "subtype": obj.get("subtype"), - "description": obj.get("description"), - "summary": obj.get("summary") + "observations": obj.get("observations") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py b/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py index 591f0750..d738a013 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py @@ -20,6 +20,7 @@ import json from pydantic import BaseModel, ConfigDict, Field from typing import Any, ClassVar, Dict, List, Optional from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall +from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel from hindsight_client_api.models.reflect_tool_call import ReflectToolCall from typing import Optional, Set from typing_extensions import Self @@ -30,7 +31,8 @@ class ReflectTrace(BaseModel): """ # noqa: E501 tool_calls: Optional[List[ReflectToolCall]] = Field(default=None, description="Tool calls made during reflection") llm_calls: Optional[List[ReflectLLMCall]] = Field(default=None, description="LLM calls made during reflection") - __properties: ClassVar[List[str]] = ["tool_calls", "llm_calls"] + mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models used during reflection (includes directives with subtype='directive')") + __properties: ClassVar[List[str]] = ["tool_calls", "llm_calls", "mental_models"] model_config = ConfigDict( populate_by_name=True, @@ -85,6 +87,13 @@ class ReflectTrace(BaseModel): if _item_llm_calls: _items.append(_item_llm_calls.to_dict()) _dict['llm_calls'] = _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 @@ -98,7 +107,8 @@ class ReflectTrace(BaseModel): _obj = cls.model_validate({ "tool_calls": [ReflectToolCall.from_dict(_item) for _item in obj["tool_calls"]] if obj.get("tool_calls") is not None else None, - "llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None + "llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") 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 }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py new file mode 100644 index 00000000..ea0596ab --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py @@ -0,0 +1,99 @@ +# 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, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class UpdateMentalModelRequest(BaseModel): + """ + Request model for updating a mental model. + """ # noqa: E501 + name: Optional[StrictStr] = None + description: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["name", "description"] + + 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 UpdateMentalModelRequest 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, + ) + # set to None if name (nullable) is None + # and model_fields_set contains the field + 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""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "name": obj.get("name"), + "description": obj.get("description") + }) + return _obj + + diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 54de9b8b..82b112f4 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -27,9 +27,6 @@ import type { DeleteMentalModelData, DeleteMentalModelErrors, DeleteMentalModelResponses, - GenerateMentalModelData, - GenerateMentalModelErrors, - GenerateMentalModelResponses, GetAgentStatsData, GetAgentStatsErrors, GetAgentStatsResponses, @@ -54,6 +51,9 @@ import type { GetMentalModelData, GetMentalModelErrors, GetMentalModelResponses, + GetMentalModelVersionData, + GetMentalModelVersionErrors, + GetMentalModelVersionResponses, GetOperationStatusData, GetOperationStatusErrors, GetOperationStatusResponses, @@ -74,6 +74,9 @@ import type { ListMentalModelsData, ListMentalModelsErrors, ListMentalModelsResponses, + ListMentalModelVersionsData, + ListMentalModelVersionsErrors, + ListMentalModelVersionsResponses, ListOperationsData, ListOperationsErrors, ListOperationsResponses, @@ -88,6 +91,9 @@ import type { ReflectData, ReflectErrors, ReflectResponses, + RefreshMentalModelData, + RefreshMentalModelErrors, + RefreshMentalModelResponses, RefreshMentalModelsData, RefreshMentalModelsErrors, RefreshMentalModelsResponses, @@ -103,6 +109,9 @@ import type { UpdateBankDispositionResponses, UpdateBankErrors, UpdateBankResponses, + UpdateMentalModelData, + UpdateMentalModelErrors, + UpdateMentalModelResponses, } from "./types.gen"; export type Options< @@ -343,7 +352,9 @@ export const listMentalModels = ( /** * Create mental model * - * Create a pinned mental model. Pinned models are user-defined and persist across refreshes. + * 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 */ export const createMentalModel = ( options: Options, @@ -395,6 +406,27 @@ export const getMentalModel = ( ...options, }); +/** + * Update mental model + * + * Update a mental model's name and/or description. Useful for editing directives. + */ +export const updateMentalModel = ( + options: Options, +) => + (options.client ?? client).patch< + UpdateMentalModelResponses, + UpdateMentalModelErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * Refresh mental models (async) * @@ -417,19 +449,53 @@ export const refreshMentalModels = ( }); /** - * Generate mental model content (async) + * Refresh mental model content (async) * - * Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model. + * 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. */ -export const generateMentalModel = ( - options: Options, +export const refreshMentalModel = ( + options: Options, ) => (options.client ?? client).post< - GenerateMentalModelResponses, - GenerateMentalModelErrors, + RefreshMentalModelResponses, + RefreshMentalModelErrors, ThrowOnError >({ - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate", + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh", + ...options, + }); + +/** + * List mental model version history + * + * List all saved versions of a mental model's observations, ordered by version descending. + */ +export const listMentalModelVersions = ( + options: Options, +) => + (options.client ?? client).get< + ListMentalModelVersionsResponses, + ListMentalModelVersionsErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions", + ...options, + }); + +/** + * Get specific mental model version + * + * Get observations from a specific version of a mental model. + */ +export const getMentalModelVersion = ( + options: Options, +) => + (options.client ?? client).get< + GetMentalModelVersionResponses, + GetMentalModelVersionErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}", ...options, }); diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index a0e591c2..3b17254c 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -314,7 +314,7 @@ export type CreateBankRequest = { /** * CreateMentalModelRequest * - * Request model for creating a pinned mental model. + * Request model for creating a mental model. */ export type CreateMentalModelRequest = { /** @@ -329,6 +329,18 @@ export type CreateMentalModelRequest = { * One-liner description for quick scanning */ description: string; + /** + * Subtype + * + * Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided) + */ + subtype?: string; + /** + * Observations + * + * For directives only: list of user-provided observations. Required when subtype='directive'. + */ + observations?: Array | null; /** * Tags * @@ -830,6 +842,38 @@ export type MemoryItem = { tags?: Array | null; }; +/** + * MentalModelFreshnessResponse + * + * Freshness information for a mental model. + */ +export type MentalModelFreshnessResponse = { + /** + * Is Up To Date + * + * Whether the model has been refreshed since the last memory was added + */ + is_up_to_date: boolean; + /** + * Last Refresh At + * + * When the model was last refreshed (ISO format) + */ + last_refresh_at: string | null; + /** + * Memories Since Refresh + * + * Number of memories added since last refresh + */ + memories_since_refresh: number; + /** + * Reasons + * + * Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed + */ + reasons?: Array; +}; + /** * MentalModelListResponse * @@ -845,27 +889,53 @@ export type MentalModelListResponse = { /** * MentalModelObservationResponse * - * An observation within a mental model with its supporting memories. + * An observation within a mental model with its supporting evidence. */ export type MentalModelObservationResponse = { /** * Title * - * Observation header (empty for intro) + * Short summary title for the observation */ title: string; /** - * Text + * Content * - * Observation content + * The observation content - detailed explanation */ - text: string; + content: string; /** - * Based On + * Evidence * - * Memory IDs supporting this observation + * Supporting evidence with quotes */ - based_on?: Array; + evidence?: Array; + /** + * Created At + * + * When this observation was first created (ISO format) + */ + created_at: string; + /** + * Trend + * + * Computed trend: stable, strengthening, weakening, new, stale + */ + trend: string; + /** + * Evidence Count + * + * Number of evidence items supporting this observation + */ + evidence_count: number; + /** + * Evidence Span + * + * Time span of evidence: {from: iso_date, to: iso_date} + */ + evidence_span: { + [key: string]: unknown; + }; }; /** @@ -900,6 +970,12 @@ export type MentalModelResponse = { * Structured observations with per-observation fact attribution */ observations?: Array; + /** + * Version + * + * Version number of the mental model observations + */ + version?: number; /** * Entity Id */ @@ -916,12 +992,74 @@ export type MentalModelResponse = { * Last Updated */ last_updated?: string | null; + /** + * Last Refresh At + * + * When observations were last refreshed (ISO format) + */ + last_refresh_at?: string | null; + /** + * Freshness info (null for directive subtypes which don't need refresh) + */ + freshness?: MentalModelFreshnessResponse | null; /** * Created At */ created_at: string; }; +/** + * ObservationEvidenceResponse + * + * A single piece of evidence supporting an observation. + */ +export type ObservationEvidenceResponse = { + /** + * Memory Id + * + * ID of the memory unit this evidence comes from + */ + memory_id: string; + /** + * Quote + * + * Exact quote from the memory supporting the observation + */ + quote: string; + /** + * Relevance + * + * Brief explanation of how this quote supports the observation + */ + relevance: string; + /** + * Timestamp + * + * When the source memory was created (ISO format) + */ + timestamp: string; +}; + +/** + * ObservationInput + * + * Input model for a single observation. + */ +export type ObservationInput = { + /** + * Title + * + * Short title/header for the observation + */ + title: string; + /** + * Content + * + * Content of the observation + */ + content: string; +}; + /** * OperationResponse * @@ -1270,21 +1408,15 @@ export type ReflectMentalModel = { /** * Subtype * - * Mental model subtype: structural, emergent, learned + * Mental model subtype: structural, emergent, learned, directive */ subtype: string; /** - * Description + * Observations * - * Brief description + * Observations for directive mental models (subtype='directive') */ - description: string; - /** - * Summary - * - * Full summary (when looked up in detail) - */ - summary?: string | null; + observations?: Array | null; }; /** @@ -1436,6 +1568,12 @@ export type ReflectTrace = { * LLM calls made during reflection */ llm_calls?: Array; + /** + * Mental Models + * + * Mental models used during reflection (includes directives with subtype='directive') + */ + mental_models?: Array; }; /** @@ -1590,6 +1728,26 @@ export type UpdateDispositionRequest = { disposition: DispositionTraits; }; +/** + * UpdateMentalModelRequest + * + * Request model for updating a mental model. + */ +export type UpdateMentalModelRequest = { + /** + * Name + * + * New name for the mental model + */ + name?: string | null; + /** + * Description + * + * New description/rule text + */ + description?: string | null; +}; + /** * ValidationError */ @@ -2226,6 +2384,48 @@ export type GetMentalModelResponses = { export type GetMentalModelResponse = GetMentalModelResponses[keyof GetMentalModelResponses]; +export type UpdateMentalModelData = { + body: UpdateMentalModelRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Model Id + */ + model_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}"; +}; + +export type UpdateMentalModelErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateMentalModelError = + UpdateMentalModelErrors[keyof UpdateMentalModelErrors]; + +export type UpdateMentalModelResponses = { + /** + * Successful Response + */ + 200: MentalModelResponse; +}; + +export type UpdateMentalModelResponse = + UpdateMentalModelResponses[keyof UpdateMentalModelResponses]; + export type RefreshMentalModelsData = { /** * Body @@ -2267,7 +2467,7 @@ export type RefreshMentalModelsResponses = { export type RefreshMentalModelsResponse = RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses]; -export type GenerateMentalModelData = { +export type RefreshMentalModelData = { body?: never; headers?: { /** @@ -2286,28 +2486,110 @@ export type GenerateMentalModelData = { model_id: string; }; query?: never; - url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate"; + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh"; }; -export type GenerateMentalModelErrors = { +export type RefreshMentalModelErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GenerateMentalModelError = - GenerateMentalModelErrors[keyof GenerateMentalModelErrors]; +export type RefreshMentalModelError = + RefreshMentalModelErrors[keyof RefreshMentalModelErrors]; -export type GenerateMentalModelResponses = { +export type RefreshMentalModelResponses = { /** * Successful Response */ 200: AsyncOperationSubmitResponse; }; -export type GenerateMentalModelResponse = - GenerateMentalModelResponses[keyof GenerateMentalModelResponses]; +export type RefreshMentalModelResponse = + RefreshMentalModelResponses[keyof RefreshMentalModelResponses]; + +export type ListMentalModelVersionsData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Model Id + */ + model_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions"; +}; + +export type ListMentalModelVersionsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListMentalModelVersionsError = + ListMentalModelVersionsErrors[keyof ListMentalModelVersionsErrors]; + +export type ListMentalModelVersionsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetMentalModelVersionData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Model Id + */ + model_id: string; + /** + * Version + */ + version: number; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}"; +}; + +export type GetMentalModelVersionErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetMentalModelVersionError = + GetMentalModelVersionErrors[keyof GetMentalModelVersionErrors]; + +export type GetMentalModelVersionResponses = { + /** + * Successful Response + */ + 200: unknown; +}; export type ListDocumentsData = { body?: never; diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index 217646f8..b980dfc2 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -38,6 +38,8 @@ "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/typography": "^0.5.19", "@types/cytoscape": "^3.21.9", diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/generate/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts similarity index 66% rename from hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/generate/route.ts rename to hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts index 40486209..8e63aa87 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/generate/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts @@ -16,19 +16,19 @@ export async function POST( return NextResponse.json({ error: "model_id is required" }, { status: 400 }); } - const response = await sdk.generateMentalModel({ + const response = await sdk.refreshMentalModel({ client: lowLevelClient, path: { bank_id: bankId, model_id: modelId }, }); if (response.error) { - console.error("API error generating mental model:", response.error); - return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 }); + 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 generating mental model:", error); - return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 }); + console.error("Error refreshing mental model:", error); + return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 }); } } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts index efab07e6..d0ca5b40 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts @@ -1,6 +1,52 @@ 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( + 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 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), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error updating mental model:", errorText); + return NextResponse.json( + { error: errorText || "Failed to update mental model" }, + { status: response.status } + ); + } + + 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 }> } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts new file mode 100644 index 00000000..5587f4b8 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ bankId: string; modelId: string; version: string }> } +) { + try { + const { bankId, modelId, version } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + if (!modelId) { + return NextResponse.json({ error: "model_id is required" }, { status: 400 }); + } + + if (!version) { + return NextResponse.json({ error: "version is required" }, { status: 400 }); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions/${version}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error getting mental model version:", errorText); + return NextResponse.json( + { error: errorText || "Failed to get mental model version" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error getting mental model version:", error); + return NextResponse.json({ error: "Failed to get mental model version" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts new file mode 100644 index 00000000..29f2ea7d --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; + +const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ bankId: string; modelId: string }> } +) { + try { + const { bankId, modelId } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + if (!modelId) { + return NextResponse.json({ error: "model_id is required" }, { status: 400 }); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("API error listing mental model versions:", errorText); + return NextResponse.json( + { error: errorText || "Failed to list mental model versions" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error listing mental model versions:", error); + return NextResponse.json({ error: "Failed to list mental model versions" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts index c9d3e60a..39fd3211 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts @@ -6,11 +6,34 @@ const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://loca 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({ client: lowLevelClient, path: { bank_id: bankId }, diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 7a84a5bd..a3af4d44 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -18,6 +18,7 @@ import { Settings2, Eye, EyeOff, + RefreshCw, } from "lucide-react"; import { Table, @@ -248,11 +249,9 @@ export function DataView({ factType }: DataViewProps) { return (
{loading ? ( -
-
-
-
Loading memories...
-
+
+ +

Loading memories...

) : data ? ( <> diff --git a/hindsight-control-plane/src/components/memory-detail-modal.tsx b/hindsight-control-plane/src/components/memory-detail-modal.tsx new file mode 100644 index 00000000..0668cee5 --- /dev/null +++ b/hindsight-control-plane/src/components/memory-detail-modal.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { client } from "@/lib/api"; +import { useBank } from "@/lib/bank-context"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Loader2, Calendar, Tag, Users, FileText, Layers } from "lucide-react"; + +interface MemoryDetail { + id: string; + text: string; + context: string; + date: string; + type: string; + mentioned_at: string | null; + occurred_start: string | null; + occurred_end: string | null; + entities: string[]; + document_id: string | null; + chunk_id: string | null; + tags: string[]; +} + +interface MemoryDetailModalProps { + memoryId: string | null; + onClose: () => void; +} + +export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) { + const { currentBank } = useBank(); + const [memory, setMemory] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("memory"); + + // Document and chunk data + const [document, setDocument] = useState(null); + const [chunk, setChunk] = useState(null); + const [loadingDocument, setLoadingDocument] = useState(false); + const [loadingChunk, setLoadingChunk] = useState(false); + + // Load memory details + useEffect(() => { + if (!memoryId || !currentBank) return; + + const loadMemory = async () => { + setLoading(true); + setError(null); + setMemory(null); + setDocument(null); + setChunk(null); + setActiveTab("memory"); + + try { + const data = await client.getMemory(memoryId, currentBank); + setMemory(data); + } catch (err) { + console.error("Error loading memory:", err); + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + loadMemory(); + }, [memoryId, currentBank]); + + // Load document when tab is selected + useEffect(() => { + if (activeTab !== "document" || !memory?.document_id || !currentBank || document) return; + + const loadDocument = async () => { + setLoadingDocument(true); + try { + const data = await client.getDocument(memory.document_id!, currentBank); + setDocument(data); + } catch (err) { + console.error("Error loading document:", err); + } finally { + setLoadingDocument(false); + } + }; + + loadDocument(); + }, [activeTab, memory?.document_id, currentBank, document]); + + // Load chunk when tab is selected + useEffect(() => { + if (activeTab !== "chunk" || !memory?.chunk_id || chunk) return; + + const loadChunk = async () => { + setLoadingChunk(true); + try { + const data = await client.getChunk(memory.chunk_id!); + setChunk(data); + } catch (err) { + console.error("Error loading chunk:", err); + } finally { + setLoadingChunk(false); + } + }; + + loadChunk(); + }, [activeTab, memory?.chunk_id, chunk]); + + const isOpen = memoryId !== null; + + return ( + !open && onClose()}> + + + Memory Details + + + {loading ? ( +
+ +
+ ) : error ? ( +
+
+
Error: {error}
+
+
+ ) : memory ? ( + + + + + Memory + + + + Chunk + + + + Document + + + +
+ + {/* Memory text */} +
+
+ Memory Text +
+

{memory.text}

+
+ + {/* Metadata grid */} +
+
+
+ Type +
+
{memory.type}
+
+ {memory.context && ( +
+
+ Context +
+
{memory.context}
+
+ )} +
+ + {/* Dates */} + {(memory.mentioned_at || memory.occurred_start) && ( +
+ {memory.mentioned_at && ( +
+
+ + Mentioned At +
+
+ {new Date(memory.mentioned_at).toLocaleString()} +
+
+ )} + {memory.occurred_start && ( +
+
+ + Occurred +
+
+ {new Date(memory.occurred_start).toLocaleDateString()} + {memory.occurred_end && memory.occurred_end !== memory.occurred_start && ( + <> - {new Date(memory.occurred_end).toLocaleDateString()} + )} +
+
+ )} +
+ )} + + {/* Entities */} + {memory.entities && memory.entities.length > 0 && ( +
+
+ + Entities +
+
+ {memory.entities.map((entity, idx) => ( + + {entity} + + ))} +
+
+ )} + + {/* Tags */} + {memory.tags && memory.tags.length > 0 && ( +
+
+ + Tags +
+
+ {memory.tags.map((tag, idx) => ( + + {tag} + + ))} +
+
+ )} + + {/* IDs */} +
+
+ Memory ID +
+ + {memory.id} + +
+
+ + + {loadingChunk ? ( +
+ +
+ ) : chunk ? ( + <> +
+
+
+ Chunk Index +
+
{chunk.chunk_index}
+
+ {chunk.chunk_text && ( +
+
+ Text Length +
+
+ {chunk.chunk_text.length.toLocaleString()} chars +
+
+ )} +
+ + {chunk.chunk_text && ( +
+
+ Chunk Text +
+
+
+                            {chunk.chunk_text}
+                          
+
+
+ )} + +
+
+ Chunk ID +
+ + {chunk.chunk_id} + +
+ + ) : ( +
+ No chunk data available +
+ )} +
+ + + {loadingDocument ? ( +
+ +
+ ) : document ? ( + <> +
+ {document.created_at && ( +
+
+ Created +
+
+ {new Date(document.created_at).toLocaleString()} +
+
+ )} +
+
+ Memory Units +
+
{document.memory_unit_count}
+
+
+ + {document.original_text && ( + <> +
+
+ Text Length +
+
+ {document.original_text.length.toLocaleString()} chars +
+
+ +
+
+ Original Text +
+
+
+                              {document.original_text}
+                            
+
+
+ + )} + +
+
+ Document ID +
+ + {document.id} + +
+ + ) : ( +
+ No document data available +
+ )} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/hindsight-control-plane/src/components/mental-models-view.tsx b/hindsight-control-plane/src/components/mental-models-view.tsx index 28c3730a..856346da 100644 --- a/hindsight-control-plane/src/components/mental-models-view.tsx +++ b/hindsight-control-plane/src/components/mental-models-view.tsx @@ -48,30 +48,58 @@ import { ChevronRight, Loader2, ExternalLink, + AlertTriangle, + Trash2, + History, + ArrowLeft, + ArrowRight, } from "lucide-react"; -import { DocumentChunkModal } from "./document-chunk-modal"; +import { MemoryDetailModal } from "./memory-detail-modal"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; type ViewMode = "dashboard" | "table"; -interface MentalModelObservation { - title: string; - text: string; - based_on: string[]; +interface ObservationEvidence { + memory_id: string; + quote: string; + relevance: string; + timestamp: string; } -interface MemoryDetail { - id: string; - text: string; - context: string; - date: string; - type: string; - mentioned_at: string | null; - occurred_start: string | null; - occurred_end: string | null; - entities: string[]; - document_id: string | null; - chunk_id: string | null; - tags: string[]; +interface MentalModelObservation { + title: string; + // 'content' is used for generated observations, 'text' for directives + content?: string; + text?: string; + evidence?: ObservationEvidence[]; + based_on?: string[]; // For directives + created_at?: string; + trend?: "stable" | "strengthening" | "weakening" | "new" | "stale"; + evidence_count?: number; + evidence_span?: { from: string | null; to: string | null }; +} + +interface MentalModelFreshness { + is_up_to_date: boolean; + last_refresh_at: string | null; + memories_since_refresh: number; + reasons: string[]; +} + +interface MentalModelVersion { + version: number; + created_at: string | null; + observation_count: number; +} + +interface VersionObservation { + title: string; + content: string; + evidence: ObservationEvidence[]; + created_at: string; + trend: string; + evidence_count: number; + evidence_span: { from: string | null; to: string | null }; } interface MentalModel { @@ -85,12 +113,38 @@ interface MentalModel { links: string[]; tags?: string[]; last_updated: string | null; + last_refresh_at?: string | null; + freshness?: MentalModelFreshness | null; created_at: string; } // Helper to count total source memories across all observations function getTotalMemoryCount(model: MentalModel): number { - return model.observations?.reduce((sum, obs) => sum + (obs.based_on?.length || 0), 0) || 0; + return model.observations?.reduce((sum, obs) => sum + (obs.evidence?.length || 0), 0) || 0; +} + +// Helper to format freshness reasons for display +function formatFreshnessReason(freshness: MentalModelFreshness): string { + if (freshness.is_up_to_date) return "Up to date"; + if (!freshness.reasons || freshness.reasons.length === 0) { + // Fallback to memory count if no reasons + return freshness.memories_since_refresh > 0 + ? `${freshness.memories_since_refresh} new` + : "Stale"; + } + + // Map reason codes to human-readable labels + const reasonLabels: Record = { + never_refreshed: "Never refreshed", + new_memories: `${freshness.memories_since_refresh} new`, + mission_changed: "Mission changed", + disposition_changed: "Disposition changed", + directives_changed: "Directives changed", + }; + + // Return the first reason (most important) + const primaryReason = freshness.reasons[0]; + return reasonLabels[primaryReason] || primaryReason; } export function MentalModelsView() { @@ -117,11 +171,17 @@ export function MentalModelsView() { // Create mental model state const [showCreateForm, setShowCreateForm] = useState(false); + const [createType, setCreateType] = useState<"pinned" | "directive">("pinned"); const [creating, setCreating] = useState(false); - const [newModel, setNewModel] = useState({ - name: "", - description: "", - }); + const [newModel, setNewModel] = useState({ name: "", description: "", tags: "" }); + + // Delete state + const [deletingModel, setDeletingModel] = useState(null); + + // Edit state + const [editingModel, setEditingModel] = useState(null); + const [editForm, setEditForm] = useState({ name: "", description: "" }); + const [saving, setSaving] = useState(false); // Auto-refresh interval (5 seconds) const AUTO_REFRESH_INTERVAL = 5000; @@ -260,13 +320,26 @@ export function MentalModelsView() { setCreating(true); try { + // Parse comma-separated tags + const tags = newModel.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + await client.createMentalModel(currentBank, { name: newModel.name.trim(), description: newModel.description.trim(), + subtype: createType === "directive" ? "directive" : undefined, + tags: tags.length > 0 ? tags : undefined, + observations: + createType === "directive" + ? [{ title: newModel.name.trim(), content: newModel.description.trim() }] + : undefined, }); // Reset form and reload - setNewModel({ name: "", description: "" }); + setNewModel({ name: "", description: "", tags: "" }); + setCreateType("pinned"); setShowCreateForm(false); await loadMentalModels(); } catch (error) { @@ -277,6 +350,48 @@ export function MentalModelsView() { } }; + const handleDeleteModel = async (modelId: string) => { + if (!currentBank) return; + + setDeletingModel(modelId); + try { + await client.deleteMentalModel(currentBank, modelId); + await loadMentalModels(); + if (selectedModel?.id === modelId) { + setSelectedModel(null); + } + } catch (error) { + console.error("Error deleting mental model:", error); + alert("Error deleting mental model: " + (error as Error).message); + } finally { + setDeletingModel(null); + } + }; + + const handleStartEdit = (model: MentalModel) => { + setEditingModel(model); + setEditForm({ name: model.name, description: model.description }); + }; + + const handleSaveEdit = async () => { + if (!currentBank || !editingModel) return; + + setSaving(true); + try { + await client.updateMentalModel(currentBank, editingModel.id, { + name: editForm.name.trim(), + description: editForm.description.trim(), + }); + setEditingModel(null); + await loadMentalModels(); + } catch (error) { + console.error("Error updating mental model:", error); + alert("Error updating mental model: " + (error as Error).message); + } finally { + setSaving(false); + } + }; + useEffect(() => { if (currentBank) { loadMentalModels(); @@ -316,6 +431,7 @@ export function MentalModelsView() { const structuralModels = mentalModels.filter((m) => m.subtype === "structural"); const emergentModels = mentalModels.filter((m) => m.subtype === "emergent"); const learnedModels = mentalModels.filter((m) => m.subtype === "learned"); + const directiveModels = mentalModels.filter((m) => m.subtype === "directive"); const getSubtypeIcon = (subtype: string) => { switch (subtype) { @@ -327,6 +443,8 @@ export function MentalModelsView() { return ; case "learned": return ; + case "directive": + return ; default: return ; } @@ -408,8 +526,8 @@ export function MentalModelsView() { disabled={!!refreshing || !mission} title={ !mission - ? "Set a mission first to generate mental models" - : "Regenerate mental models" + ? "Set a mission first to refresh mental models" + : "Refresh mental models" } className="h-8" > @@ -418,7 +536,7 @@ export function MentalModelsView() { ) : ( )} - {refreshing ? "Regenerating..." : "Regenerate"} + {refreshing ? "Refreshing..." : "Refresh"} @@ -492,7 +610,7 @@ export function MentalModelsView() { ? "bg-blue-500/10 border border-blue-500/20" : operationStatus.status === "completed" ? "bg-emerald-500/10 border border-emerald-500/20" - : "bg-red-500/10 border border-red-500/20" + : "bg-rose-500/10 border border-rose-500/20" }`} > {operationStatus.status === "pending" ? ( @@ -500,20 +618,20 @@ export function MentalModelsView() { ) : operationStatus.status === "completed" ? ( ) : ( - + )} {operationStatus.status === "pending" - ? `Regenerating ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}...` + ? `Refreshing ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}...` : operationStatus.status === "completed" - ? `Successfully regenerated ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}` - : `Failed to regenerate ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}`} + ? `Successfully refreshed ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}` + : `Failed to refresh ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}`} {operationStatus.status === "pending" && ( Running in background... )} {operationStatus.errorMessage && ( - {operationStatus.errorMessage} + {operationStatus.errorMessage} )} + +
+
setNewModel({ ...newModel, name: e.target.value })} - placeholder="e.g., Product Roadmap, Team Structure, Q1 Goals" + placeholder={ + createType === "directive" + ? "e.g., Competitor Policy, Response Guidelines" + : "e.g., Product Roadmap, Team Structure, Q1 Goals" + } />
- +