From e2baca8bfe03ad450f255b63ff47117b64951fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 6 Mar 2026 17:50:48 +0100 Subject: [PATCH] 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 --- ...d4e5f6g7h8_add_history_to_mental_models.py | 30 ++ hindsight-api/hindsight_api/api/http.py | 35 +++ hindsight-api/hindsight_api/config.py | 7 + .../hindsight_api/engine/memory_engine.py | 54 +++- hindsight-api/hindsight_api/main.py | 1 + hindsight-api/tests/test_mental_models.py | 107 +++++++ hindsight-clients/go/api/openapi.yaml | 45 +++ hindsight-clients/go/api_mental_models.go | 126 ++++++++ .../hindsight_client/hindsight_client.py | 13 + .../api/mental_models_api.py | 293 ++++++++++++++++++ .../typescript/generated/sdk.gen.ts | 20 ++ .../typescript/generated/types.gen.ts | 39 +++ .../[mentalModelId]/history/route.ts | 36 +++ .../components/mental-model-detail-modal.tsx | 243 ++++++++++++++- .../src/components/mental-models-view.tsx | 81 +++-- hindsight-control-plane/src/lib/api.ts | 12 + .../docs/developer/api/mental-models.mdx | 27 ++ .../docs/developer/configuration.md | 2 + hindsight-docs/examples/api/mental-models.py | 12 + hindsight-docs/static/openapi.json | 66 ++++ 20 files changed, 1214 insertions(+), 35 deletions(-) create mode 100644 hindsight-api/hindsight_api/alembic/versions/c3d4e5f6g7h8_add_history_to_mental_models.py create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts diff --git a/hindsight-api/hindsight_api/alembic/versions/c3d4e5f6g7h8_add_history_to_mental_models.py b/hindsight-api/hindsight_api/alembic/versions/c3d4e5f6g7h8_add_history_to_mental_models.py new file mode 100644 index 00000000..78834981 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/c3d4e5f6g7h8_add_history_to_mental_models.py @@ -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") diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index 6704e29f..5de04c82 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index a104ae30..fb6b243a 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -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)) ), diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 21ee90de..bc47a4b3 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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]) diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index dbd41501..8151c4f7 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -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, diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py index ab6d983e..7ddde78e 100644 --- a/hindsight-api/tests/test_mental_models.py +++ b/hindsight-api/tests/test_mental_models.py @@ -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.""" diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index cc49746f..977985db 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -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 diff --git a/hindsight-clients/go/api_mental_models.go b/hindsight-clients/go/api_mental_models.go index 170054d6..2e3f6f54 100644 --- a/hindsight-clients/go/api_mental_models.go +++ b/hindsight-clients/go/api_mental_models.go @@ -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 diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 4039f166..9ff7828a 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -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( diff --git a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py index de647d20..99d4e10a 100644 --- a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py @@ -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, diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index eb7ecc14..1b153eef 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -82,6 +82,9 @@ import type { GetMemoryResponses, GetMentalModelData, GetMentalModelErrors, + GetMentalModelHistoryData, + GetMentalModelHistoryErrors, + GetMentalModelHistoryResponses, GetMentalModelResponses, GetObservationHistoryData, GetObservationHistoryErrors, @@ -503,6 +506,23 @@ export const updateMentalModel = ( }, }); +/** + * Get mental model history + * + * Get the refresh history of a mental model, showing content changes over time. + */ +export const getMentalModelHistory = ( + options: Options, +) => + (options.client ?? client).get< + GetMentalModelHistoryResponses, + GetMentalModelHistoryErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history", + ...options, + }); + /** * Refresh mental model * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index ca9f63de..b6723f7c 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -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?: { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts new file mode 100644 index 00000000..566340bd --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts @@ -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 }); + } +} diff --git a/hindsight-control-plane/src/components/mental-model-detail-modal.tsx b/hindsight-control-plane/src/components/mental-model-detail-modal.tsx index 45b3ae96..5c185f2c 100644 --- a/hindsight-control-plane/src/components/mental-model-detail-modal.tsx +++ b/hindsight-control-plane/src/components/mental-model-detail-modal.tsx @@ -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 unchanged; + + return ( +
+
+
+ Before +
+ {left.map((line, idx) => ( +
+ {line.text} +
+ ))} +
+
+
+ After +
+ {right.map((line, idx) => ( +
+ {line.text} +
+ ))} +
+
+ ); +} + +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 ( +
+ {/* Navigation header */} +
+ + Change {history.length - idx} of{" "} + {history.length} · {new Date(entry.changed_at).toLocaleString()} + +
+ + +
+
+ + {/* Change card */} + {entry.previous_content !== null ? ( + + ) : ( +
+ + Previous content not available + +
+ )} +
+ ); +} + 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState(initialTab ?? "model"); + + const [history, setHistory] = useState(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 ) : mentalModel ? ( -
- -
+ + + + + Mental Model + + + + History + {history && history.length > 0 ? ` (${history.length})` : ""} + + + +
+ + + + + + {loadingHistory ? ( +
+ +
+ ) : history && history.length > 0 ? ( + + ) : ( +

No history recorded yet.

+ )} +
+
+
) : null} diff --git a/hindsight-control-plane/src/components/mental-models-view.tsx b/hindsight-control-plane/src/components/mental-models-view.tsx index d20260ed..06779abe 100644 --- a/hindsight-control-plane/src/components/mental-models-view.tsx +++ b/hindsight-control-plane/src/components/mental-models-view.tsx @@ -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(null); const [viewDirectiveId, setViewDirectiveId] = useState(null); + const [showHistoryModal, setShowHistoryModal] = useState(false); const handleRefresh = async () => { if (!currentBank) return; @@ -1031,27 +1042,44 @@ function MentalModelDetailPanel({

{mentalModel.name}

-

{mentalModel.source_query}

- + + + + + + + + Edit + + + + Refresh + + setShowHistoryModal(true)}> + + View History + + + + + Delete + + + @@ -1222,18 +1250,6 @@ function MentalModelDetailPanel({

)} - -
- -
@@ -1249,6 +1265,13 @@ function MentalModelDetailPanel({ onClose={() => setViewDirectiveId(null)} /> )} + + {/* Mental Model History Modal */} + setShowHistoryModal(false)} + initialTab="history" + /> ); } diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 2824c471..2dac161a 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -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 diff --git a/hindsight-docs/docs/developer/api/mental-models.mdx b/hindsight-docs/docs/developer/api/mental-models.mdx index 8873ac54..4a0b3ca2 100644 --- a/hindsight-docs/docs/developer/api/mental-models.mdx +++ b/hindsight-docs/docs/developer/api/mental-models.mdx @@ -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: + + + + + + + +### 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 | diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index e846ab76..eb3d23e6 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -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` | diff --git a/hindsight-docs/examples/api/mental-models.py b/hindsight-docs/examples/api/mental-models.py index ab1a8476..4f850563 100644 --- a/hindsight-docs/examples/api/mental-models.py +++ b/hindsight-docs/examples/api/mental-models.py @@ -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( diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index cfbe26a6..013be0ba 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -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": [