Compare commits

...
8 Commits
Author SHA1 Message Date
Nicolò Boschi e91ce73e59 update 2026-01-19 11:32:17 +01:00
Nicolò Boschi baba49c9aa update 2026-01-19 09:34:54 +01:00
Nicolò Boschi 652e6a7c8c fix 2026-01-19 09:24:10 +01:00
Nicolò Boschi d490b83776 fix 2026-01-16 18:33:34 +01:00
Nicolò Boschi 27ee64adab ui 2026-01-16 17:05:54 +01:00
Nicolò Boschi bbab5686f4 tags 2026-01-16 17:02:34 +01:00
Nicolò Boschi 67e19e5908 feat: improve mental model refresh and add directives 2026-01-16 15:54:48 +01:00
Nicolò Boschi 24a331dfd9 feat: improve mental model refresh and add directives 2026-01-16 14:28:03 +01:00
57 changed files with 9899 additions and 1316 deletions
+1 -1
View File
@@ -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
-39
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6"
"admin" ""
+32
View File
@@ -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
@@ -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")
@@ -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'))
""")
+381 -35
View File
@@ -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(
File diff suppressed because it is too large Load Diff
@@ -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):
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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"
)
@@ -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
@@ -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)
@@ -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,
@@ -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)
@@ -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.",
)
@@ -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",
@@ -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
+125
View File
@@ -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."""
+1 -13
View File
@@ -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"]
+4
View File
@@ -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()
+639
View File
@@ -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"]
@@ -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
+171 -4
View File
@@ -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:
+23
View File
@@ -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")
@@ -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()
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = <ThrowOnError extends boolean = false>(
/**
* 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 = <ThrowOnError extends boolean = false>(
options: Options<CreateMentalModelData, ThrowOnError>,
@@ -395,6 +406,27 @@ export const getMentalModel = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Update mental model
*
* Update a mental model's name and/or description. Useful for editing directives.
*/
export const updateMentalModel = <ThrowOnError extends boolean = false>(
options: Options<UpdateMentalModelData, ThrowOnError>,
) =>
(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 = <ThrowOnError extends boolean = false>(
});
/**
* 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 = <ThrowOnError extends boolean = false>(
options: Options<GenerateMentalModelData, ThrowOnError>,
export const refreshMentalModel = <ThrowOnError extends boolean = false>(
options: Options<RefreshMentalModelData, ThrowOnError>,
) =>
(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 = <ThrowOnError extends boolean = false>(
options: Options<ListMentalModelVersionsData, ThrowOnError>,
) =>
(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 = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelVersionData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelVersionResponses,
GetMentalModelVersionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
...options,
});
@@ -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<ObservationInput> | null;
/**
* Tags
*
@@ -830,6 +842,38 @@ export type MemoryItem = {
tags?: Array<string> | 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<string>;
};
/**
* 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<string>;
evidence?: Array<ObservationEvidenceResponse>;
/**
* 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<MentalModelObservationResponse>;
/**
* 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<string> | null;
};
/**
@@ -1436,6 +1568,12 @@ export type ReflectTrace = {
* LLM calls made during reflection
*/
llm_calls?: Array<ReflectLlmCall>;
/**
* Mental Models
*
* Mental models used during reflection (includes directives with subtype='directive')
*/
mental_models?: Array<ReflectMentalModel>;
};
/**
@@ -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;
+2
View File
@@ -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",
@@ -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 });
}
}
@@ -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 }> }
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 },
@@ -18,6 +18,7 @@ import {
Settings2,
Eye,
EyeOff,
RefreshCw,
} from "lucide-react";
import {
Table,
@@ -248,11 +249,9 @@ export function DataView({ factType }: DataViewProps) {
return (
<div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-muted-foreground">Loading memories...</div>
</div>
<div className="text-center py-12">
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<p className="text-muted-foreground">Loading memories...</p>
</div>
) : data ? (
<>
@@ -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<MemoryDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("memory");
// Document and chunk data
const [document, setDocument] = useState<any>(null);
const [chunk, setChunk] = useState<any>(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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Memory Details</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center justify-center py-20">
<div className="text-center text-destructive">
<div className="text-sm">Error: {error}</div>
</div>
</div>
) : memory ? (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="memory" className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Memory
</TabsTrigger>
<TabsTrigger
value="chunk"
disabled={!memory.chunk_id}
className="flex items-center gap-1.5"
>
<Layers className="w-3.5 h-3.5" />
Chunk
</TabsTrigger>
<TabsTrigger
value="document"
disabled={!memory.document_id}
className="flex items-center gap-1.5"
>
<FileText className="w-3.5 h-3.5" />
Document
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto mt-4">
<TabsContent value="memory" className="mt-0 space-y-4">
{/* Memory text */}
<div className="p-4 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Memory Text
</div>
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
</div>
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Type
</div>
<div className="text-sm text-foreground capitalize">{memory.type}</div>
</div>
{memory.context && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Context
</div>
<div className="text-sm text-foreground">{memory.context}</div>
</div>
)}
</div>
{/* Dates */}
{(memory.mentioned_at || memory.occurred_start) && (
<div className="grid grid-cols-2 gap-3">
{memory.mentioned_at && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Mentioned At
</div>
<div className="text-sm text-foreground">
{new Date(memory.mentioned_at).toLocaleString()}
</div>
</div>
)}
{memory.occurred_start && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Occurred
</div>
<div className="text-sm text-foreground">
{new Date(memory.occurred_start).toLocaleDateString()}
{memory.occurred_end && memory.occurred_end !== memory.occurred_start && (
<> - {new Date(memory.occurred_end).toLocaleDateString()}</>
)}
</div>
</div>
)}
</div>
)}
{/* Entities */}
{memory.entities && memory.entities.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Users className="w-3 h-3" />
Entities
</div>
<div className="flex flex-wrap gap-1.5">
{memory.entities.map((entity, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-background rounded text-xs text-foreground"
>
{entity}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{memory.tags && memory.tags.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Tags
</div>
<div className="flex flex-wrap gap-1.5">
{memory.tags.map((tag, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* IDs */}
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Memory ID
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{memory.id}
</code>
</div>
</TabsContent>
<TabsContent value="chunk" className="mt-0 space-y-4">
{loadingChunk ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : chunk ? (
<>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Chunk Index
</div>
<div className="text-sm text-foreground">{chunk.chunk_index}</div>
</div>
{chunk.chunk_text && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Text Length
</div>
<div className="text-sm text-foreground">
{chunk.chunk_text.length.toLocaleString()} chars
</div>
</div>
)}
</div>
{chunk.chunk_text && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Chunk Text
</div>
<div className="p-4 bg-muted rounded-lg border border-border max-h-[300px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono text-foreground">
{chunk.chunk_text}
</pre>
</div>
</div>
)}
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Chunk ID
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{chunk.chunk_id}
</code>
</div>
</>
) : (
<div className="text-center py-12 text-muted-foreground">
No chunk data available
</div>
)}
</TabsContent>
<TabsContent value="document" className="mt-0 space-y-4">
{loadingDocument ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : document ? (
<>
<div className="grid grid-cols-2 gap-3">
{document.created_at && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Created
</div>
<div className="text-sm text-foreground">
{new Date(document.created_at).toLocaleString()}
</div>
</div>
)}
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Memory Units
</div>
<div className="text-sm text-foreground">{document.memory_unit_count}</div>
</div>
</div>
{document.original_text && (
<>
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Text Length
</div>
<div className="text-sm text-foreground">
{document.original_text.length.toLocaleString()} chars
</div>
</div>
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Original Text
</div>
<div className="p-4 bg-muted rounded-lg border border-border max-h-[300px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono text-foreground">
{document.original_text}
</pre>
</div>
</div>
</>
)}
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Document ID
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{document.id}
</code>
</div>
</>
) : (
<div className="text-center py-12 text-muted-foreground">
No document data available
</div>
)}
</TabsContent>
</div>
</Tabs>
) : null}
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -14,9 +14,21 @@ import {
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, Info, Tag, Clock, Database, Brain } from "lucide-react";
import {
Sparkles,
Info,
Tag,
Clock,
Database,
Brain,
MessageSquare,
Shield,
X,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "answer" | "trace" | "json";
@@ -33,6 +45,94 @@ export function ThinkView() {
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState("");
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
const [feedback, setFeedback] = useState("");
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
const [selectedMemory, setSelectedMemory] = useState<any | null>(null);
const [selectedDirective, setSelectedDirective] = useState<any | null>(null);
const [fullDirective, setFullDirective] = useState<any | null>(null);
const [loadingDirective, setLoadingDirective] = useState(false);
const [selectedMentalModel, setSelectedMentalModel] = useState<any | null>(null);
const [fullMentalModel, setFullMentalModel] = useState<any | null>(null);
const [loadingMentalModel, setLoadingMentalModel] = useState(false);
const FEEDBACK_DIRECTIVE_NAME = "General Feedback";
// Load full directive data when one is selected
const handleSelectDirective = async (directive: any) => {
setSelectedDirective(directive);
setFullDirective(null);
if (!currentBank || !directive?.id) return;
setLoadingDirective(true);
try {
const directives = await client.listDirectives(currentBank);
const fullDir = directives.items?.find((d: any) => d.id === directive.id);
setFullDirective(fullDir || directive);
} catch (error) {
console.error("Failed to load directive:", error);
setFullDirective(directive); // Fall back to partial data
} finally {
setLoadingDirective(false);
}
};
// Load full mental model data when one is selected
const handleSelectMentalModel = async (model: any) => {
setSelectedMentalModel(model);
setFullMentalModel(null);
if (!currentBank || !model?.id) return;
setLoadingMentalModel(true);
try {
const models = await client.listMentalModels(currentBank);
const fullModel = models.items?.find((m: any) => m.id === model.id);
setFullMentalModel(fullModel || model);
} catch (error) {
console.error("Failed to load mental model:", error);
setFullMentalModel(model); // Fall back to partial data
} finally {
setLoadingMentalModel(false);
}
};
const submitFeedback = async () => {
if (!currentBank || !feedback.trim()) return;
setFeedbackSubmitting(true);
try {
// Find existing "General Feedback" directive
const directives = await client.listDirectives(currentBank);
const existingDirective = directives.items?.find((d) => d.name === FEEDBACK_DIRECTIVE_NAME);
if (existingDirective) {
// Append to existing directive description
const newDescription = existingDirective.description
? `${existingDirective.description}\n${feedback.trim()}`
: feedback.trim();
await client.updateMentalModel(currentBank, existingDirective.id, {
description: newDescription,
});
} else {
// Create new directive with observation
await client.createMentalModel(currentBank, {
name: FEEDBACK_DIRECTIVE_NAME,
description: "User feedback for improving responses",
subtype: "directive",
observations: [{ title: "Feedback", content: feedback.trim() }],
});
}
setFeedback("");
setFeedbackSubmitted(true);
setTimeout(() => setFeedbackSubmitted(false), 3000);
} catch (error) {
console.error("Error submitting feedback:", error);
alert("Error submitting feedback: " + (error as Error).message);
} finally {
setFeedbackSubmitting(false);
}
};
const runReflect = async () => {
if (!currentBank || !query) return;
@@ -135,7 +235,7 @@ export function ThinkView() {
checked={includeFacts}
onCheckedChange={(c) => setIncludeFacts(c as boolean)}
/>
<span className="text-sm">Include Facts</span>
<span className="text-sm">Include Source</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
@@ -288,6 +388,50 @@ export function ThinkView() {
</CardContent>
</Card>
)}
{/* Feedback */}
<Card className="border-blue-200 dark:border-blue-800">
<CardHeader className="py-4">
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="w-4 h-4" />
Provide Feedback
</CardTitle>
<CardDescription className="text-xs">
Your feedback will be saved as a directive to improve future responses
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
{feedbackSubmitted ? (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<span className="text-lg">&#10003;</span>
<span className="text-sm font-medium">
Feedback saved to {FEEDBACK_DIRECTIVE_NAME}
</span>
</div>
) : (
<div className="flex gap-3">
<Textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Enter your feedback here..."
className="flex-1 min-h-[60px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
submitFeedback();
}
}}
/>
<Button
onClick={submitFeedback}
disabled={feedbackSubmitting || !feedback.trim()}
className="self-end"
>
{feedbackSubmitting ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)}
@@ -377,7 +521,17 @@ export function ThinkView() {
}> = [];
llmCalls.forEach((lc: any, idx: number) => {
const isFinal = lc.scope.includes("final");
// Add tools for this iteration (using iteration field from tool trace)
const iterTools = toolCalls.filter(
(tc: any) => tc.iteration === idx + 1
);
// Determine if this is the final LLM call:
// - scope includes "final", OR
// - it's the last LLM call AND no tools were called after it
const isLastLLMCall = idx === llmCalls.length - 1;
const isFinal =
lc.scope.includes("final") ||
(isLastLLMCall && iterTools.length === 0);
const iterNum = isFinal ? llmCalls.length : idx + 1;
// Add LLM call
@@ -388,10 +542,6 @@ export function ThinkView() {
isFinal,
});
// Add tools for this iteration (using iteration field from tool trace)
const iterTools = toolCalls.filter(
(tc: any) => tc.iteration === idx + 1
);
if (iterTools.length > 0) {
timeline.push({
type: "tools",
@@ -517,7 +667,11 @@ export function ThinkView() {
<CardTitle className="text-base">Based On</CardTitle>
<CardDescription className="text-xs">
{(result.based_on?.memories?.length || 0) +
(result.based_on?.mental_models?.length || 0)}{" "}
(result.based_on?.mental_models?.filter(
(m: any) => m.subtype !== "directive"
)?.length || 0) +
(result.trace?.mental_models?.filter((m: any) => m.subtype === "directive")
?.length || 0)}{" "}
items used
</CardDescription>
</CardHeader>
@@ -528,7 +682,7 @@ export function ThinkView() {
<div>
<p className="font-medium text-sm text-foreground">Not included</p>
<p className="text-xs text-muted-foreground mt-0.5">
Enable "Include Facts" to see memories.
Enable "Include Source" to see memories.
</p>
</div>
</div>
@@ -543,10 +697,53 @@ export function ThinkView() {
(f: any) => f.type === "experience"
);
const opinionFacts = memories.filter((f: any) => f.type === "opinion");
const mentalModels = result.based_on?.mental_models || [];
const mentalModels = (result.based_on?.mental_models || []).filter(
(m: any) => m.subtype !== "directive"
);
const directives =
result.trace?.mental_models?.filter(
(m: any) => m.subtype === "directive"
) || [];
return (
<>
{/* Directives */}
{directives.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Shield className="w-3 h-3" />
Directives ({directives.length})
</div>
<div className="space-y-1.5">
{directives.map((directive: any, i: number) => (
<div
key={i}
className="p-2 bg-muted rounded text-xs cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => handleSelectDirective(directive)}
>
<div className="font-medium">{directive.name}</div>
{directive.observations &&
directive.observations.length > 0 && (
<ul className="mt-1 space-y-0.5">
{directive.observations.map(
(obs: string, j: number) => (
<li
key={j}
className="text-[10px] text-muted-foreground flex items-start gap-1"
>
<span></span>
<span>{obs}</span>
</li>
)
)}
</ul>
)}
</div>
))}
</div>
</div>
)}
{/* Mental Models */}
{mentalModels.length > 0 && (
<div className="space-y-1.5">
@@ -556,13 +753,12 @@ export function ThinkView() {
</div>
<div className="space-y-1.5">
{mentalModels.map((model: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
<div
key={i}
className="p-2 bg-muted rounded text-xs cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => handleSelectMentalModel(model)}
>
<div className="font-medium">{model.name}</div>
{model.description && (
<div className="text-[10px] text-muted-foreground mt-1">
{model.description}
</div>
)}
</div>
))}
</div>
@@ -578,7 +774,11 @@ export function ThinkView() {
</div>
<div className="space-y-1.5">
{worldFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
<div
key={i}
className="p-2 bg-muted rounded text-xs cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => setSelectedMemory(fact)}
>
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
@@ -600,7 +800,11 @@ export function ThinkView() {
</div>
<div className="space-y-1.5">
{experienceFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
<div
key={i}
className="p-2 bg-muted rounded text-xs cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => setSelectedMemory(fact)}
>
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
@@ -622,7 +826,11 @@ export function ThinkView() {
</div>
<div className="space-y-1.5">
{opinionFacts.map((fact: any, i: number) => (
<div key={i} className="p-2 bg-muted rounded text-xs">
<div
key={i}
className="p-2 bg-muted rounded text-xs cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => setSelectedMemory(fact)}
>
{fact.text}
{fact.context && (
<div className="text-[10px] text-muted-foreground mt-1">
@@ -686,6 +894,224 @@ export function ThinkView() {
</CardContent>
</Card>
)}
{/* Memory Detail Panel */}
{selectedMemory && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l shadow-2xl z-50 overflow-y-auto">
<MemoryDetailPanel
memory={selectedMemory}
onClose={() => setSelectedMemory(null)}
inPanel
bankId={currentBank || undefined}
/>
</div>
)}
{/* Directive Detail Panel */}
{selectedDirective && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l shadow-2xl z-50 overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5" />
<h2 className="text-lg font-semibold">Directive</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedDirective(null);
setFullDirective(null);
}}
>
<X className="w-4 h-4" />
</Button>
</div>
{loadingDirective ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
<p className="mt-1 font-medium">
{fullDirective?.name || selectedDirective.name}
</p>
</div>
{fullDirective?.description && (
<div>
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<p className="mt-1 text-sm">{fullDirective.description}</p>
</div>
)}
{fullDirective?.tags && fullDirective.tags.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">Tags</h3>
<div className="flex flex-wrap gap-1">
{fullDirective.tags.map((tag: string) => (
<span
key={tag}
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground flex items-center gap-1"
>
<Tag className="w-2.5 h-2.5" />
{tag}
</span>
))}
</div>
</div>
)}
{(fullDirective?.observations || selectedDirective.observations) && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Observations (
{(fullDirective?.observations || selectedDirective.observations)?.length || 0}
)
</h3>
<div className="space-y-2">
{(fullDirective?.observations || selectedDirective.observations)?.map(
(obs: any, i: number) => (
<div key={i} className="p-3 bg-muted rounded-lg">
{obs.title && (
<div className="font-medium text-sm mb-1">{obs.title}</div>
)}
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
{obs.content || obs.text || (typeof obs === "string" ? obs : "")}
</div>
{obs.memory_ids && obs.memory_ids.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
Based on {obs.memory_ids.length} memories
</div>
)}
</div>
)
)}
</div>
</div>
)}
<div className="pt-2 border-t">
<h3 className="text-sm font-medium text-muted-foreground">ID</h3>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{selectedDirective.id}
</p>
</div>
</div>
)}
</div>
</div>
)}
{/* Mental Model Detail Panel */}
{selectedMentalModel && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l shadow-2xl z-50 overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Brain className="w-5 h-5" />
<h2 className="text-lg font-semibold">Mental Model</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedMentalModel(null);
setFullMentalModel(null);
}}
>
<X className="w-4 h-4" />
</Button>
</div>
{loadingMentalModel ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
<p className="mt-1 font-medium">
{fullMentalModel?.name || selectedMentalModel.name}
</p>
</div>
{fullMentalModel?.description && (
<div>
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<p className="mt-1 text-sm">{fullMentalModel.description}</p>
</div>
)}
<div className="flex gap-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Type</h3>
<p className="mt-1 text-sm">{selectedMentalModel.type}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Subtype</h3>
<span
className={`inline-block mt-1 text-xs px-2 py-0.5 rounded ${
selectedMentalModel.subtype === "structural"
? "bg-blue-500/10 text-blue-600"
: selectedMentalModel.subtype === "emergent"
? "bg-emerald-500/10 text-emerald-600"
: selectedMentalModel.subtype === "learned"
? "bg-violet-500/10 text-violet-600"
: selectedMentalModel.subtype === "directive"
? "bg-rose-500/10 text-rose-600"
: "bg-muted"
}`}
>
{selectedMentalModel.subtype}
</span>
</div>
</div>
{fullMentalModel?.tags && fullMentalModel.tags.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">Tags</h3>
<div className="flex flex-wrap gap-1">
{fullMentalModel.tags.map((tag: string) => (
<span
key={tag}
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground flex items-center gap-1"
>
<Tag className="w-2.5 h-2.5" />
{tag}
</span>
))}
</div>
</div>
)}
{fullMentalModel?.observations && fullMentalModel.observations.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Observations ({fullMentalModel.observations.length})
</h3>
<div className="space-y-2">
{fullMentalModel.observations.map((obs: any, i: number) => (
<div key={i} className="p-3 bg-muted rounded-lg">
{obs.title && <div className="font-medium text-sm mb-1">{obs.title}</div>}
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
{obs.content || obs.text || (typeof obs === "string" ? obs : "")}
</div>
{obs.memory_ids && obs.memory_ids.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
Based on {obs.memory_ids.length} memories
</div>
)}
</div>
))}
</div>
</div>
)}
<div className="pt-2 border-t">
<h3 className="text-sm font-medium text-muted-foreground">ID</h3>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{selectedMentalModel.id}
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+92 -6
View File
@@ -272,7 +272,7 @@ export class ControlPlaneClient {
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; text: string; based_on: string[] }>;
observations?: Array<{ title: string; content: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
@@ -308,6 +308,32 @@ export class ControlPlaneClient {
});
}
/**
* Update a mental model (name and/or description)
*/
async updateMentalModel(
bankId: string,
modelId: string,
params: { name?: string; description?: string }
) {
return this.fetchApi<{
id: string;
bank_id: string;
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; content: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
last_updated: string | null;
created_at: string;
}>(`/api/banks/${bankId}/mental-models/${modelId}`, {
method: "PATCH",
body: JSON.stringify(params),
});
}
/**
* Get operation status
*/
@@ -324,11 +350,11 @@ export class ControlPlaneClient {
}
/**
* Generate/refresh content for a specific mental model (async)
* Refresh content for a specific mental model (async)
*/
async generateMentalModel(bankId: string, modelId: string) {
async refreshMentalModel(bankId: string, modelId: string) {
return this.fetchApi<{ operation_id: string; message: string }>(
`/api/banks/${bankId}/mental-models/${modelId}/generate`,
`/api/banks/${bankId}/mental-models/${modelId}/refresh`,
{
method: "POST",
}
@@ -336,13 +362,15 @@ export class ControlPlaneClient {
}
/**
* Create a pinned mental model
* Create a mental model (pinned or directive)
*/
async createMentalModel(
bankId: string,
params: {
name: string;
description: string;
subtype?: "pinned" | "directive";
observations?: Array<{ title: string; content: string }>;
tags?: string[];
}
) {
@@ -352,7 +380,7 @@ export class ControlPlaneClient {
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; text: string; based_on: string[] }>;
observations?: Array<{ title: string; content: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
@@ -384,6 +412,64 @@ export class ControlPlaneClient {
body: JSON.stringify(profile),
});
}
/**
* List directives for a bank
*/
async listDirectives(bankId: string) {
return this.fetchApi<{
items: Array<{
id: string;
bank_id: string;
subtype: string;
name: string;
description: string;
observations?: Array<{ title: string; content: string; based_on: string[] }>;
entity_id: string | null;
links: string[];
tags?: string[];
last_updated: string | null;
created_at: string;
}>;
}>(`/api/banks/${bankId}/mental-models?subtype=directive`);
}
/**
* List version history for a mental model
*/
async listMentalModelVersions(bankId: string, modelId: string) {
return this.fetchApi<{
versions: Array<{
version: number;
created_at: string | null;
observation_count: number;
}>;
}>(`/api/banks/${bankId}/mental-models/${modelId}/versions`);
}
/**
* Get a specific version of a mental model
*/
async getMentalModelVersion(bankId: string, modelId: string, version: number) {
return this.fetchApi<{
version: number;
observations: Array<{
title: string;
content: string;
evidence: Array<{
memory_id: string;
quote: string;
relevance: string;
timestamp: string;
}>;
created_at: string;
trend: string;
evidence_count: number;
evidence_span: { from: string | null; to: string | null };
}>;
created_at: string | null;
}>(`/api/banks/${bankId}/mental-models/${modelId}/versions/${version}`);
}
}
// Export singleton instance
+501 -40
View File
@@ -901,7 +901,7 @@
"Mental Models"
],
"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",
"operationId": "create_mental_model",
"parameters": [
{
@@ -1096,6 +1096,82 @@
}
}
}
},
"patch": {
"tags": [
"Mental Models"
],
"summary": "Update mental model",
"description": "Update a mental model's name and/or description. Useful for editing directives.",
"operationId": "update_mental_model",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Model Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateMentalModelRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MentalModelResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/mental-models/refresh": {
@@ -1174,14 +1250,14 @@
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{model_id}/generate": {
"/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh": {
"post": {
"tags": [
"Mental Models"
],
"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.",
"operationId": "generate_mental_model",
"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.",
"operationId": "refresh_mental_model",
"parameters": [
{
"name": "bank_id",
@@ -1242,6 +1318,147 @@
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{model_id}/versions": {
"get": {
"tags": [
"Mental Models"
],
"summary": "List mental model version history",
"description": "List all saved versions of a mental model's observations, ordered by version descending.",
"operationId": "list_mental_model_versions",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Model Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}": {
"get": {
"tags": [
"Mental Models"
],
"summary": "Get specific mental model version",
"description": "Get observations from a specific version of a mental model.",
"operationId": "get_mental_model_version",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "model_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Model Id"
}
},
{
"name": "version",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Version"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/documents": {
"get": {
"tags": [
@@ -2899,6 +3116,27 @@
"title": "Description",
"description": "One-liner description for quick scanning"
},
"subtype": {
"type": "string",
"title": "Subtype",
"description": "Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)",
"default": "pinned"
},
"observations": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/ObservationInput"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Observations",
"description": "For directives only: list of user-provided observations. Required when subtype='directive'."
},
"tags": {
"items": {
"type": "string"
@@ -2914,14 +3152,27 @@
"description"
],
"title": "CreateMentalModelRequest",
"description": "Request model for creating a pinned mental model.",
"example": {
"description": "Key product priorities and upcoming features",
"name": "Product Roadmap",
"tags": [
"project-x"
]
}
"description": "Request model for creating a mental model.",
"examples": [
{
"description": "Key product priorities and upcoming features",
"name": "Product Roadmap",
"tags": [
"project-x"
]
},
{
"description": "Rules about scheduling meetings",
"name": "Meeting Rules",
"observations": [
{
"content": "Never schedule meetings before 10am",
"title": "Morning meetings"
}
],
"subtype": "directive"
}
]
},
"CreatedMentalModel": {
"properties": {
@@ -3812,6 +4063,48 @@
"timestamp": "2024-01-15T10:30:00Z"
}
},
"MentalModelFreshnessResponse": {
"properties": {
"is_up_to_date": {
"type": "boolean",
"title": "Is Up To Date",
"description": "Whether the model has been refreshed since the last memory was added"
},
"last_refresh_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Last Refresh At",
"description": "When the model was last refreshed (ISO format)"
},
"memories_since_refresh": {
"type": "integer",
"title": "Memories Since Refresh",
"description": "Number of memories added since last refresh"
},
"reasons": {
"items": {
"type": "string"
},
"type": "array",
"title": "Reasons",
"description": "Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed"
}
},
"type": "object",
"required": [
"is_up_to_date",
"last_refresh_at",
"memories_since_refresh"
],
"title": "MentalModelFreshnessResponse",
"description": "Freshness information for a mental model."
},
"MentalModelListResponse": {
"properties": {
"items": {
@@ -3834,29 +4127,54 @@
"title": {
"type": "string",
"title": "Title",
"description": "Observation header (empty for intro)"
"description": "Short summary title for the observation"
},
"text": {
"content": {
"type": "string",
"title": "Text",
"description": "Observation content"
"title": "Content",
"description": "The observation content - detailed explanation"
},
"based_on": {
"evidence": {
"items": {
"type": "string"
"$ref": "#/components/schemas/ObservationEvidenceResponse"
},
"type": "array",
"title": "Based On",
"description": "Memory IDs supporting this observation"
"title": "Evidence",
"description": "Supporting evidence with quotes"
},
"created_at": {
"type": "string",
"title": "Created At",
"description": "When this observation was first created (ISO format)"
},
"trend": {
"type": "string",
"title": "Trend",
"description": "Computed trend: stable, strengthening, weakening, new, stale"
},
"evidence_count": {
"type": "integer",
"title": "Evidence Count",
"description": "Number of evidence items supporting this observation"
},
"evidence_span": {
"additionalProperties": true,
"type": "object",
"title": "Evidence Span",
"description": "Time span of evidence: {from: iso_date, to: iso_date}"
}
},
"type": "object",
"required": [
"title",
"text"
"content",
"created_at",
"trend",
"evidence_count",
"evidence_span"
],
"title": "MentalModelObservationResponse",
"description": "An observation within a mental model with its supporting memories."
"description": "An observation within a mental model with its supporting evidence."
},
"MentalModelResponse": {
"properties": {
@@ -3888,6 +4206,12 @@
"title": "Observations",
"description": "Structured observations with per-observation fact attribution"
},
"version": {
"type": "integer",
"title": "Version",
"description": "Version number of the mental model observations",
"default": 0
},
"entity_id": {
"anyOf": [
{
@@ -3926,6 +4250,29 @@
],
"title": "Last Updated"
},
"last_refresh_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Last Refresh At",
"description": "When observations were last refreshed (ISO format)"
},
"freshness": {
"anyOf": [
{
"$ref": "#/components/schemas/MentalModelFreshnessResponse"
},
{
"type": "null"
}
],
"description": "Freshness info (null for directive subtypes which don't need refresh)"
},
"created_at": {
"type": "string",
"title": "Created At"
@@ -3946,25 +4293,99 @@
"bank_id": "test-bank",
"created_at": "2024-01-10T08:00:00Z",
"description": "Who's on the team and their roles",
"freshness": {
"is_up_to_date": true,
"last_refresh_at": "2024-01-15T10:30:00Z",
"memories_since_refresh": 0,
"reasons": []
},
"id": "team-structure",
"last_refresh_at": "2024-01-15T10:30:00Z",
"last_updated": "2024-01-15T10:30:00Z",
"links": [],
"name": "Team Structure",
"observations": [
{
"based_on": [
"uuid1"
"content": "The team prefers async communication over synchronous meetings",
"created_at": "2024-01-15T10:30:00Z",
"evidence": [
{
"memory_id": "uuid1",
"quote": "I prefer Slack over meetings",
"relevance": "Shows async preference",
"timestamp": "2024-01-10T08:00:00Z"
}
],
"text": "The team consists of...",
"title": "Overview"
"evidence_count": 1,
"evidence_span": {
"from": "2024-01-10T08:00:00Z",
"to": "2024-01-10T08:00:00Z"
},
"title": "Prefers async communication",
"trend": "stable"
}
],
"subtype": "structural",
"tags": [
"project-x"
]
],
"version": 1
}
},
"ObservationEvidenceResponse": {
"properties": {
"memory_id": {
"type": "string",
"title": "Memory Id",
"description": "ID of the memory unit this evidence comes from"
},
"quote": {
"type": "string",
"title": "Quote",
"description": "Exact quote from the memory supporting the observation"
},
"relevance": {
"type": "string",
"title": "Relevance",
"description": "Brief explanation of how this quote supports the observation"
},
"timestamp": {
"type": "string",
"title": "Timestamp",
"description": "When the source memory was created (ISO format)"
}
},
"type": "object",
"required": [
"memory_id",
"quote",
"relevance",
"timestamp"
],
"title": "ObservationEvidenceResponse",
"description": "A single piece of evidence supporting an observation."
},
"ObservationInput": {
"properties": {
"title": {
"type": "string",
"title": "Title",
"description": "Short title/header for the observation"
},
"content": {
"type": "string",
"title": "Content",
"description": "Content of the observation"
}
},
"type": "object",
"required": [
"title",
"content"
],
"title": "ObservationInput",
"description": "Input model for a single observation."
},
"OperationResponse": {
"properties": {
"id": {
@@ -4692,24 +5113,22 @@
"subtype": {
"type": "string",
"title": "Subtype",
"description": "Mental model subtype: structural, emergent, learned"
"description": "Mental model subtype: structural, emergent, learned, directive"
},
"description": {
"type": "string",
"title": "Description",
"description": "Brief description"
},
"summary": {
"observations": {
"anyOf": [
{
"type": "string"
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Summary",
"description": "Full summary (when looked up in detail)"
"title": "Observations",
"description": "Observations for directive mental models (subtype='directive')"
}
},
"type": "object",
@@ -4717,8 +5136,7 @@
"id",
"name",
"type",
"subtype",
"description"
"subtype"
],
"title": "ReflectMentalModel",
"description": "A mental model accessed during reflect."
@@ -5028,6 +5446,14 @@
"type": "array",
"title": "Llm Calls",
"description": "LLM calls made during reflection"
},
"mental_models": {
"items": {
"$ref": "#/components/schemas/ReflectMentalModel"
},
"type": "array",
"title": "Mental Models",
"description": "Mental models used during reflection (includes directives with subtype='directive')"
}
},
"type": "object",
@@ -5278,6 +5704,41 @@
"title": "UpdateDispositionRequest",
"description": "Request model for updating disposition traits."
},
"UpdateMentalModelRequest": {
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name",
"description": "New name for the mental model"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"description": "New description/rule text"
}
},
"type": "object",
"title": "UpdateMentalModelRequest",
"description": "Request model for updating a mental model.",
"example": {
"description": "Updated description with new rules",
"name": "Updated Name"
}
},
"ValidationError": {
"properties": {
"loc": {
+84
View File
@@ -145,6 +145,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",
@@ -7588,6 +7590,88 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",