Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91ce73e59 | ||
|
|
baba49c9aa | ||
|
|
652e6a7c8c | ||
|
|
d490b83776 | ||
|
|
27ee64adab | ||
|
|
bbab5686f4 | ||
|
|
67e19e5908 | ||
|
|
24a331dfd9 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6"
|
||||
"admin" ""
|
||||
@@ -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'))
|
||||
""")
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
-2
@@ -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
|
||||
|
||||
+98
@@ -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
|
||||
|
||||
|
||||
+24
-8
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-5
@@ -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 }> }
|
||||
|
||||
+47
@@ -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 });
|
||||
}
|
||||
}
|
||||
+43
@@ -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">✓</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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+84
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user