feat: mental model history tracking and UI diff view (#516)
* feat: mental model refresh history tracking and UI diff view
- DB migration: add history JSONB column to mental_models table
- Track previous content on each refresh in update_mental_model
- Add get_mental_model_history() engine method
- New GET /mental-models/{id}/history endpoint
- Control plane proxy route and getMentalModelHistory() in api.ts
- MentalModelDetailModal: add History tab with lazy loading, carousel
navigation (left=older, right=newer), word-level content diff view
* fix: resolve alembic migration head conflict for mental model history
* feat: mental model history tracking, side-by-side diff UI, and config flag
- Track content changes on every mental model update/refresh (persisted in JSONB history column)
- New GET /mental-models/{id}/history endpoint returning changes most-recent-first
- Side-by-side diff view in History tab (Before/After columns, line-level highlights)
- Actions dropdown in detail panel (Edit, Refresh, View History, Delete)
- HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY config flag (default: true)
- Also adds missing HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY to configuration docs
- Python client wrapper method get_mental_model_history()
- Tests for history persistence (recorded, ordered, name-only skipped, missing returns None)
- Fix NameError: timezone not imported in update_mental_model
* fix: call get_mental_model_history before delete in doc example
This commit is contained in:
parent
576473b6aa
commit
e2baca8bfe
20 changed files with 1214 additions and 35 deletions
|
|
@ -0,0 +1,30 @@
|
|||
"""Add history column to mental_models
|
||||
|
||||
Revision ID: c3d4e5f6g7h8
|
||||
Revises: a2b3c4d5e6f7, a2b3c4d5e6f8
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "c3d4e5f6g7h8"
|
||||
down_revision: str | Sequence[str] | None = ("a2b3c4d5e6f7", "a2b3c4d5e6f8")
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
|
||||
|
|
@ -2748,6 +2748,41 @@ def _register_routes(app: FastAPI):
|
|||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
|
||||
summary="Get mental model history",
|
||||
description="Get the refresh history of a mental model, showing content changes over time.",
|
||||
operation_id="get_mental_model_history",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
async def api_get_mental_model_history(
|
||||
bank_id: str,
|
||||
mental_model_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get the refresh history of a mental model."""
|
||||
try:
|
||||
data = await app.state.memory.get_mental_model_history(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
|
||||
return data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
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/{mental_model_id}/history: {error_detail}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/mental-models",
|
||||
response_model=CreateMentalModelResponse,
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
|||
)
|
||||
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
|
||||
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
|
||||
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
|
||||
|
||||
# Webhook configuration (global, static - server-level only)
|
||||
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
|
||||
|
|
@ -451,6 +452,7 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
|
|||
# Observations defaults (consolidated knowledge from facts)
|
||||
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
||||
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
|
||||
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
|
||||
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
|
||||
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
|
||||
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
|
||||
|
|
@ -730,6 +732,7 @@ class HindsightConfig:
|
|||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations: bool
|
||||
enable_observation_history: bool
|
||||
enable_mental_model_history: bool
|
||||
consolidation_batch_size: int
|
||||
consolidation_llm_batch_size: int
|
||||
consolidation_max_tokens: int
|
||||
|
|
@ -1186,6 +1189,10 @@ class HindsightConfig:
|
|||
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
|
||||
).lower()
|
||||
== "true",
|
||||
enable_mental_model_history=os.getenv(
|
||||
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
|
||||
).lower()
|
||||
== "true",
|
||||
consolidation_batch_size=int(
|
||||
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
|
||||
),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import logging
|
|||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import asyncpg
|
||||
|
|
@ -5999,6 +5999,39 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
|
||||
return result
|
||||
|
||||
async def get_mental_model_history(
|
||||
self,
|
||||
bank_id: str,
|
||||
mental_model_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict] | None:
|
||||
"""Get the refresh history of a mental model.
|
||||
|
||||
Returns None if the mental model is not found.
|
||||
Returns a list of history entries (most recent first), each with previous_content and changed_at.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT history
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1 AND id = $2
|
||||
""",
|
||||
bank_id,
|
||||
mental_model_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
raw_history = row["history"]
|
||||
if isinstance(raw_history, str):
|
||||
raw_history = json.loads(raw_history)
|
||||
if not raw_history:
|
||||
return []
|
||||
return list(reversed(raw_history))
|
||||
|
||||
async def create_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
|
|
@ -6219,6 +6252,17 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# If content is changing, fetch current content first to record history
|
||||
previous_content: str | None = None
|
||||
if content is not None:
|
||||
current_row = await conn.fetchrow(
|
||||
f"SELECT content FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
|
||||
bank_id,
|
||||
mental_model_id,
|
||||
)
|
||||
if current_row:
|
||||
previous_content = current_row["content"]
|
||||
|
||||
# Build dynamic update
|
||||
updates = []
|
||||
params: list[Any] = [bank_id, mental_model_id]
|
||||
|
|
@ -6234,6 +6278,14 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
params.append(content)
|
||||
param_idx += 1
|
||||
updates.append("last_refreshed_at = NOW()")
|
||||
# Record history entry with the previous content
|
||||
if get_config().enable_mental_model_history:
|
||||
history_entry = json.dumps(
|
||||
[{"previous_content": previous_content, "changed_at": datetime.now(timezone.utc).isoformat()}]
|
||||
)
|
||||
updates.append(f"history = COALESCE(history, '[]'::jsonb) || ${param_idx}::jsonb")
|
||||
params.append(history_entry)
|
||||
param_idx += 1
|
||||
# Also update embedding (convert to string for asyncpg vector type)
|
||||
embedding_text = f"{name or ''} {content}"
|
||||
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ def main():
|
|||
file_delete_after_retain=config.file_delete_after_retain,
|
||||
enable_observations=config.enable_observations,
|
||||
enable_observation_history=config.enable_observation_history,
|
||||
enable_mental_model_history=config.enable_mental_model_history,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
|
|
|
|||
|
|
@ -656,6 +656,113 @@ class TestDirectivesPromptInjection:
|
|||
assert directives_pos < critical_rules_pos
|
||||
|
||||
|
||||
class TestMentalModelHistory:
|
||||
"""Test mental model history persistence."""
|
||||
|
||||
async def test_history_recorded_on_content_update(self, memory: MemoryEngine, request_context):
|
||||
"""Test that updating content records a history entry."""
|
||||
bank_id = f"test-mm-history-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
source_query="What is the test?",
|
||||
content="Original content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# No history yet
|
||||
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
|
||||
assert history == []
|
||||
|
||||
# Update content
|
||||
await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
content="Updated content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
|
||||
assert len(history) == 1
|
||||
assert history[0]["previous_content"] == "Original content"
|
||||
assert "changed_at" in history[0]
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_history_ordered_most_recent_first(self, memory: MemoryEngine, request_context):
|
||||
"""Test that history is returned most recent first."""
|
||||
bank_id = f"test-mm-history-order-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
source_query="What is the test?",
|
||||
content="v1",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
content="v2",
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
content="v3",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
|
||||
assert len(history) == 2
|
||||
# Most recent first: second update recorded "v2" as previous, first recorded "v1"
|
||||
assert history[0]["previous_content"] == "v2"
|
||||
assert history[1]["previous_content"] == "v1"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_history_not_recorded_on_name_only_update(self, memory: MemoryEngine, request_context):
|
||||
"""Test that updating only name does not record history."""
|
||||
bank_id = f"test-mm-history-name-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Original Name",
|
||||
source_query="What is the test?",
|
||||
content="Content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
name="Updated Name",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
|
||||
assert history == []
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_history_returns_none_for_missing_model(self, memory: MemoryEngine, request_context):
|
||||
"""Test that history returns None when mental model doesn't exist."""
|
||||
bank_id = f"test-mm-history-missing-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.get_mental_model_history(
|
||||
bank_id, "nonexistent-id", request_context=request_context
|
||||
)
|
||||
assert result is None
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestMentalModelRefreshTagSecurity:
|
||||
"""Test that mental model refresh respects tag-based security boundaries."""
|
||||
|
||||
|
|
|
|||
|
|
@ -884,6 +884,51 @@ paths:
|
|||
summary: Update mental model
|
||||
tags:
|
||||
- Mental Models
|
||||
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history:
|
||||
get:
|
||||
description: "Get the refresh history of a mental model, showing content changes\
|
||||
\ over time."
|
||||
operationId: get_mental_model_history
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: mental_model_id
|
||||
required: true
|
||||
schema:
|
||||
title: Mental Model Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Get mental model history
|
||||
tags:
|
||||
- Mental Models
|
||||
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh:
|
||||
post:
|
||||
description: Submit an async task to re-run the source query through reflect
|
||||
|
|
|
|||
|
|
@ -409,6 +409,132 @@ func (a *MentalModelsAPIService) GetMentalModelExecute(r ApiGetMentalModelReques
|
|||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiGetMentalModelHistoryRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *MentalModelsAPIService
|
||||
bankId string
|
||||
mentalModelId string
|
||||
authorization *string
|
||||
}
|
||||
|
||||
func (r ApiGetMentalModelHistoryRequest) Authorization(authorization string) ApiGetMentalModelHistoryRequest {
|
||||
r.authorization = &authorization
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiGetMentalModelHistoryRequest) Execute() (interface{}, *http.Response, error) {
|
||||
return r.ApiService.GetMentalModelHistoryExecute(r)
|
||||
}
|
||||
|
||||
/*
|
||||
GetMentalModelHistory Get mental model history
|
||||
|
||||
Get the refresh history of a mental model, showing content changes over time.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@param mentalModelId
|
||||
@return ApiGetMentalModelHistoryRequest
|
||||
*/
|
||||
func (a *MentalModelsAPIService) GetMentalModelHistory(ctx context.Context, bankId string, mentalModelId string) ApiGetMentalModelHistoryRequest {
|
||||
return ApiGetMentalModelHistoryRequest{
|
||||
ApiService: a,
|
||||
ctx: ctx,
|
||||
bankId: bankId,
|
||||
mentalModelId: mentalModelId,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the request
|
||||
// @return interface{}
|
||||
func (a *MentalModelsAPIService) GetMentalModelHistoryExecute(r ApiGetMentalModelHistoryRequest) (interface{}, *http.Response, error) {
|
||||
var (
|
||||
localVarHTTPMethod = http.MethodGet
|
||||
localVarPostBody interface{}
|
||||
formFiles []formFile
|
||||
localVarReturnValue interface{}
|
||||
)
|
||||
|
||||
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MentalModelsAPIService.GetMentalModelHistory")
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
|
||||
}
|
||||
|
||||
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history"
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"mental_model_id"+"}", url.PathEscape(parameterValueToString(r.mentalModelId, "mentalModelId")), -1)
|
||||
|
||||
localVarHeaderParams := make(map[string]string)
|
||||
localVarQueryParams := url.Values{}
|
||||
localVarFormParams := url.Values{}
|
||||
|
||||
// to determine the Content-Type header
|
||||
localVarHTTPContentTypes := []string{}
|
||||
|
||||
// set Content-Type header
|
||||
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
|
||||
if localVarHTTPContentType != "" {
|
||||
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
|
||||
}
|
||||
|
||||
// to determine the Accept header
|
||||
localVarHTTPHeaderAccepts := []string{"application/json"}
|
||||
|
||||
// set Accept header
|
||||
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
|
||||
if localVarHTTPHeaderAccept != "" {
|
||||
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
|
||||
}
|
||||
if r.authorization != nil {
|
||||
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
|
||||
}
|
||||
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, err
|
||||
}
|
||||
|
||||
localVarHTTPResponse, err := a.client.callAPI(req)
|
||||
if err != nil || localVarHTTPResponse == nil {
|
||||
return localVarReturnValue, localVarHTTPResponse, err
|
||||
}
|
||||
|
||||
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
|
||||
localVarHTTPResponse.Body.Close()
|
||||
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
|
||||
if err != nil {
|
||||
return localVarReturnValue, localVarHTTPResponse, err
|
||||
}
|
||||
|
||||
if localVarHTTPResponse.StatusCode >= 300 {
|
||||
newErr := &GenericOpenAPIError{
|
||||
body: localVarBody,
|
||||
error: localVarHTTPResponse.Status,
|
||||
}
|
||||
if localVarHTTPResponse.StatusCode == 422 {
|
||||
var v HTTPValidationError
|
||||
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
newErr.error = err.Error()
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
|
||||
newErr.model = v
|
||||
}
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
|
||||
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
newErr := &GenericOpenAPIError{
|
||||
body: localVarBody,
|
||||
error: err.Error(),
|
||||
}
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
|
||||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiListMentalModelsRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *MentalModelsAPIService
|
||||
|
|
|
|||
|
|
@ -913,6 +913,19 @@ class Hindsight:
|
|||
"""
|
||||
return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id, _request_timeout=self._timeout))
|
||||
|
||||
def get_mental_model_history(self, bank_id: str, mental_model_id: str):
|
||||
"""
|
||||
Get the content change history of a mental model.
|
||||
|
||||
Returns a list of history entries (most recent first), each with
|
||||
``previous_content`` and ``changed_at`` fields.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
mental_model_id: The mental model ID
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model_history(bank_id, mental_model_id, _request_timeout=self._timeout))
|
||||
|
||||
# Directives methods
|
||||
|
||||
def create_directive(
|
||||
|
|
|
|||
|
|
@ -936,6 +936,299 @@ class MentalModelsApi:
|
|||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_mental_model_history(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
mental_model_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> object:
|
||||
"""Get mental model history
|
||||
|
||||
Get the refresh history of a mental model, showing content changes over time.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param mental_model_id: (required)
|
||||
:type mental_model_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_mental_model_history_serialize(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "object",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
).data
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_mental_model_history_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
mental_model_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> ApiResponse[object]:
|
||||
"""Get mental model history
|
||||
|
||||
Get the refresh history of a mental model, showing content changes over time.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param mental_model_id: (required)
|
||||
:type mental_model_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_mental_model_history_serialize(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "object",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
)
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_mental_model_history_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
mental_model_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> RESTResponseType:
|
||||
"""Get mental model history
|
||||
|
||||
Get the refresh history of a mental model, showing content changes over time.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param mental_model_id: (required)
|
||||
:type mental_model_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_mental_model_history_serialize(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "object",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
return response_data.response
|
||||
|
||||
|
||||
def _get_mental_model_history_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
mental_model_id,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
_headers,
|
||||
_host_index,
|
||||
) -> RequestSerialized:
|
||||
|
||||
_host = None
|
||||
|
||||
_collection_formats: Dict[str, str] = {
|
||||
}
|
||||
|
||||
_path_params: Dict[str, str] = {}
|
||||
_query_params: List[Tuple[str, str]] = []
|
||||
_header_params: Dict[str, Optional[str]] = _headers or {}
|
||||
_form_params: List[Tuple[str, str]] = []
|
||||
_files: Dict[
|
||||
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
|
||||
] = {}
|
||||
_body_params: Optional[bytes] = None
|
||||
|
||||
# process the path parameters
|
||||
if bank_id is not None:
|
||||
_path_params['bank_id'] = bank_id
|
||||
if mental_model_id is not None:
|
||||
_path_params['mental_model_id'] = mental_model_id
|
||||
# process the query parameters
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
# process the form parameters
|
||||
# process the body parameter
|
||||
|
||||
|
||||
# set the HTTP header `Accept`
|
||||
if 'Accept' not in _header_params:
|
||||
_header_params['Accept'] = self.api_client.select_header_accept(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# authentication setting
|
||||
_auth_settings: List[str] = [
|
||||
]
|
||||
|
||||
return self.api_client.param_serialize(
|
||||
method='GET',
|
||||
resource_path='/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history',
|
||||
path_params=_path_params,
|
||||
query_params=_query_params,
|
||||
header_params=_header_params,
|
||||
body=_body_params,
|
||||
post_params=_form_params,
|
||||
files=_files,
|
||||
auth_settings=_auth_settings,
|
||||
collection_formats=_collection_formats,
|
||||
_host=_host,
|
||||
_request_auth=_request_auth
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def list_mental_models(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ import type {
|
|||
GetMemoryResponses,
|
||||
GetMentalModelData,
|
||||
GetMentalModelErrors,
|
||||
GetMentalModelHistoryData,
|
||||
GetMentalModelHistoryErrors,
|
||||
GetMentalModelHistoryResponses,
|
||||
GetMentalModelResponses,
|
||||
GetObservationHistoryData,
|
||||
GetObservationHistoryErrors,
|
||||
|
|
@ -503,6 +506,23 @@ export const updateMentalModel = <ThrowOnError extends boolean = false>(
|
|||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get mental model history
|
||||
*
|
||||
* Get the refresh history of a mental model, showing content changes over time.
|
||||
*/
|
||||
export const getMentalModelHistory = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetMentalModelHistoryData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetMentalModelHistoryResponses,
|
||||
GetMentalModelHistoryErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh mental model
|
||||
*
|
||||
|
|
|
|||
|
|
@ -3082,6 +3082,45 @@ export type UpdateMentalModelResponses = {
|
|||
export type UpdateMentalModelResponse =
|
||||
UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
|
||||
|
||||
export type GetMentalModelHistoryData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Mental Model Id
|
||||
*/
|
||||
mental_model_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history";
|
||||
};
|
||||
|
||||
export type GetMentalModelHistoryErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetMentalModelHistoryError =
|
||||
GetMentalModelHistoryErrors[keyof GetMentalModelHistoryErrors];
|
||||
|
||||
export type GetMentalModelHistoryResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type RefreshMentalModelData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId, mentalModelId } = await params;
|
||||
|
||||
if (!bankId || !mentalModelId) {
|
||||
return NextResponse.json(
|
||||
{ error: "bank_id and mental_model_id are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}/history`,
|
||||
{ method: "GET", headers: getDataplaneHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json({ error: "Mental model not found" }, { status: 404 });
|
||||
}
|
||||
throw new Error(`API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching mental model history:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch mental model history" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import { useState, useEffect } from "react";
|
|||
import { client, MentalModel } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { Loader2, Zap } from "lucide-react";
|
||||
import { Loader2, Zap, FileText, History, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
|
|
@ -100,20 +102,197 @@ export function MentalModelDetailContent({ mentalModel }: MentalModelDetailConte
|
|||
);
|
||||
}
|
||||
|
||||
type HistoryEntry = { previous_content: string | null; changed_at: string };
|
||||
|
||||
type LineDiff = { type: "same" | "removed" | "added"; text: string };
|
||||
|
||||
function diffLines(a: string, b: string): { left: LineDiff[]; right: LineDiff[] } {
|
||||
const aLines = a.split("\n");
|
||||
const bLines = b.split("\n");
|
||||
const m = aLines.length;
|
||||
const n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++)
|
||||
for (let j = 1; j <= n; j++)
|
||||
dp[i][j] =
|
||||
aLines[i - 1] === bLines[j - 1]
|
||||
? dp[i - 1][j - 1] + 1
|
||||
: Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
|
||||
const ops: LineDiff[] = [];
|
||||
let i = m,
|
||||
j = n;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
|
||||
ops.push({ type: "same", text: aLines[i - 1] });
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.push({ type: "added", text: bLines[j - 1] });
|
||||
j--;
|
||||
} else {
|
||||
ops.push({ type: "removed", text: aLines[i - 1] });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
ops.reverse();
|
||||
|
||||
// Pair removed/added lines side-by-side; same lines appear on both sides
|
||||
const left: LineDiff[] = [];
|
||||
const right: LineDiff[] = [];
|
||||
let k = 0;
|
||||
while (k < ops.length) {
|
||||
const op = ops[k];
|
||||
if (op.type === "same") {
|
||||
left.push(op);
|
||||
right.push(op);
|
||||
k++;
|
||||
} else {
|
||||
// collect a block of removed/added and align them
|
||||
const removed: string[] = [];
|
||||
const added: string[] = [];
|
||||
while (k < ops.length && ops[k].type !== "same") {
|
||||
if (ops[k].type === "removed") removed.push(ops[k].text);
|
||||
else added.push(ops[k].text);
|
||||
k++;
|
||||
}
|
||||
const maxLen = Math.max(removed.length, added.length);
|
||||
for (let r = 0; r < maxLen; r++) {
|
||||
left.push(
|
||||
r < removed.length ? { type: "removed", text: removed[r] } : { type: "same", text: "" }
|
||||
);
|
||||
right.push(
|
||||
r < added.length ? { type: "added", text: added[r] } : { type: "same", text: "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
function SideBySideDiff({ before, after }: { before: string; after: string }) {
|
||||
const { left, right } = diffLines(before, after);
|
||||
const hasChanges = left.some((l) => l.type !== "same") || right.some((r) => r.type !== "same");
|
||||
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 divide-x divide-border border border-border rounded-md overflow-hidden text-xs font-mono">
|
||||
<div>
|
||||
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
|
||||
Before
|
||||
</div>
|
||||
{left.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
|
||||
line.type === "removed"
|
||||
? "bg-red-500/10 text-red-700 dark:text-red-400"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
|
||||
After
|
||||
</div>
|
||||
{right.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
|
||||
line.type === "added"
|
||||
? "bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MentalModelHistoryView({
|
||||
history,
|
||||
currentContent,
|
||||
}: {
|
||||
history: HistoryEntry[];
|
||||
currentContent: string;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const entry = history[idx];
|
||||
const afterContent = idx === 0 ? currentContent : (history[idx - 1].previous_content ?? "");
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Navigation header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Change <span className="font-semibold text-foreground">{history.length - idx}</span> of{" "}
|
||||
{history.length} · {new Date(entry.changed_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={idx === history.length - 1}
|
||||
onClick={() => setIdx(idx + 1)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={idx === 0}
|
||||
onClick={() => setIdx(idx - 1)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change card */}
|
||||
{entry.previous_content !== null ? (
|
||||
<SideBySideDiff before={entry.previous_content} after={afterContent} />
|
||||
) : (
|
||||
<div className="border border-border rounded-lg p-3">
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
Previous content not available
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MentalModelDetailModalProps {
|
||||
mentalModelId: string | null;
|
||||
onClose: () => void;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal wrapper for MentalModelDetailContent.
|
||||
* Fetches the mental model by ID and displays it in a dialog.
|
||||
*/
|
||||
export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDetailModalProps) {
|
||||
export function MentalModelDetailModal({
|
||||
mentalModelId,
|
||||
onClose,
|
||||
initialTab,
|
||||
}: MentalModelDetailModalProps) {
|
||||
const { currentBank } = useBank();
|
||||
const [mentalModel, setMentalModel] = useState<MentalModel | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(initialTab ?? "model");
|
||||
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentalModelId || !currentBank) return;
|
||||
|
|
@ -122,6 +301,8 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
setMentalModel(null);
|
||||
setHistory(null);
|
||||
setActiveTab(initialTab ?? "model");
|
||||
|
||||
try {
|
||||
const data = await client.getMentalModel(currentBank, mentalModelId);
|
||||
|
|
@ -137,6 +318,26 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
|
|||
loadMentalModel();
|
||||
}, [mentalModelId, currentBank]);
|
||||
|
||||
// Load history lazily when history tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab !== "history" || !mentalModel || !currentBank || history !== null) return;
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const data = await client.getMentalModelHistory(currentBank, mentalModel.id);
|
||||
setHistory(data);
|
||||
} catch (err) {
|
||||
console.error("Error loading mental model history:", err);
|
||||
setHistory([]);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
}, [activeTab, mentalModel, currentBank, history]);
|
||||
|
||||
const isOpen = mentalModelId !== null;
|
||||
|
||||
return (
|
||||
|
|
@ -156,9 +357,41 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
|
|||
</div>
|
||||
</div>
|
||||
) : mentalModel ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MentalModelDetailContent mentalModel={mentalModel} />
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="model" className="flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Mental Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center gap-1.5">
|
||||
<History className="w-3.5 h-3.5" />
|
||||
History
|
||||
{history && history.length > 0 ? ` (${history.length})` : ""}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto mt-4">
|
||||
<TabsContent value="model" className="mt-0">
|
||||
<MentalModelDetailContent mentalModel={mentalModel} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="mt-0">
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : history && history.length > 0 ? (
|
||||
<MentalModelHistoryView history={history} currentContent={mentalModel.content} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No history recorded yet.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -50,9 +50,19 @@ import {
|
|||
Pencil,
|
||||
LayoutGrid,
|
||||
List,
|
||||
History,
|
||||
MoreVertical,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { DirectiveDetailModal } from "./directive-detail-modal";
|
||||
import { MentalModelDetailModal } from "./mental-model-detail-modal";
|
||||
|
||||
interface ReflectResponseBasedOnFact {
|
||||
id: string;
|
||||
|
|
@ -927,6 +937,7 @@ function MentalModelDetailPanel({
|
|||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [viewMemoryId, setViewMemoryId] = useState<string | null>(null);
|
||||
const [viewDirectiveId, setViewDirectiveId] = useState<string | null>(null);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentBank) return;
|
||||
|
|
@ -1031,27 +1042,44 @@ function MentalModelDetailPanel({
|
|||
<div className="flex-1 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">{mentalModel.name}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit} className="h-7 w-7 p-0">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{mentalModel.source_query}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="h-8"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 gap-1" disabled={refreshing}>
|
||||
{refreshing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowHistoryModal(true)}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
View History
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1222,18 +1250,6 @@ function MentalModelDetailPanel({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:text-destructive hover:border-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1249,6 +1265,13 @@ function MentalModelDetailPanel({
|
|||
onClose={() => setViewDirectiveId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mental Model History Modal */}
|
||||
<MentalModelDetailModal
|
||||
mentalModelId={showHistoryModal ? mentalModel.id : null}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
initialTab="history"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -813,6 +813,18 @@ export class ControlPlaneClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the refresh history of a mental model
|
||||
*/
|
||||
async getMentalModelHistory(bankId: string, mentalModelId: string) {
|
||||
return this.fetchApi<
|
||||
{
|
||||
previous_content: string | null;
|
||||
changed_at: string;
|
||||
}[]
|
||||
>(`/api/banks/${bankId}/mental-models/${mentalModelId}/history`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API version and feature flags
|
||||
* Use this to check which capabilities are available in the dataplane
|
||||
|
|
|
|||
|
|
@ -272,6 +272,33 @@ For more details on tag matching modes (`any`, `any_strict`, `all`, `all_strict`
|
|||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
Every time a mental model's content changes (via refresh or manual update), the previous version is saved with a timestamp. You can retrieve the full change log with the history endpoint:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
The endpoint returns a list of history entries, most recent first:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `previous_content` | string \| null | The content before this change (`null` if not available) |
|
||||
| `changed_at` | string | ISO 8601 timestamp of when the change occurred |
|
||||
|
||||
Each entry captures the **content before the change** and when it happened. The current content is returned by the standard [Get a Mental Model](#get-a-mental-model) endpoint.
|
||||
|
||||
:::note
|
||||
History tracking is enabled by default. Set `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY=false` to disable it.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
| Use Case | Example |
|
||||
|
|
|
|||
|
|
@ -531,6 +531,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
|
|||
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
|
||||
| `HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS` | Fan-out limit per node in MPFP graph traversal | `20` |
|
||||
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
|
||||
| `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
|
||||
|
||||
#### Graph Retrieval Algorithms
|
||||
|
||||
|
|
@ -762,6 +763,7 @@ Observations are consolidated knowledge synthesized from facts.
|
|||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
|
||||
| `HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY` | Track history of changes to each observation (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Configurable per bank. | `8` |
|
||||
|
|
|
|||
|
|
@ -110,6 +110,18 @@ if mental_model_id:
|
|||
# [/docs:update-mental-model]
|
||||
|
||||
|
||||
# [docs:get-mental-model-history]
|
||||
# Get the change history of a mental model
|
||||
history = client.get_mental_model_history(
|
||||
bank_id=BANK_ID,
|
||||
mental_model_id=mental_model_id
|
||||
)
|
||||
|
||||
for entry in history:
|
||||
print(f"Changed at: {entry['changed_at']}")
|
||||
print(f"Previous content: {entry['previous_content']}")
|
||||
# [/docs:get-mental-model-history]
|
||||
|
||||
# [docs:delete-mental-model]
|
||||
# Delete a mental model
|
||||
client.delete_mental_model(
|
||||
|
|
|
|||
|
|
@ -1310,6 +1310,72 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Mental Models"
|
||||
],
|
||||
"summary": "Get mental model history",
|
||||
"description": "Get the refresh history of a mental model, showing content changes over time.",
|
||||
"operationId": "get_mental_model_history",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mental_model_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Mental 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/{mental_model_id}/refresh": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue