feat: improve mental model refresh and add directives (#166)
* feat: improve mental model refresh and add directives * feat: improve mental model refresh and add directives * tags * ui * fix * fix * update * update
This commit is contained in:
parent
870cfccabb
commit
63a65d0723
57 changed files with 9899 additions and 1316 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,7 +29,7 @@ nltk_data/
|
||||||
|
|
||||||
# Monitoring stack (Prometheus/Grafana binaries and data)
|
# Monitoring stack (Prometheus/Grafana binaries and data)
|
||||||
.monitoring/
|
.monitoring/
|
||||||
.pgbouncer
|
.pgbouncer/
|
||||||
|
|
||||||
# Large benchmark datasets (will be downloaded automatically)
|
# Large benchmark datasets (will be downloaded automatically)
|
||||||
**/longmemeval_s_cleaned.json
|
**/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" ""
|
|
||||||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -199,6 +199,38 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
|
||||||
- Pydantic models for request/response
|
- Pydantic models for request/response
|
||||||
- Ruff for linting (line-length 120)
|
- Ruff for linting (line-length 120)
|
||||||
- No Python files at project root - maintain clean directory structure
|
- 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
|
### TypeScript Style
|
||||||
- Next.js App Router for control plane
|
- 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 import MemoryEngine
|
||||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||||
from hindsight_api.engine.memory_engine import Budget, fq_table
|
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.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
|
||||||
from hindsight_api.engine.search.tags import TagsMatch
|
from hindsight_api.engine.search.tags import TagsMatch
|
||||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||||
|
|
@ -559,9 +560,10 @@ class ReflectMentalModel(BaseModel):
|
||||||
id: str = Field(description="Mental model ID")
|
id: str = Field(description="Mental model ID")
|
||||||
name: str = Field(description="Mental model name")
|
name: str = Field(description="Mental model name")
|
||||||
type: str = Field(description="Mental model type: entity, concept, event")
|
type: str = Field(description="Mental model type: entity, concept, event")
|
||||||
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
|
subtype: str = Field(description="Mental model subtype: structural, emergent, learned, directive")
|
||||||
description: str = Field(description="Brief description")
|
observations: list[str] | None = Field(
|
||||||
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
|
default=None, description="Observations for directive mental models (subtype='directive')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReflectBasedOn(BaseModel):
|
class ReflectBasedOn(BaseModel):
|
||||||
|
|
@ -578,6 +580,10 @@ class ReflectTrace(BaseModel):
|
||||||
|
|
||||||
tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
|
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")
|
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):
|
class CreatedMentalModel(BaseModel):
|
||||||
|
|
@ -1045,12 +1051,40 @@ class BankStatsResponse(BaseModel):
|
||||||
# Mental Model models
|
# Mental Model models
|
||||||
|
|
||||||
|
|
||||||
class MentalModelObservationResponse(BaseModel):
|
class ObservationEvidenceResponse(BaseModel):
|
||||||
"""An observation within a mental model with its supporting memories."""
|
"""A single piece of evidence supporting an observation."""
|
||||||
|
|
||||||
title: str = Field(description="Observation header (empty for intro)")
|
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
|
||||||
text: str = Field(description="Observation content")
|
quote: str = Field(description="Exact quote from the memory supporting the observation")
|
||||||
based_on: list[str] = Field(default_factory=list, description="Memory IDs supporting this 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):
|
class MentalModelResponse(BaseModel):
|
||||||
|
|
@ -1064,11 +1098,36 @@ class MentalModelResponse(BaseModel):
|
||||||
"subtype": "structural",
|
"subtype": "structural",
|
||||||
"name": "Team Structure",
|
"name": "Team Structure",
|
||||||
"description": "Who's on the team and their roles",
|
"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,
|
"entity_id": None,
|
||||||
"links": [],
|
"links": [],
|
||||||
"tags": ["project-x"],
|
"tags": ["project-x"],
|
||||||
"last_updated": "2024-01-15T10:30:00Z",
|
"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",
|
"created_at": "2024-01-10T08:00:00Z",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1082,10 +1141,15 @@ class MentalModelResponse(BaseModel):
|
||||||
observations: list[MentalModelObservationResponse] = Field(
|
observations: list[MentalModelObservationResponse] = Field(
|
||||||
default_factory=list, description="Structured observations with per-observation fact attribution"
|
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
|
entity_id: str | None = None
|
||||||
links: list[str] = []
|
links: list[str] = []
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
last_updated: str | None = None
|
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
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1095,6 +1159,39 @@ class MentalModelListResponse(BaseModel):
|
||||||
items: list[MentalModelResponse]
|
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):
|
class RefreshMentalModelsRequest(BaseModel):
|
||||||
"""Request model for refresh mental models endpoint."""
|
"""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):
|
class CreateMentalModelRequest(BaseModel):
|
||||||
"""Request model for creating a pinned mental model."""
|
"""Request model for creating a mental model."""
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"examples": [
|
||||||
"name": "Product Roadmap",
|
{
|
||||||
"description": "Key product priorities and upcoming features",
|
"name": "Product Roadmap",
|
||||||
"tags": ["project-x"],
|
"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")
|
name: str = Field(description="Human-readable name for the mental model")
|
||||||
description: str = Field(description="One-liner description for quick scanning")
|
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")
|
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):
|
class OperationResponse(BaseModel):
|
||||||
"""Response model for a single async operation."""
|
"""Response model for a single async operation."""
|
||||||
|
|
||||||
|
|
@ -1742,14 +1878,12 @@ def _register_routes(app: FastAPI):
|
||||||
name=mm.name,
|
name=mm.name,
|
||||||
type=mm.type,
|
type=mm.type,
|
||||||
subtype=mm.subtype,
|
subtype=mm.subtype,
|
||||||
description=mm.description,
|
|
||||||
summary=mm.summary,
|
|
||||||
)
|
)
|
||||||
for mm in core_result.mental_models
|
for mm in core_result.mental_models
|
||||||
]
|
]
|
||||||
based_on_result = ReflectBasedOn(memories=memories, mental_models=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
|
trace_result: ReflectTrace | None = None
|
||||||
if request.include.tool_calls is not None:
|
if request.include.tool_calls is not None:
|
||||||
include_output = request.include.tool_calls.output
|
include_output = request.include.tool_calls.output
|
||||||
|
|
@ -1764,7 +1898,24 @@ def _register_routes(app: FastAPI):
|
||||||
for tc in core_result.tool_trace
|
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]
|
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)
|
# Build mental_models_created from tool trace (learn tool outputs)
|
||||||
created_models: list[CreatedMentalModel] = []
|
created_models: list[CreatedMentalModel] = []
|
||||||
|
|
@ -2076,7 +2227,46 @@ def _register_routes(app: FastAPI):
|
||||||
tags_match=tags_match,
|
tags_match=tags_match,
|
||||||
request_context=request_context,
|
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):
|
except (AuthenticationError, HTTPException):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2090,7 +2280,11 @@ def _register_routes(app: FastAPI):
|
||||||
"/v1/default/banks/{bank_id}/mental-models",
|
"/v1/default/banks/{bank_id}/mental-models",
|
||||||
response_model=MentalModelResponse,
|
response_model=MentalModelResponse,
|
||||||
summary="Create mental model",
|
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",
|
operation_id="create_mental_model",
|
||||||
tags=["Mental Models"],
|
tags=["Mental Models"],
|
||||||
)
|
)
|
||||||
|
|
@ -2099,16 +2293,23 @@ def _register_routes(app: FastAPI):
|
||||||
body: CreateMentalModelRequest,
|
body: CreateMentalModelRequest,
|
||||||
request_context: RequestContext = Depends(get_request_context),
|
request_context: RequestContext = Depends(get_request_context),
|
||||||
):
|
):
|
||||||
"""Create a pinned mental model."""
|
"""Create a mental model (pinned or directive)."""
|
||||||
try:
|
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(
|
model = await app.state.memory.create_mental_model(
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
|
subtype=body.subtype,
|
||||||
|
observations=observations_list,
|
||||||
tags=body.tags,
|
tags=body.tags,
|
||||||
request_context=request_context,
|
request_context=request_context,
|
||||||
)
|
)
|
||||||
return MentalModelResponse(**model)
|
return _prepare_mental_model_response(model)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except (AuthenticationError, HTTPException):
|
except (AuthenticationError, HTTPException):
|
||||||
|
|
@ -2142,7 +2343,47 @@ def _register_routes(app: FastAPI):
|
||||||
)
|
)
|
||||||
if model is None:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
|
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):
|
except (AuthenticationError, HTTPException):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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}")
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.post(
|
@app.patch(
|
||||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}/generate",
|
"/v1/default/banks/{bank_id}/mental-models/{model_id}",
|
||||||
response_model=AsyncOperationSubmitResponse,
|
response_model=MentalModelResponse,
|
||||||
summary="Generate mental model content (async)",
|
summary="Update mental model",
|
||||||
description="Submit a background job to generate/refresh content for a specific mental model. "
|
description="Update a mental model's name and/or description. Useful for editing directives.",
|
||||||
"This is useful for newly created learned models or to regenerate content for any model.",
|
operation_id="update_mental_model",
|
||||||
operation_id="generate_mental_model",
|
|
||||||
tags=["Mental Models"],
|
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,
|
bank_id: str,
|
||||||
model_id: str,
|
model_id: str,
|
||||||
request_context: RequestContext = Depends(get_request_context),
|
request_context: RequestContext = Depends(get_request_context),
|
||||||
):
|
):
|
||||||
"""Generate content for a specific mental model."""
|
"""Refresh content for a specific mental model."""
|
||||||
try:
|
try:
|
||||||
result = await app.state.memory.generate_mental_model_async(
|
result = await app.state.memory.refresh_mental_model_async(
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
request_context=request_context,
|
request_context=request_context,
|
||||||
|
|
@ -2260,7 +2539,74 @@ def _register_routes(app: FastAPI):
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.get(
|
@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
|
STRUCTURAL = "structural" # Derived from mission, created upfront
|
||||||
EMERGENT = "emergent" # Discovered from data patterns
|
EMERGENT = "emergent" # Discovered from data patterns
|
||||||
LEARNED = "learned" # Formed through reflection
|
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):
|
class MentalModel(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,37 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
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 .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, ToolCall
|
||||||
from .prompts import FINAL_SYSTEM_PROMPT, build_final_prompt, build_system_prompt_for_tools
|
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
|
||||||
from .tools_schema import get_reflect_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:
|
if TYPE_CHECKING:
|
||||||
from ..llm_wrapper import LLMProvider
|
from ..llm_wrapper import LLMProvider
|
||||||
from ..response_models import LLMToolCall
|
from ..response_models import LLMToolCall
|
||||||
|
|
@ -136,7 +161,7 @@ async def run_reflect_agent(
|
||||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
response_schema: dict | None = None,
|
response_schema: dict | None = None,
|
||||||
output_mode: Literal["answer", "observations"] = "answer",
|
directives: list[dict[str, Any]] | None = None,
|
||||||
) -> ReflectAgentResult:
|
) -> ReflectAgentResult:
|
||||||
"""
|
"""
|
||||||
Execute the reflect agent loop using native tool calling.
|
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_iterations: Maximum number of iterations before forcing response
|
||||||
max_tokens: Maximum tokens for the final response
|
max_tokens: Maximum tokens for the final response
|
||||||
response_schema: Optional JSON Schema for structured output in 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:
|
Returns:
|
||||||
ReflectAgentResult with final answer and metadata
|
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}"
|
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Get tools for this agent
|
# Build directives_applied for the trace
|
||||||
tools = get_reflect_tools(enable_learn=enable_learn, output_mode=output_mode)
|
directives_applied = _build_directives_applied(directives)
|
||||||
|
|
||||||
# Build initial messages
|
# Extract directive rules for tool schema (if any)
|
||||||
system_prompt = build_system_prompt_for_tools(bank_profile, context, output_mode=output_mode)
|
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]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": query},
|
{"role": "user", "content": query},
|
||||||
|
|
@ -189,44 +220,43 @@ async def run_reflect_agent(
|
||||||
available_memory_ids: set[str] = set()
|
available_memory_ids: set[str] = set()
|
||||||
available_model_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
|
# Pre-fetch mental models so the agent always starts with this knowledge
|
||||||
if output_mode == "answer":
|
prefetch_start = time.time()
|
||||||
prefetch_start = time.time()
|
models_result = await lookup_fn(None) # List all mental models
|
||||||
models_result = await lookup_fn(None) # List all mental models
|
prefetch_duration = int((time.time() - prefetch_start) * 1000)
|
||||||
prefetch_duration = int((time.time() - prefetch_start) * 1000)
|
|
||||||
|
|
||||||
# Track available model IDs
|
# Track available model IDs
|
||||||
if isinstance(models_result, dict) and "models" in models_result:
|
if isinstance(models_result, dict) and "models" in models_result:
|
||||||
for model in models_result["models"]:
|
for model in models_result["models"]:
|
||||||
if "id" in model:
|
if "id" in model:
|
||||||
available_model_ids.add(model["id"])
|
available_model_ids.add(model["id"])
|
||||||
|
|
||||||
# Add to context history for the agent
|
# Add to context history for the agent
|
||||||
context_history.append({"tool": "list_mental_models", "output": models_result})
|
context_history.append({"tool": "list_mental_models", "output": models_result})
|
||||||
|
|
||||||
# Add to tool trace
|
# Add to tool trace
|
||||||
tool_trace.append(
|
tool_trace.append(
|
||||||
ToolCall(
|
ToolCall(
|
||||||
tool="list_mental_models",
|
tool="list_mental_models",
|
||||||
input={"tool": "list_mental_models"},
|
input={"tool": "list_mental_models"},
|
||||||
output=models_result,
|
output=models_result,
|
||||||
duration_ms=prefetch_duration,
|
duration_ms=prefetch_duration,
|
||||||
iteration=0,
|
iteration=0,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
tool_trace_summary.append(
|
)
|
||||||
{
|
tool_trace_summary.append(
|
||||||
"tool": "list_mental_models",
|
{
|
||||||
"input_summary": "(prefetch)",
|
"tool": "list_mental_models",
|
||||||
"duration_ms": prefetch_duration,
|
"input_summary": "(prefetch)",
|
||||||
"output_chars": len(json.dumps(models_result, default=str)),
|
"duration_ms": prefetch_duration,
|
||||||
}
|
"output_chars": len(json.dumps(models_result, default=str)),
|
||||||
)
|
}
|
||||||
total_tools_called += 1
|
)
|
||||||
|
total_tools_called += 1
|
||||||
|
|
||||||
# Include in the user message so the agent sees it
|
# Include in the user message so the agent sees it
|
||||||
models_info = json.dumps(models_result, indent=2, default=str)
|
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```"
|
messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```"
|
||||||
|
|
||||||
def _get_llm_trace() -> list[LLMCall]:
|
def _get_llm_trace() -> list[LLMCall]:
|
||||||
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
|
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,
|
mental_models_created=mental_models_created,
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
llm_trace=_get_llm_trace(),
|
llm_trace=_get_llm_trace(),
|
||||||
|
directives_applied=directives_applied,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call LLM with tools
|
# Call LLM with tools
|
||||||
|
|
@ -338,6 +369,7 @@ async def run_reflect_agent(
|
||||||
mental_models_created=mental_models_created,
|
mental_models_created=mental_models_created,
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
llm_trace=_get_llm_trace(),
|
llm_trace=_get_llm_trace(),
|
||||||
|
directives_applied=directives_applied,
|
||||||
)
|
)
|
||||||
|
|
||||||
# No tool calls - LLM wants to respond with text
|
# No tool calls - LLM wants to respond with text
|
||||||
|
|
@ -361,6 +393,7 @@ async def run_reflect_agent(
|
||||||
mental_models_created=mental_models_created,
|
mental_models_created=mental_models_created,
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
llm_trace=_get_llm_trace(),
|
llm_trace=_get_llm_trace(),
|
||||||
|
directives_applied=directives_applied,
|
||||||
)
|
)
|
||||||
# Empty response, force final
|
# Empty response, force final
|
||||||
prompt = build_final_prompt(query, context_history, bank_profile, context)
|
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,
|
mental_models_created=mental_models_created,
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
llm_trace=_get_llm_trace(),
|
llm_trace=_get_llm_trace(),
|
||||||
|
directives_applied=directives_applied,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for done tool call
|
# 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"), None)
|
done_call = next((tc for tc in result.tool_calls if tc.name == "done" or tc.name == "functions.done"), None)
|
||||||
if done_call:
|
if done_call:
|
||||||
# Guardrail: Require evidence before done
|
# Guardrail: Require evidence before done
|
||||||
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
|
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
|
||||||
|
|
@ -421,7 +455,6 @@ async def run_reflect_agent(
|
||||||
# Process done tool
|
# Process done tool
|
||||||
return await _process_done_tool(
|
return await _process_done_tool(
|
||||||
done_call,
|
done_call,
|
||||||
output_mode,
|
|
||||||
available_memory_ids,
|
available_memory_ids,
|
||||||
available_model_ids,
|
available_model_ids,
|
||||||
iteration + 1,
|
iteration + 1,
|
||||||
|
|
@ -431,12 +464,13 @@ async def run_reflect_agent(
|
||||||
_get_llm_trace(),
|
_get_llm_trace(),
|
||||||
_log_completion,
|
_log_completion,
|
||||||
reflect_id,
|
reflect_id,
|
||||||
|
directives_applied=directives_applied,
|
||||||
llm_config=llm_config,
|
llm_config=llm_config,
|
||||||
response_schema=response_schema,
|
response_schema=response_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute other tools in parallel
|
# Execute other tools in parallel (exclude done and functions.done)
|
||||||
other_tools = [tc for tc in result.tool_calls if tc.name != "done"]
|
other_tools = [tc for tc in result.tool_calls if tc.name not in ("done", "functions.done")]
|
||||||
if other_tools:
|
if other_tools:
|
||||||
# Add assistant message with tool calls
|
# Add assistant message with tool calls
|
||||||
messages.append(
|
messages.append(
|
||||||
|
|
@ -534,6 +568,7 @@ async def run_reflect_agent(
|
||||||
mental_models_created=mental_models_created,
|
mental_models_created=mental_models_created,
|
||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
llm_trace=_get_llm_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(
|
async def _process_done_tool(
|
||||||
done_call: "LLMToolCall",
|
done_call: "LLMToolCall",
|
||||||
output_mode: str,
|
|
||||||
available_memory_ids: set[str],
|
available_memory_ids: set[str],
|
||||||
available_model_ids: set[str],
|
available_model_ids: set[str],
|
||||||
iterations: int,
|
iterations: int,
|
||||||
|
|
@ -561,60 +595,13 @@ async def _process_done_tool(
|
||||||
llm_trace: list[LLMCall],
|
llm_trace: list[LLMCall],
|
||||||
log_completion: Callable,
|
log_completion: Callable,
|
||||||
reflect_id: str,
|
reflect_id: str,
|
||||||
|
directives_applied: list[DirectiveInfo],
|
||||||
llm_config: "LLMProvider | None" = None,
|
llm_config: "LLMProvider | None" = None,
|
||||||
response_schema: dict | None = None,
|
response_schema: dict | None = None,
|
||||||
) -> ReflectAgentResult:
|
) -> ReflectAgentResult:
|
||||||
"""Process the done tool call and return the result."""
|
"""Process the done tool call and return the result."""
|
||||||
args = done_call.arguments
|
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()
|
answer = args.get("answer", "").strip()
|
||||||
if not answer:
|
if not answer:
|
||||||
answer = "No answer provided."
|
answer = "No answer provided."
|
||||||
|
|
@ -639,6 +626,7 @@ async def _process_done_tool(
|
||||||
llm_trace=llm_trace,
|
llm_trace=llm_trace,
|
||||||
used_memory_ids=used_memory_ids,
|
used_memory_ids=used_memory_ids,
|
||||||
used_model_ids=used_model_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,
|
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute a single tool by name."""
|
"""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":
|
if tool_name == "list_mental_models":
|
||||||
return await lookup_fn(None)
|
return await lookup_fn(None)
|
||||||
|
|
||||||
|
|
|
||||||
1213
hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
Normal file
1213
hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
Normal file
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")
|
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||||
|
|
||||||
|
|
||||||
class Observation(BaseModel):
|
class DirectiveInfo(BaseModel):
|
||||||
"""A single observation with supporting memories."""
|
"""Information about a directive that was applied during reflect."""
|
||||||
|
|
||||||
title: str = Field(description="Observation title/header")
|
id: str = Field(description="Directive mental model ID")
|
||||||
text: str = Field(description="Observation content")
|
name: str = Field(description="Directive name")
|
||||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
|
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
|
||||||
|
|
||||||
|
|
||||||
class ReflectAgentResult(BaseModel):
|
class ReflectAgentResult(BaseModel):
|
||||||
"""Result from the reflect agent."""
|
"""Result from the reflect agent."""
|
||||||
|
|
||||||
text: str = Field(description="Final answer text")
|
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(
|
structured_output: dict[str, Any] | None = Field(
|
||||||
default=None, description="Structured output parsed according to provided response_schema"
|
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")
|
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_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
|
||||||
used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer")
|
used_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"
|
||||||
|
)
|
||||||
|
|
|
||||||
248
hindsight-api/hindsight_api/engine/reflect/observations.py
Normal file
248
hindsight-api/hindsight_api/engine/reflect/observations.py
Normal file
|
|
@ -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
|
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(
|
def build_system_prompt_for_tools(
|
||||||
bank_profile: dict[str, Any],
|
bank_profile: dict[str, Any],
|
||||||
context: str | None = None,
|
context: str | None = None,
|
||||||
output_mode: str = "answer",
|
directives: list[dict[str, Any]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build the system prompt for tool-calling reflect agent.
|
Build the system prompt for tool-calling reflect agent.
|
||||||
|
|
@ -19,128 +120,90 @@ def build_system_prompt_for_tools(
|
||||||
Args:
|
Args:
|
||||||
bank_profile: Bank profile with name and mission
|
bank_profile: Bank profile with name and mission
|
||||||
context: Optional additional context
|
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")
|
name = bank_profile.get("name", "Assistant")
|
||||||
mission = bank_profile.get("mission", "")
|
mission = bank_profile.get("mission", "")
|
||||||
|
|
||||||
# Build critical rules based on mode
|
no_info_rule = (
|
||||||
if output_mode == "observations":
|
"- 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 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
parts = [
|
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",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mode-specific workflow and output format
|
# Inject directives at the VERY START for maximum prominence
|
||||||
if output_mode == "observations":
|
if directives:
|
||||||
# Observations mode: for mental model generation - no mental model lookup tools
|
parts.append(build_directives_section(directives))
|
||||||
parts.extend(
|
|
||||||
[
|
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",
|
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
|
||||||
" - 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",
|
parts.extend(
|
||||||
" - Only include memories you've verified via expand()",
|
[
|
||||||
"4. When ready, call done() with MULTIPLE structured observations",
|
"## CRITICAL RULES",
|
||||||
"",
|
"- You must NEVER fabricate information that has no basis in retrieved data",
|
||||||
"## Output Format: MULTIPLE Structured Observations",
|
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
|
||||||
"",
|
"- You MUST call recall() before saying you don't have information",
|
||||||
"CRITICAL: You MUST create MULTIPLE separate observations in the array - one for each theme.",
|
no_info_rule,
|
||||||
"Do NOT put all content in a single observation!",
|
"",
|
||||||
"",
|
"## How to Reason",
|
||||||
"- Create 3-8 separate observations, each as its OWN item in the observations array",
|
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
|
||||||
"- Each observation covers ONE specific theme (preferences, history, relationships, etc.)",
|
"- Synthesize a coherent narrative from related memories",
|
||||||
"- Each observation has: title (short header), text (content), memory_ids (full UUIDs)",
|
"- 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",
|
||||||
"Text format for each observation:",
|
"",
|
||||||
"- Main insight or finding (no markdown headers)",
|
"## Query Strategy (IMPORTANT)",
|
||||||
"- End with 'Key evidence:' section containing DIRECT QUOTES from memories in *italics*",
|
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
|
||||||
"- Quote the actual memory text, don't summarize - use *italics* for citations",
|
"",
|
||||||
"",
|
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
|
||||||
"Example done() call with MULTIPLE observations:",
|
"GOOD: Break it down into component searches:",
|
||||||
"```json",
|
" 1. recall('lessons') - find all lesson-related memories",
|
||||||
"{",
|
" 2. recall('teaching sessions') - alternative phrasing",
|
||||||
' "observations": [',
|
" 3. recall('student progress') - find student-related memories",
|
||||||
" {",
|
" 4. recall('topics taught') - find subject matter",
|
||||||
' "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*",',
|
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
|
||||||
' "memory_ids": ["abc123-full-uuid", "def456-full-uuid"]',
|
"- 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",
|
||||||
' "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*",',
|
"## Workflow",
|
||||||
' "memory_ids": ["ghi789-full-uuid"]',
|
]
|
||||||
" }",
|
)
|
||||||
" ]",
|
|
||||||
"}",
|
# Answer mode: include mental model lookup in workflow
|
||||||
"```",
|
parts.extend(
|
||||||
]
|
[
|
||||||
)
|
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
|
||||||
else:
|
"2. If relevant, call get_mental_model(model_id) for full observations",
|
||||||
# Answer mode: include mental model lookup in workflow
|
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
|
||||||
parts.extend(
|
" - Identify entities and concepts in the question",
|
||||||
[
|
" - Search for each separately with targeted queries",
|
||||||
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
|
"4. Run multiple recall() calls - don't just echo the user's question",
|
||||||
"2. If relevant, call get_mental_model(model_id) for full observations",
|
"5. Use expand() if you need more context on specific memories",
|
||||||
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
|
"6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so",
|
||||||
" - Identify entities and concepts in the question",
|
"7. When ready, call done() with your answer and supporting memory_ids",
|
||||||
" - Search for each separately with targeted queries",
|
"",
|
||||||
"4. Run multiple recall() calls - don't just echo the user's question",
|
"## When to Use learn() - IMPORTANT",
|
||||||
"5. Use expand() if you need more context on specific memories",
|
"ACTIVELY look for opportunities to use learn() when you discover:",
|
||||||
"6. If you discover an important recurring topic worth tracking, use learn() to create a mental model",
|
"- A person mentioned in 2+ memories who has no mental model yet",
|
||||||
"7. When ready, call done() with your answer and supporting memory_ids",
|
"- A project or concept the user asks about that has no mental model",
|
||||||
"",
|
"- A pattern or topic worth tracking for future questions",
|
||||||
"## When to Use learn()",
|
"",
|
||||||
"Use learn() to create a new mental model when you discover:",
|
"DO NOT wait to be asked - proactively create models when you see the need.",
|
||||||
"- A person, project, or concept that appears frequently in memories",
|
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
|
||||||
"- An important topic the user seems to care about but has no mental model for",
|
"",
|
||||||
"- A pattern or relationship worth synthesizing for future reference",
|
"## Output Format: Plain Text Answer",
|
||||||
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
|
"Call done() with a plain text 'answer' field.",
|
||||||
"",
|
"- Do NOT use markdown formatting",
|
||||||
"## Output Format: Plain Text Answer",
|
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
|
||||||
"Call done() with a plain text 'answer' field.",
|
"- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
|
||||||
"- 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("")
|
||||||
parts.append(f"## Memory Bank: {name}")
|
parts.append(f"## Memory Bank: {name}")
|
||||||
|
|
@ -164,6 +227,10 @@ def build_system_prompt_for_tools(
|
||||||
if context:
|
if context:
|
||||||
parts.append(f"\n## Additional Context\n{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)
|
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.
|
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."""
|
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 logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from .models import MentalModelInput
|
from .models import MentalModelInput
|
||||||
|
from .observations import Observation, ObservationEvidence, Trend
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from asyncpg import Connection
|
from asyncpg import Connection
|
||||||
|
|
@ -26,6 +28,37 @@ def generate_model_id(name: str) -> str:
|
||||||
return normalized[:50]
|
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(
|
async def tool_lookup(
|
||||||
conn: "Connection",
|
conn: "Connection",
|
||||||
bank_id: str,
|
bank_id: str,
|
||||||
|
|
@ -66,18 +99,8 @@ async def tool_lookup(
|
||||||
obs_data = json.loads(obs_data)
|
obs_data = json.loads(obs_data)
|
||||||
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else 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
|
# Parse observations into typed models
|
||||||
observations = []
|
observations = _parse_observations(observations_raw)
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"found": True,
|
"found": True,
|
||||||
|
|
@ -86,7 +109,7 @@ async def tool_lookup(
|
||||||
"subtype": row["subtype"],
|
"subtype": row["subtype"],
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
"description": row["description"],
|
"description": row["description"],
|
||||||
"observations": observations, # [{title, text, based_on}, ...]
|
"observations": observations,
|
||||||
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
|
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
|
||||||
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
|
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
|
||||||
},
|
},
|
||||||
|
|
@ -95,6 +118,8 @@ async def tool_lookup(
|
||||||
else:
|
else:
|
||||||
# List mental models (compact: id, name, description only)
|
# List mental models (compact: id, name, description only)
|
||||||
# Full observations are retrieved via get_mental_model(model_id)
|
# 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
|
# Filter by tags if provided
|
||||||
if tags:
|
if tags:
|
||||||
if tags_match == "all":
|
if tags_match == "all":
|
||||||
|
|
@ -103,7 +128,7 @@ async def tool_lookup(
|
||||||
"""
|
"""
|
||||||
SELECT id, subtype, name, description
|
SELECT id, subtype, name, description
|
||||||
FROM mental_models
|
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
|
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||||
""",
|
""",
|
||||||
bank_id,
|
bank_id,
|
||||||
|
|
@ -115,7 +140,7 @@ async def tool_lookup(
|
||||||
"""
|
"""
|
||||||
SELECT id, subtype, name, description
|
SELECT id, subtype, name, description
|
||||||
FROM mental_models
|
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
|
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||||
""",
|
""",
|
||||||
bank_id,
|
bank_id,
|
||||||
|
|
@ -126,7 +151,7 @@ async def tool_lookup(
|
||||||
"""
|
"""
|
||||||
SELECT id, subtype, name, description
|
SELECT id, subtype, name, description
|
||||||
FROM mental_models
|
FROM mental_models
|
||||||
WHERE bank_id = $1
|
WHERE bank_id = $1 AND subtype != 'directive'
|
||||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||||
""",
|
""",
|
||||||
bank_id,
|
bank_id,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ Tool schema definitions for the reflect agent.
|
||||||
These are OpenAI-format tool definitions used with native tool calling.
|
These are OpenAI-format tool definitions used with native tool calling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
# Tool definitions in OpenAI format
|
# Tool definitions in OpenAI format
|
||||||
TOOL_LIST_MENTAL_MODELS = {
|
TOOL_LIST_MENTAL_MODELS = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|
@ -134,68 +132,76 @@ TOOL_DONE_ANSWER = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
TOOL_DONE_OBSERVATIONS = {
|
|
||||||
"type": "function",
|
def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||||
"function": {
|
"""
|
||||||
"name": "done",
|
Build the done tool schema with directive compliance field.
|
||||||
"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": {
|
When directives are present, adds a required field that forces the agent
|
||||||
"type": "object",
|
to confirm compliance with each directive before submitting.
|
||||||
"properties": {
|
|
||||||
"observations": {
|
Args:
|
||||||
"type": "array",
|
directive_rules: List of directive rule strings
|
||||||
"minItems": 3,
|
"""
|
||||||
"items": {
|
from typing import Any, cast
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
# Build rules list for description
|
||||||
"title": {
|
rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules))
|
||||||
"type": "string",
|
|
||||||
"description": "Short header for this observation's theme (e.g., 'Work Style', 'Technical Skills')",
|
# Build the tool with directive compliance field
|
||||||
},
|
return {
|
||||||
"text": {
|
"type": "function",
|
||||||
"type": "string",
|
"function": {
|
||||||
"description": "Observation content about ONE theme. End with 'Key evidence:' containing text citations (summaries of what memories say), NOT memory IDs.",
|
"name": "done",
|
||||||
},
|
"description": (
|
||||||
"memory_ids": {
|
"Signal completion with your final answer. IMPORTANT: You must confirm directive compliance before submitting. "
|
||||||
"type": "array",
|
"Your answer will be REJECTED if it violates any directive."
|
||||||
"items": {"type": "string"},
|
),
|
||||||
"description": "Full UUIDs of memories supporting this observation (put IDs here, not in text)",
|
"parameters": {
|
||||||
},
|
"type": "object",
|
||||||
},
|
"properties": {
|
||||||
"required": ["title", "text", "memory_ids"],
|
"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(
|
def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]:
|
||||||
enable_learn: bool = True, output_mode: Literal["answer", "observations"] = "answer"
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
"""
|
||||||
Get the list of tools for the reflect agent.
|
Get the list of tools for the reflect agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
enable_learn: Whether to include the learn tool
|
enable_learn: Whether to include the learn tool
|
||||||
output_mode: "answer" or "observations" - determines done tool format
|
directive_rules: Optional list of directive rule strings. If provided,
|
||||||
In observations mode, mental model tools are excluded to avoid
|
the done() tool will require directive compliance confirmation.
|
||||||
using potentially outdated models during regeneration.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of tool definitions in OpenAI format
|
List of tool definitions in OpenAI format
|
||||||
"""
|
"""
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
# In answer mode, include mental model tools for lookup
|
# Include mental model tools for lookup
|
||||||
# In observations mode (mental model generation), exclude them to avoid circular references
|
tools.append(TOOL_LIST_MENTAL_MODELS)
|
||||||
if output_mode == "answer":
|
tools.append(TOOL_GET_MENTAL_MODEL)
|
||||||
tools.append(TOOL_LIST_MENTAL_MODELS)
|
|
||||||
tools.append(TOOL_GET_MENTAL_MODEL)
|
|
||||||
|
|
||||||
tools.append(TOOL_RECALL)
|
tools.append(TOOL_RECALL)
|
||||||
|
|
||||||
if enable_learn:
|
if enable_learn:
|
||||||
|
|
@ -203,9 +209,9 @@ def get_reflect_tools(
|
||||||
|
|
||||||
tools.append(TOOL_EXPAND)
|
tools.append(TOOL_EXPAND)
|
||||||
|
|
||||||
# Add appropriate done tool based on output mode
|
# Use directive-aware done tool if directives are present
|
||||||
if output_mode == "observations":
|
if directive_rules:
|
||||||
tools.append(TOOL_DONE_OBSERVATIONS)
|
tools.append(_build_done_tool_with_directives(directive_rules))
|
||||||
else:
|
else:
|
||||||
tools.append(TOOL_DONE_ANSWER)
|
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)")
|
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):
|
class TokenUsage(BaseModel):
|
||||||
"""
|
"""
|
||||||
Token usage metrics for LLM calls.
|
Token usage metrics for LLM calls.
|
||||||
|
|
@ -252,7 +260,11 @@ class ReflectResult(BaseModel):
|
||||||
)
|
)
|
||||||
mental_models: list[MentalModelRef] = Field(
|
mental_models: list[MentalModelRef] = Field(
|
||||||
default_factory=list,
|
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,
|
RecallResult,
|
||||||
ReflectContext,
|
ReflectContext,
|
||||||
ReflectResultContext,
|
ReflectResultContext,
|
||||||
|
RefreshMentalModelContext,
|
||||||
|
RefreshMentalModelResult,
|
||||||
RetainContext,
|
RetainContext,
|
||||||
RetainResult,
|
RetainResult,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
|
|
@ -54,6 +56,8 @@ __all__ = [
|
||||||
"RecallResult",
|
"RecallResult",
|
||||||
"ReflectContext",
|
"ReflectContext",
|
||||||
"ReflectResultContext",
|
"ReflectResultContext",
|
||||||
|
"RefreshMentalModelContext",
|
||||||
|
"RefreshMentalModelResult",
|
||||||
"RetainContext",
|
"RetainContext",
|
||||||
"RetainResult",
|
"RetainResult",
|
||||||
"ValidationResult",
|
"ValidationResult",
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,18 @@ class ReflectContext:
|
||||||
context: str | None = None
|
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)
|
# Post-operation Contexts (includes results)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -164,6 +176,27 @@ class ReflectResultContext:
|
||||||
error: str | None = None
|
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):
|
class OperationValidatorExtension(Extension, ABC):
|
||||||
"""
|
"""
|
||||||
Validates and hooks into retain/recall/reflect operations.
|
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)
|
# Post-operation hooks (optional - override to implement)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -325,3 +377,28 @@ class OperationValidatorExtension(Extension, ABC):
|
||||||
- error: Error message (if failed)
|
- error: Error message (if failed)
|
||||||
"""
|
"""
|
||||||
pass
|
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,
|
RecallResult,
|
||||||
ReflectContext,
|
ReflectContext,
|
||||||
ReflectResultContext,
|
ReflectResultContext,
|
||||||
|
RefreshMentalModelContext,
|
||||||
|
RefreshMentalModelResult,
|
||||||
RequestContext,
|
RequestContext,
|
||||||
RetainContext,
|
RetainContext,
|
||||||
RetainResult,
|
RetainResult,
|
||||||
|
|
@ -93,6 +95,7 @@ class RateLimitingValidator(OperationValidatorExtension):
|
||||||
self.retain_counts: dict[str, int] = defaultdict(int)
|
self.retain_counts: dict[str, int] = defaultdict(int)
|
||||||
self.recall_counts: dict[str, int] = defaultdict(int)
|
self.recall_counts: dict[str, int] = defaultdict(int)
|
||||||
self.reflect_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:
|
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||||
self.retain_counts[ctx.bank_id] += 1
|
self.retain_counts[ctx.bank_id] += 1
|
||||||
|
|
@ -118,6 +121,16 @@ class RateLimitingValidator(OperationValidatorExtension):
|
||||||
)
|
)
|
||||||
return ValidationResult.accept()
|
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):
|
class TrackingValidator(OperationValidatorExtension):
|
||||||
"""
|
"""
|
||||||
|
|
@ -132,10 +145,12 @@ class TrackingValidator(OperationValidatorExtension):
|
||||||
self.pre_retain_calls: list[RetainContext] = []
|
self.pre_retain_calls: list[RetainContext] = []
|
||||||
self.pre_recall_calls: list[RecallContext] = []
|
self.pre_recall_calls: list[RecallContext] = []
|
||||||
self.pre_reflect_calls: list[ReflectContext] = []
|
self.pre_reflect_calls: list[ReflectContext] = []
|
||||||
|
self.pre_refresh_mental_model_calls: list[RefreshMentalModelContext] = []
|
||||||
# Post-hook tracking
|
# Post-hook tracking
|
||||||
self.post_retain_calls: list[RetainResult] = []
|
self.post_retain_calls: list[RetainResult] = []
|
||||||
self.post_recall_calls: list[RecallResult] = []
|
self.post_recall_calls: list[RecallResult] = []
|
||||||
self.post_reflect_calls: list[ReflectResultContext] = []
|
self.post_reflect_calls: list[ReflectResultContext] = []
|
||||||
|
self.post_refresh_mental_model_calls: list[RefreshMentalModelResult] = []
|
||||||
|
|
||||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||||
self.pre_retain_calls.append(ctx)
|
self.pre_retain_calls.append(ctx)
|
||||||
|
|
@ -149,6 +164,12 @@ class TrackingValidator(OperationValidatorExtension):
|
||||||
self.pre_reflect_calls.append(ctx)
|
self.pre_reflect_calls.append(ctx)
|
||||||
return ValidationResult.accept()
|
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:
|
async def on_retain_complete(self, result: RetainResult) -> None:
|
||||||
self.post_retain_calls.append(result)
|
self.post_retain_calls.append(result)
|
||||||
|
|
||||||
|
|
@ -158,6 +179,11 @@ class TrackingValidator(OperationValidatorExtension):
|
||||||
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
|
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
|
||||||
self.post_reflect_calls.append(result)
|
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:
|
class TestMemoryEngineValidation:
|
||||||
"""Tests for validation integration with MemoryEngine.
|
"""Tests for validation integration with MemoryEngine.
|
||||||
|
|
@ -515,6 +541,105 @@ class TestOperationHooksParameters:
|
||||||
assert len(validator.pre_recall_calls) == 1
|
assert len(validator.pre_recall_calls) == 1
|
||||||
assert len(validator.post_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:
|
class TestTenantExtension:
|
||||||
"""Tests for TenantExtension and ApiKeyTenantExtension."""
|
"""Tests for TenantExtension and ApiKeyTenantExtension."""
|
||||||
|
|
|
||||||
|
|
@ -259,23 +259,11 @@ class TestReflectToolSchemas:
|
||||||
assert "recall" in tool_names
|
assert "recall" in tool_names
|
||||||
assert "done" 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):
|
def test_get_reflect_tools_answer_mode(self):
|
||||||
"""Test getting reflect tools with answer output mode."""
|
"""Test getting reflect tools with answer output mode."""
|
||||||
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
|
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")
|
done_tool = next(t for t in tools if t["function"]["name"] == "done")
|
||||||
params = done_tool["function"]["parameters"]["properties"]
|
params = done_tool["function"]["parameters"]["properties"]
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ from hindsight_api.extensions import (
|
||||||
RetainContext,
|
RetainContext,
|
||||||
RecallContext,
|
RecallContext,
|
||||||
ReflectContext,
|
ReflectContext,
|
||||||
|
RefreshMentalModelContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -394,3 +395,6 @@ class MockOperationValidator(OperationValidatorExtension):
|
||||||
|
|
||||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||||
return ValidationResult.accept()
|
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 "tags" in model
|
||||||
assert isinstance(model["tags"], list)
|
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"]
|
||||||
|
|
||||||
|
|
|
||||||
405
hindsight-api/tests/test_observation_trends.py
Normal file
405
hindsight-api/tests/test_observation_trends.py
Normal file
|
|
@ -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",
|
"subtype": "learned",
|
||||||
"name": "Model 1",
|
"name": "Model 1",
|
||||||
"description": "First model",
|
"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,
|
"entity_id": None,
|
||||||
"last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
|
"last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
|
||||||
}
|
}
|
||||||
|
|
@ -98,9 +110,12 @@ class TestToolLookup:
|
||||||
assert result["found"] is True
|
assert result["found"] is True
|
||||||
assert result["model"]["id"] == "model-1"
|
assert result["model"]["id"] == "model-1"
|
||||||
assert len(result["model"]["observations"]) == 1
|
assert len(result["model"]["observations"]) == 1
|
||||||
assert result["model"]["observations"][0]["text"] == "Full summary of model 1"
|
# Observations are now Observation objects
|
||||||
# Verify memory_ids are mapped to based_on
|
obs = result["model"]["observations"][0]
|
||||||
assert result["model"]["observations"][0]["based_on"] == ["mem-1", "mem-2"]
|
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):
|
async def test_model_not_found(self, mock_conn):
|
||||||
"""Test looking up non-existent model."""
|
"""Test looking up non-existent model."""
|
||||||
|
|
@ -840,6 +855,158 @@ class TestReflectAgent:
|
||||||
|
|
||||||
assert result.text == "The answer is simple and direct."
|
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
|
@pytest.mark.integration
|
||||||
class TestReflectIntegration:
|
class TestReflectIntegration:
|
||||||
|
|
|
||||||
|
|
@ -2058,3 +2058,26 @@ async def test_user_provided_entities(memory, request_context):
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await memory.delete_bank(bank_id, request_context=request_context)
|
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,
|
RetainContext,
|
||||||
RecallContext,
|
RecallContext,
|
||||||
ReflectContext,
|
ReflectContext,
|
||||||
|
RefreshMentalModelContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -288,3 +289,6 @@ class MockOperationValidator(OperationValidatorExtension):
|
||||||
|
|
||||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||||
return ValidationResult.accept()
|
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_memory_units_response.py
|
||||||
hindsight_client_api/models/list_tags_response.py
|
hindsight_client_api/models/list_tags_response.py
|
||||||
hindsight_client_api/models/memory_item.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_list_response.py
|
||||||
hindsight_client_api/models/mental_model_observation_response.py
|
hindsight_client_api/models/mental_model_observation_response.py
|
||||||
hindsight_client_api/models/mental_model_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_response.py
|
||||||
hindsight_client_api/models/operation_status_response.py
|
hindsight_client_api/models/operation_status_response.py
|
||||||
hindsight_client_api/models/operations_list_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/token_usage.py
|
||||||
hindsight_client_api/models/tool_calls_include_options.py
|
hindsight_client_api/models/tool_calls_include_options.py
|
||||||
hindsight_client_api/models/update_disposition_request.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.py
|
||||||
hindsight_client_api/models/validation_error_loc_inner.py
|
hindsight_client_api/models/validation_error_loc_inner.py
|
||||||
hindsight_client_api/rest.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_memory_units_response import ListMemoryUnitsResponse
|
||||||
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
||||||
from hindsight_client_api.models.memory_item import MemoryItem
|
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_list_response import MentalModelListResponse
|
||||||
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
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.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_response import OperationResponse
|
||||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
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.token_usage import TokenUsage
|
||||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
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_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 import ValidationError
|
||||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
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_memory_units_response import ListMemoryUnitsResponse
|
||||||
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
||||||
from hindsight_client_api.models.memory_item import MemoryItem
|
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_list_response import MentalModelListResponse
|
||||||
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
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.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_response import OperationResponse
|
||||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
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.token_usage import TokenUsage
|
||||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
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_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 import ValidationError
|
||||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,20 @@ import json
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||||
from typing import Any, ClassVar, Dict, List, Optional
|
from typing import Any, ClassVar, Dict, List, Optional
|
||||||
|
from hindsight_client_api.models.observation_input import ObservationInput
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
class CreateMentalModelRequest(BaseModel):
|
class CreateMentalModelRequest(BaseModel):
|
||||||
"""
|
"""
|
||||||
Request model for creating a pinned mental model.
|
Request model for creating a mental model.
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
name: StrictStr = Field(description="Human-readable name for the mental model")
|
name: StrictStr = Field(description="Human-readable name for the mental model")
|
||||||
description: StrictStr = Field(description="One-liner description for quick scanning")
|
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")
|
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(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -70,6 +73,18 @@ class CreateMentalModelRequest(BaseModel):
|
||||||
exclude=excluded_fields,
|
exclude=excluded_fields,
|
||||||
exclude_none=True,
|
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
|
return _dict
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -84,6 +99,8 @@ class CreateMentalModelRequest(BaseModel):
|
||||||
_obj = cls.model_validate({
|
_obj = cls.model_validate({
|
||||||
"name": obj.get("name"),
|
"name": obj.get("name"),
|
||||||
"description": obj.get("description"),
|
"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")
|
"tags": obj.get("tags")
|
||||||
})
|
})
|
||||||
return _obj
|
return _obj
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# coding: utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
Hindsight HTTP API
|
||||||
|
|
||||||
|
HTTP API for Hindsight
|
||||||
|
|
||||||
|
The version of the OpenAPI document: 0.1.0
|
||||||
|
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||||
|
|
||||||
|
Do not edit the class manually.
|
||||||
|
""" # noqa: E501
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import pprint
|
||||||
|
import re # noqa: F401
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
|
||||||
|
from typing import Any, ClassVar, Dict, List, Optional
|
||||||
|
from typing import Optional, Set
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
class MentalModelFreshnessResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Freshness information for a mental model.
|
||||||
|
""" # noqa: E501
|
||||||
|
is_up_to_date: StrictBool = Field(description="Whether the model has been refreshed since the last memory was added")
|
||||||
|
last_refresh_at: Optional[StrictStr]
|
||||||
|
memories_since_refresh: StrictInt = Field(description="Number of memories added since last refresh")
|
||||||
|
reasons: Optional[List[StrictStr]] = Field(default=None, description="Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed")
|
||||||
|
__properties: ClassVar[List[str]] = ["is_up_to_date", "last_refresh_at", "memories_since_refresh", "reasons"]
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
populate_by_name=True,
|
||||||
|
validate_assignment=True,
|
||||||
|
protected_namespaces=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_str(self) -> str:
|
||||||
|
"""Returns the string representation of the model using alias"""
|
||||||
|
return pprint.pformat(self.model_dump(by_alias=True))
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Returns the JSON representation of the model using alias"""
|
||||||
|
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||||
|
return json.dumps(self.to_dict())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||||
|
"""Create an instance of MentalModelFreshnessResponse from a JSON string"""
|
||||||
|
return cls.from_dict(json.loads(json_str))
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Return the dictionary representation of the model using alias.
|
||||||
|
|
||||||
|
This has the following differences from calling pydantic's
|
||||||
|
`self.model_dump(by_alias=True)`:
|
||||||
|
|
||||||
|
* `None` is only added to the output dict for nullable fields that
|
||||||
|
were set at model initialization. Other fields with value `None`
|
||||||
|
are ignored.
|
||||||
|
"""
|
||||||
|
excluded_fields: Set[str] = set([
|
||||||
|
])
|
||||||
|
|
||||||
|
_dict = self.model_dump(
|
||||||
|
by_alias=True,
|
||||||
|
exclude=excluded_fields,
|
||||||
|
exclude_none=True,
|
||||||
|
)
|
||||||
|
# set to None if last_refresh_at (nullable) is None
|
||||||
|
# and model_fields_set contains the field
|
||||||
|
if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set:
|
||||||
|
_dict['last_refresh_at'] = None
|
||||||
|
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||||
|
"""Create an instance of MentalModelFreshnessResponse from a dict"""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return cls.model_validate(obj)
|
||||||
|
|
||||||
|
_obj = cls.model_validate({
|
||||||
|
"is_up_to_date": obj.get("is_up_to_date"),
|
||||||
|
"last_refresh_at": obj.get("last_refresh_at"),
|
||||||
|
"memories_since_refresh": obj.get("memories_since_refresh"),
|
||||||
|
"reasons": obj.get("reasons")
|
||||||
|
})
|
||||||
|
return _obj
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,19 +17,24 @@ import pprint
|
||||||
import re # noqa: F401
|
import re # noqa: F401
|
||||||
import json
|
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 typing import Any, ClassVar, Dict, List, Optional
|
||||||
|
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
class MentalModelObservationResponse(BaseModel):
|
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
|
""" # noqa: E501
|
||||||
title: StrictStr = Field(description="Observation header (empty for intro)")
|
title: StrictStr = Field(description="Short summary title for the observation")
|
||||||
text: StrictStr = Field(description="Observation content")
|
content: StrictStr = Field(description="The observation content - detailed explanation")
|
||||||
based_on: Optional[List[StrictStr]] = Field(default=None, description="Memory IDs supporting this observation")
|
evidence: Optional[List[ObservationEvidenceResponse]] = Field(default=None, description="Supporting evidence with quotes")
|
||||||
__properties: ClassVar[List[str]] = ["title", "text", "based_on"]
|
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(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -70,6 +75,13 @@ class MentalModelObservationResponse(BaseModel):
|
||||||
exclude=excluded_fields,
|
exclude=excluded_fields,
|
||||||
exclude_none=True,
|
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
|
return _dict
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -83,8 +95,12 @@ class MentalModelObservationResponse(BaseModel):
|
||||||
|
|
||||||
_obj = cls.model_validate({
|
_obj = cls.model_validate({
|
||||||
"title": obj.get("title"),
|
"title": obj.get("title"),
|
||||||
"text": obj.get("text"),
|
"content": obj.get("content"),
|
||||||
"based_on": obj.get("based_on")
|
"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
|
return _obj
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ import pprint
|
||||||
import re # noqa: F401
|
import re # noqa: F401
|
||||||
import json
|
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 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 hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
@ -33,12 +34,15 @@ class MentalModelResponse(BaseModel):
|
||||||
name: StrictStr
|
name: StrictStr
|
||||||
description: StrictStr
|
description: StrictStr
|
||||||
observations: Optional[List[MentalModelObservationResponse]] = Field(default=None, description="Structured observations with per-observation fact attribution")
|
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
|
entity_id: Optional[StrictStr] = None
|
||||||
links: Optional[List[StrictStr]] = None
|
links: Optional[List[StrictStr]] = None
|
||||||
tags: Optional[List[StrictStr]] = None
|
tags: Optional[List[StrictStr]] = None
|
||||||
last_updated: Optional[StrictStr] = None
|
last_updated: Optional[StrictStr] = None
|
||||||
|
last_refresh_at: Optional[StrictStr] = None
|
||||||
|
freshness: Optional[MentalModelFreshnessResponse] = None
|
||||||
created_at: StrictStr
|
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(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -86,6 +90,9 @@ class MentalModelResponse(BaseModel):
|
||||||
if _item_observations:
|
if _item_observations:
|
||||||
_items.append(_item_observations.to_dict())
|
_items.append(_item_observations.to_dict())
|
||||||
_dict['observations'] = _items
|
_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
|
# set to None if entity_id (nullable) is None
|
||||||
# and model_fields_set contains the field
|
# and model_fields_set contains the field
|
||||||
if self.entity_id is None and "entity_id" in self.model_fields_set:
|
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:
|
if self.last_updated is None and "last_updated" in self.model_fields_set:
|
||||||
_dict['last_updated'] = None
|
_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
|
return _dict
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -114,10 +131,13 @@ class MentalModelResponse(BaseModel):
|
||||||
"name": obj.get("name"),
|
"name": obj.get("name"),
|
||||||
"description": obj.get("description"),
|
"description": obj.get("description"),
|
||||||
"observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
|
"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"),
|
"entity_id": obj.get("entity_id"),
|
||||||
"links": obj.get("links"),
|
"links": obj.get("links"),
|
||||||
"tags": obj.get("tags"),
|
"tags": obj.get("tags"),
|
||||||
"last_updated": obj.get("last_updated"),
|
"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")
|
"created_at": obj.get("created_at")
|
||||||
})
|
})
|
||||||
return _obj
|
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")
|
id: StrictStr = Field(description="Mental model ID")
|
||||||
name: StrictStr = Field(description="Mental model name")
|
name: StrictStr = Field(description="Mental model name")
|
||||||
type: StrictStr = Field(description="Mental model type: entity, concept, event")
|
type: StrictStr = Field(description="Mental model type: entity, concept, event")
|
||||||
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned")
|
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned, directive")
|
||||||
description: StrictStr = Field(description="Brief description")
|
observations: Optional[List[StrictStr]] = None
|
||||||
summary: Optional[StrictStr] = None
|
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "observations"]
|
||||||
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "description", "summary"]
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -73,10 +72,10 @@ class ReflectMentalModel(BaseModel):
|
||||||
exclude=excluded_fields,
|
exclude=excluded_fields,
|
||||||
exclude_none=True,
|
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
|
# and model_fields_set contains the field
|
||||||
if self.summary is None and "summary" in self.model_fields_set:
|
if self.observations is None and "observations" in self.model_fields_set:
|
||||||
_dict['summary'] = None
|
_dict['observations'] = None
|
||||||
|
|
||||||
return _dict
|
return _dict
|
||||||
|
|
||||||
|
|
@ -94,8 +93,7 @@ class ReflectMentalModel(BaseModel):
|
||||||
"name": obj.get("name"),
|
"name": obj.get("name"),
|
||||||
"type": obj.get("type"),
|
"type": obj.get("type"),
|
||||||
"subtype": obj.get("subtype"),
|
"subtype": obj.get("subtype"),
|
||||||
"description": obj.get("description"),
|
"observations": obj.get("observations")
|
||||||
"summary": obj.get("summary")
|
|
||||||
})
|
})
|
||||||
return _obj
|
return _obj
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import json
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from typing import Any, ClassVar, Dict, List, Optional
|
from typing import Any, ClassVar, Dict, List, Optional
|
||||||
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
|
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 hindsight_client_api.models.reflect_tool_call import ReflectToolCall
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
@ -30,7 +31,8 @@ class ReflectTrace(BaseModel):
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
tool_calls: Optional[List[ReflectToolCall]] = Field(default=None, description="Tool calls made during reflection")
|
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")
|
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(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -85,6 +87,13 @@ class ReflectTrace(BaseModel):
|
||||||
if _item_llm_calls:
|
if _item_llm_calls:
|
||||||
_items.append(_item_llm_calls.to_dict())
|
_items.append(_item_llm_calls.to_dict())
|
||||||
_dict['llm_calls'] = _items
|
_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
|
return _dict
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -98,7 +107,8 @@ class ReflectTrace(BaseModel):
|
||||||
|
|
||||||
_obj = cls.model_validate({
|
_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,
|
"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
|
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,
|
DeleteMentalModelData,
|
||||||
DeleteMentalModelErrors,
|
DeleteMentalModelErrors,
|
||||||
DeleteMentalModelResponses,
|
DeleteMentalModelResponses,
|
||||||
GenerateMentalModelData,
|
|
||||||
GenerateMentalModelErrors,
|
|
||||||
GenerateMentalModelResponses,
|
|
||||||
GetAgentStatsData,
|
GetAgentStatsData,
|
||||||
GetAgentStatsErrors,
|
GetAgentStatsErrors,
|
||||||
GetAgentStatsResponses,
|
GetAgentStatsResponses,
|
||||||
|
|
@ -54,6 +51,9 @@ import type {
|
||||||
GetMentalModelData,
|
GetMentalModelData,
|
||||||
GetMentalModelErrors,
|
GetMentalModelErrors,
|
||||||
GetMentalModelResponses,
|
GetMentalModelResponses,
|
||||||
|
GetMentalModelVersionData,
|
||||||
|
GetMentalModelVersionErrors,
|
||||||
|
GetMentalModelVersionResponses,
|
||||||
GetOperationStatusData,
|
GetOperationStatusData,
|
||||||
GetOperationStatusErrors,
|
GetOperationStatusErrors,
|
||||||
GetOperationStatusResponses,
|
GetOperationStatusResponses,
|
||||||
|
|
@ -74,6 +74,9 @@ import type {
|
||||||
ListMentalModelsData,
|
ListMentalModelsData,
|
||||||
ListMentalModelsErrors,
|
ListMentalModelsErrors,
|
||||||
ListMentalModelsResponses,
|
ListMentalModelsResponses,
|
||||||
|
ListMentalModelVersionsData,
|
||||||
|
ListMentalModelVersionsErrors,
|
||||||
|
ListMentalModelVersionsResponses,
|
||||||
ListOperationsData,
|
ListOperationsData,
|
||||||
ListOperationsErrors,
|
ListOperationsErrors,
|
||||||
ListOperationsResponses,
|
ListOperationsResponses,
|
||||||
|
|
@ -88,6 +91,9 @@ import type {
|
||||||
ReflectData,
|
ReflectData,
|
||||||
ReflectErrors,
|
ReflectErrors,
|
||||||
ReflectResponses,
|
ReflectResponses,
|
||||||
|
RefreshMentalModelData,
|
||||||
|
RefreshMentalModelErrors,
|
||||||
|
RefreshMentalModelResponses,
|
||||||
RefreshMentalModelsData,
|
RefreshMentalModelsData,
|
||||||
RefreshMentalModelsErrors,
|
RefreshMentalModelsErrors,
|
||||||
RefreshMentalModelsResponses,
|
RefreshMentalModelsResponses,
|
||||||
|
|
@ -103,6 +109,9 @@ import type {
|
||||||
UpdateBankDispositionResponses,
|
UpdateBankDispositionResponses,
|
||||||
UpdateBankErrors,
|
UpdateBankErrors,
|
||||||
UpdateBankResponses,
|
UpdateBankResponses,
|
||||||
|
UpdateMentalModelData,
|
||||||
|
UpdateMentalModelErrors,
|
||||||
|
UpdateMentalModelResponses,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
|
|
||||||
export type Options<
|
export type Options<
|
||||||
|
|
@ -343,7 +352,9 @@ export const listMentalModels = <ThrowOnError extends boolean = false>(
|
||||||
/**
|
/**
|
||||||
* Create mental model
|
* 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>(
|
export const createMentalModel = <ThrowOnError extends boolean = false>(
|
||||||
options: Options<CreateMentalModelData, ThrowOnError>,
|
options: Options<CreateMentalModelData, ThrowOnError>,
|
||||||
|
|
@ -395,6 +406,27 @@ export const getMentalModel = <ThrowOnError extends boolean = false>(
|
||||||
...options,
|
...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)
|
* 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>(
|
export const refreshMentalModel = <ThrowOnError extends boolean = false>(
|
||||||
options: Options<GenerateMentalModelData, ThrowOnError>,
|
options: Options<RefreshMentalModelData, ThrowOnError>,
|
||||||
) =>
|
) =>
|
||||||
(options.client ?? client).post<
|
(options.client ?? client).post<
|
||||||
GenerateMentalModelResponses,
|
RefreshMentalModelResponses,
|
||||||
GenerateMentalModelErrors,
|
RefreshMentalModelErrors,
|
||||||
ThrowOnError
|
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,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,7 @@ export type CreateBankRequest = {
|
||||||
/**
|
/**
|
||||||
* CreateMentalModelRequest
|
* CreateMentalModelRequest
|
||||||
*
|
*
|
||||||
* Request model for creating a pinned mental model.
|
* Request model for creating a mental model.
|
||||||
*/
|
*/
|
||||||
export type CreateMentalModelRequest = {
|
export type CreateMentalModelRequest = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -329,6 +329,18 @@ export type CreateMentalModelRequest = {
|
||||||
* One-liner description for quick scanning
|
* One-liner description for quick scanning
|
||||||
*/
|
*/
|
||||||
description: string;
|
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
|
* Tags
|
||||||
*
|
*
|
||||||
|
|
@ -830,6 +842,38 @@ export type MemoryItem = {
|
||||||
tags?: Array<string> | null;
|
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
|
* MentalModelListResponse
|
||||||
*
|
*
|
||||||
|
|
@ -845,27 +889,53 @@ export type MentalModelListResponse = {
|
||||||
/**
|
/**
|
||||||
* MentalModelObservationResponse
|
* MentalModelObservationResponse
|
||||||
*
|
*
|
||||||
* An observation within a mental model with its supporting memories.
|
* An observation within a mental model with its supporting evidence.
|
||||||
*/
|
*/
|
||||||
export type MentalModelObservationResponse = {
|
export type MentalModelObservationResponse = {
|
||||||
/**
|
/**
|
||||||
* Title
|
* Title
|
||||||
*
|
*
|
||||||
* Observation header (empty for intro)
|
* Short summary title for the observation
|
||||||
*/
|
*/
|
||||||
title: string;
|
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
|
* Structured observations with per-observation fact attribution
|
||||||
*/
|
*/
|
||||||
observations?: Array<MentalModelObservationResponse>;
|
observations?: Array<MentalModelObservationResponse>;
|
||||||
|
/**
|
||||||
|
* Version
|
||||||
|
*
|
||||||
|
* Version number of the mental model observations
|
||||||
|
*/
|
||||||
|
version?: number;
|
||||||
/**
|
/**
|
||||||
* Entity Id
|
* Entity Id
|
||||||
*/
|
*/
|
||||||
|
|
@ -916,12 +992,74 @@ export type MentalModelResponse = {
|
||||||
* Last Updated
|
* Last Updated
|
||||||
*/
|
*/
|
||||||
last_updated?: string | null;
|
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
|
||||||
*/
|
*/
|
||||||
created_at: string;
|
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
|
* OperationResponse
|
||||||
*
|
*
|
||||||
|
|
@ -1270,21 +1408,15 @@ export type ReflectMentalModel = {
|
||||||
/**
|
/**
|
||||||
* Subtype
|
* Subtype
|
||||||
*
|
*
|
||||||
* Mental model subtype: structural, emergent, learned
|
* Mental model subtype: structural, emergent, learned, directive
|
||||||
*/
|
*/
|
||||||
subtype: string;
|
subtype: string;
|
||||||
/**
|
/**
|
||||||
* Description
|
* Observations
|
||||||
*
|
*
|
||||||
* Brief description
|
* Observations for directive mental models (subtype='directive')
|
||||||
*/
|
*/
|
||||||
description: string;
|
observations?: Array<string> | null;
|
||||||
/**
|
|
||||||
* Summary
|
|
||||||
*
|
|
||||||
* Full summary (when looked up in detail)
|
|
||||||
*/
|
|
||||||
summary?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1436,6 +1568,12 @@ export type ReflectTrace = {
|
||||||
* LLM calls made during reflection
|
* LLM calls made during reflection
|
||||||
*/
|
*/
|
||||||
llm_calls?: Array<ReflectLlmCall>;
|
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;
|
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
|
* ValidationError
|
||||||
*/
|
*/
|
||||||
|
|
@ -2226,6 +2384,48 @@ export type GetMentalModelResponses = {
|
||||||
export type GetMentalModelResponse =
|
export type GetMentalModelResponse =
|
||||||
GetMentalModelResponses[keyof GetMentalModelResponses];
|
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 = {
|
export type RefreshMentalModelsData = {
|
||||||
/**
|
/**
|
||||||
* Body
|
* Body
|
||||||
|
|
@ -2267,7 +2467,7 @@ export type RefreshMentalModelsResponses = {
|
||||||
export type RefreshMentalModelsResponse =
|
export type RefreshMentalModelsResponse =
|
||||||
RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses];
|
RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses];
|
||||||
|
|
||||||
export type GenerateMentalModelData = {
|
export type RefreshMentalModelData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
headers?: {
|
headers?: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -2286,28 +2486,110 @@ export type GenerateMentalModelData = {
|
||||||
model_id: string;
|
model_id: string;
|
||||||
};
|
};
|
||||||
query?: never;
|
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
|
* Validation Error
|
||||||
*/
|
*/
|
||||||
422: HttpValidationError;
|
422: HttpValidationError;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerateMentalModelError =
|
export type RefreshMentalModelError =
|
||||||
GenerateMentalModelErrors[keyof GenerateMentalModelErrors];
|
RefreshMentalModelErrors[keyof RefreshMentalModelErrors];
|
||||||
|
|
||||||
export type GenerateMentalModelResponses = {
|
export type RefreshMentalModelResponses = {
|
||||||
/**
|
/**
|
||||||
* Successful Response
|
* Successful Response
|
||||||
*/
|
*/
|
||||||
200: AsyncOperationSubmitResponse;
|
200: AsyncOperationSubmitResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerateMentalModelResponse =
|
export type RefreshMentalModelResponse =
|
||||||
GenerateMentalModelResponses[keyof GenerateMentalModelResponses];
|
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 = {
|
export type ListDocumentsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@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/postcss": "^4.1.17",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,19 @@ export async function POST(
|
||||||
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await sdk.generateMentalModel({
|
const response = await sdk.refreshMentalModel({
|
||||||
client: lowLevelClient,
|
client: lowLevelClient,
|
||||||
path: { bank_id: bankId, model_id: modelId },
|
path: { bank_id: bankId, model_id: modelId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
console.error("API error generating mental model:", response.error);
|
console.error("API error refreshing mental model:", response.error);
|
||||||
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(response.data, { status: 200 });
|
return NextResponse.json(response.data, { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating mental model:", error);
|
console.error("Error refreshing mental model:", error);
|
||||||
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,52 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
|
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(
|
export async function DELETE(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ bankId: string; modelId: string; version: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { bankId, modelId, version } = await params;
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return NextResponse.json({ error: "version is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions/${version}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("API error getting mental model version:", errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorText || "Failed to get mental model version" },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting mental model version:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to get mental model version" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { bankId, modelId } = await params;
|
||||||
|
|
||||||
|
if (!bankId) {
|
||||||
|
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("API error listing mental model versions:", errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorText || "Failed to list mental model versions" },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error listing mental model versions:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to list mental model versions" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,34 @@ const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://loca
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
|
||||||
try {
|
try {
|
||||||
const { bankId } = await params;
|
const { bankId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const subtype = searchParams.get("subtype");
|
||||||
|
|
||||||
if (!bankId) {
|
if (!bankId) {
|
||||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
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({
|
const response = await sdk.listMentalModels({
|
||||||
client: lowLevelClient,
|
client: lowLevelClient,
|
||||||
path: { bank_id: bankId },
|
path: { bank_id: bankId },
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Settings2,
|
Settings2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -248,11 +249,9 @@ export function DataView({ factType }: DataViewProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="text-center py-12">
|
||||||
<div className="text-center">
|
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||||
<div className="text-4xl mb-2">⏳</div>
|
<p className="text-muted-foreground">Loading memories...</p>
|
||||||
<div className="text-sm text-muted-foreground">Loading memories...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
391
hindsight-control-plane/src/components/memory-detail-modal.tsx
Normal file
391
hindsight-control-plane/src/components/memory-detail-modal.tsx
Normal file
|
|
@ -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";
|
} from "@/components/ui/select";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 JsonView from "react18-json-view";
|
||||||
import "react18-json-view/src/style.css";
|
import "react18-json-view/src/style.css";
|
||||||
|
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||||
|
|
||||||
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
|
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
|
||||||
type ViewMode = "answer" | "trace" | "json";
|
type ViewMode = "answer" | "trace" | "json";
|
||||||
|
|
@ -33,6 +45,94 @@ export function ThinkView() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
|
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 () => {
|
const runReflect = async () => {
|
||||||
if (!currentBank || !query) return;
|
if (!currentBank || !query) return;
|
||||||
|
|
@ -135,7 +235,7 @@ export function ThinkView() {
|
||||||
checked={includeFacts}
|
checked={includeFacts}
|
||||||
onCheckedChange={(c) => setIncludeFacts(c as boolean)}
|
onCheckedChange={(c) => setIncludeFacts(c as boolean)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Include Facts</span>
|
<span className="text-sm">Include Source</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -288,6 +388,50 @@ export function ThinkView() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -377,7 +521,17 @@ export function ThinkView() {
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
llmCalls.forEach((lc: any, idx: number) => {
|
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;
|
const iterNum = isFinal ? llmCalls.length : idx + 1;
|
||||||
|
|
||||||
// Add LLM call
|
// Add LLM call
|
||||||
|
|
@ -388,10 +542,6 @@ export function ThinkView() {
|
||||||
isFinal,
|
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) {
|
if (iterTools.length > 0) {
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "tools",
|
type: "tools",
|
||||||
|
|
@ -517,7 +667,11 @@ export function ThinkView() {
|
||||||
<CardTitle className="text-base">Based On</CardTitle>
|
<CardTitle className="text-base">Based On</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
{(result.based_on?.memories?.length || 0) +
|
{(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
|
items used
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -528,7 +682,7 @@ export function ThinkView() {
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm text-foreground">Not included</p>
|
<p className="font-medium text-sm text-foreground">Not included</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Enable "Include Facts" to see memories.
|
Enable "Include Source" to see memories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -543,10 +697,53 @@ export function ThinkView() {
|
||||||
(f: any) => f.type === "experience"
|
(f: any) => f.type === "experience"
|
||||||
);
|
);
|
||||||
const opinionFacts = memories.filter((f: any) => f.type === "opinion");
|
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 (
|
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 */}
|
{/* Mental Models */}
|
||||||
{mentalModels.length > 0 && (
|
{mentalModels.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -556,13 +753,12 @@ export function ThinkView() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{mentalModels.map((model: any, i: number) => (
|
{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>
|
<div className="font-medium">{model.name}</div>
|
||||||
{model.description && (
|
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
|
||||||
{model.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -578,7 +774,11 @@ export function ThinkView() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{worldFacts.map((fact: any, i: number) => (
|
{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.text}
|
||||||
{fact.context && (
|
{fact.context && (
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
|
@ -600,7 +800,11 @@ export function ThinkView() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{experienceFacts.map((fact: any, i: number) => (
|
{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.text}
|
||||||
{fact.context && (
|
{fact.context && (
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
|
@ -622,7 +826,11 @@ export function ThinkView() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{opinionFacts.map((fact: any, i: number) => (
|
{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.text}
|
||||||
{fact.context && (
|
{fact.context && (
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
|
@ -686,6 +894,224 @@ export function ThinkView() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
hindsight-control-plane/src/components/ui/tabs.tsx
Normal file
55
hindsight-control-plane/src/components/ui/tabs.tsx
Normal file
|
|
@ -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 };
|
||||||
32
hindsight-control-plane/src/components/ui/tooltip.tsx
Normal file
32
hindsight-control-plane/src/components/ui/tooltip.tsx
Normal file
|
|
@ -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;
|
subtype: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: 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;
|
entity_id: string | null;
|
||||||
links: string[];
|
links: string[];
|
||||||
tags?: 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
|
* 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 }>(
|
return this.fetchApi<{ operation_id: string; message: string }>(
|
||||||
`/api/banks/${bankId}/mental-models/${modelId}/generate`,
|
`/api/banks/${bankId}/mental-models/${modelId}/refresh`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}
|
}
|
||||||
|
|
@ -336,13 +362,15 @@ export class ControlPlaneClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pinned mental model
|
* Create a mental model (pinned or directive)
|
||||||
*/
|
*/
|
||||||
async createMentalModel(
|
async createMentalModel(
|
||||||
bankId: string,
|
bankId: string,
|
||||||
params: {
|
params: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
subtype?: "pinned" | "directive";
|
||||||
|
observations?: Array<{ title: string; content: string }>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
@ -352,7 +380,7 @@ export class ControlPlaneClient {
|
||||||
subtype: string;
|
subtype: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: 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;
|
entity_id: string | null;
|
||||||
links: string[];
|
links: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
@ -384,6 +412,64 @@ export class ControlPlaneClient {
|
||||||
body: JSON.stringify(profile),
|
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
|
// Export singleton instance
|
||||||
|
|
|
||||||
|
|
@ -901,7 +901,7 @@
|
||||||
"Mental Models"
|
"Mental Models"
|
||||||
],
|
],
|
||||||
"summary": "Create mental model",
|
"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",
|
"operationId": "create_mental_model",
|
||||||
"parameters": [
|
"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": {
|
"/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": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Mental Models"
|
"Mental Models"
|
||||||
],
|
],
|
||||||
"summary": "Generate mental model content (async)",
|
"summary": "Refresh 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.",
|
"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": "generate_mental_model",
|
"operationId": "refresh_mental_model",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "bank_id",
|
"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": {
|
"/v1/default/banks/{bank_id}/documents": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -2899,6 +3116,27 @@
|
||||||
"title": "Description",
|
"title": "Description",
|
||||||
"description": "One-liner description for quick scanning"
|
"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": {
|
"tags": {
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -2914,14 +3152,27 @@
|
||||||
"description"
|
"description"
|
||||||
],
|
],
|
||||||
"title": "CreateMentalModelRequest",
|
"title": "CreateMentalModelRequest",
|
||||||
"description": "Request model for creating a pinned mental model.",
|
"description": "Request model for creating a mental model.",
|
||||||
"example": {
|
"examples": [
|
||||||
"description": "Key product priorities and upcoming features",
|
{
|
||||||
"name": "Product Roadmap",
|
"description": "Key product priorities and upcoming features",
|
||||||
"tags": [
|
"name": "Product Roadmap",
|
||||||
"project-x"
|
"tags": [
|
||||||
]
|
"project-x"
|
||||||
}
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Rules about scheduling meetings",
|
||||||
|
"name": "Meeting Rules",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"content": "Never schedule meetings before 10am",
|
||||||
|
"title": "Morning meetings"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtype": "directive"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"CreatedMentalModel": {
|
"CreatedMentalModel": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -3812,6 +4063,48 @@
|
||||||
"timestamp": "2024-01-15T10:30:00Z"
|
"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": {
|
"MentalModelListResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": {
|
"items": {
|
||||||
|
|
@ -3834,29 +4127,54 @@
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Observation header (empty for intro)"
|
"description": "Short summary title for the observation"
|
||||||
},
|
},
|
||||||
"text": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Text",
|
"title": "Content",
|
||||||
"description": "Observation content"
|
"description": "The observation content - detailed explanation"
|
||||||
},
|
},
|
||||||
"based_on": {
|
"evidence": {
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"$ref": "#/components/schemas/ObservationEvidenceResponse"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Based On",
|
"title": "Evidence",
|
||||||
"description": "Memory IDs supporting this observation"
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"title",
|
"title",
|
||||||
"text"
|
"content",
|
||||||
|
"created_at",
|
||||||
|
"trend",
|
||||||
|
"evidence_count",
|
||||||
|
"evidence_span"
|
||||||
],
|
],
|
||||||
"title": "MentalModelObservationResponse",
|
"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": {
|
"MentalModelResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -3888,6 +4206,12 @@
|
||||||
"title": "Observations",
|
"title": "Observations",
|
||||||
"description": "Structured observations with per-observation fact attribution"
|
"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": {
|
"entity_id": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|
@ -3926,6 +4250,29 @@
|
||||||
],
|
],
|
||||||
"title": "Last Updated"
|
"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": {
|
"created_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Created At"
|
"title": "Created At"
|
||||||
|
|
@ -3946,25 +4293,99 @@
|
||||||
"bank_id": "test-bank",
|
"bank_id": "test-bank",
|
||||||
"created_at": "2024-01-10T08:00:00Z",
|
"created_at": "2024-01-10T08:00:00Z",
|
||||||
"description": "Who's on the team and their roles",
|
"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",
|
"id": "team-structure",
|
||||||
|
"last_refresh_at": "2024-01-15T10:30:00Z",
|
||||||
"last_updated": "2024-01-15T10:30:00Z",
|
"last_updated": "2024-01-15T10:30:00Z",
|
||||||
"links": [],
|
"links": [],
|
||||||
"name": "Team Structure",
|
"name": "Team Structure",
|
||||||
"observations": [
|
"observations": [
|
||||||
{
|
{
|
||||||
"based_on": [
|
"content": "The team prefers async communication over synchronous meetings",
|
||||||
"uuid1"
|
"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...",
|
"evidence_count": 1,
|
||||||
"title": "Overview"
|
"evidence_span": {
|
||||||
|
"from": "2024-01-10T08:00:00Z",
|
||||||
|
"to": "2024-01-10T08:00:00Z"
|
||||||
|
},
|
||||||
|
"title": "Prefers async communication",
|
||||||
|
"trend": "stable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subtype": "structural",
|
"subtype": "structural",
|
||||||
"tags": [
|
"tags": [
|
||||||
"project-x"
|
"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": {
|
"OperationResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
|
@ -4692,24 +5113,22 @@
|
||||||
"subtype": {
|
"subtype": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Subtype",
|
"title": "Subtype",
|
||||||
"description": "Mental model subtype: structural, emergent, learned"
|
"description": "Mental model subtype: structural, emergent, learned, directive"
|
||||||
},
|
},
|
||||||
"description": {
|
"observations": {
|
||||||
"type": "string",
|
|
||||||
"title": "Description",
|
|
||||||
"description": "Brief description"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string"
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Summary",
|
"title": "Observations",
|
||||||
"description": "Full summary (when looked up in detail)"
|
"description": "Observations for directive mental models (subtype='directive')"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -4717,8 +5136,7 @@
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"type",
|
"type",
|
||||||
"subtype",
|
"subtype"
|
||||||
"description"
|
|
||||||
],
|
],
|
||||||
"title": "ReflectMentalModel",
|
"title": "ReflectMentalModel",
|
||||||
"description": "A mental model accessed during reflect."
|
"description": "A mental model accessed during reflect."
|
||||||
|
|
@ -5028,6 +5446,14 @@
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Llm Calls",
|
"title": "Llm Calls",
|
||||||
"description": "LLM calls made during reflection"
|
"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",
|
"type": "object",
|
||||||
|
|
@ -5278,6 +5704,41 @@
|
||||||
"title": "UpdateDispositionRequest",
|
"title": "UpdateDispositionRequest",
|
||||||
"description": "Request model for updating disposition traits."
|
"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": {
|
"ValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"loc": {
|
"loc": {
|
||||||
|
|
|
||||||
84
package-lock.json
generated
84
package-lock.json
generated
|
|
@ -145,6 +145,8 @@
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@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/postcss": "^4.1.17",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue