Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c94e99041f | ||
|
|
fa3501d448 | ||
|
|
f88b50a45e | ||
|
|
1b4ad7f435 | ||
|
|
d2504ac5ed | ||
|
|
d9d7021a49 | ||
|
|
e2baca8bfe | ||
|
|
576473b6aa | ||
|
|
99220d0527 | ||
|
|
8540c33236 | ||
|
|
5d05962db0 | ||
|
|
1d17dea2f1 | ||
|
|
928dc696e8 |
+30
@@ -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")
|
||||
@@ -71,9 +71,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
||||
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
|
||||
from hindsight_api.engine.search.tags import TagsMatch
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
@@ -100,7 +98,12 @@ class ChunkIncludeOptions(BaseModel):
|
||||
class SourceFactsIncludeOptions(BaseModel):
|
||||
"""Options for including source facts for observation-type results."""
|
||||
|
||||
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
|
||||
max_tokens: int = Field(
|
||||
default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)"
|
||||
)
|
||||
max_tokens_per_observation: int = Field(
|
||||
default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)"
|
||||
)
|
||||
|
||||
|
||||
class IncludeOptions(BaseModel):
|
||||
@@ -474,6 +477,11 @@ class FileRetainMetadata(BaseModel):
|
||||
metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata")
|
||||
tags: list[str] | None = Field(default=None, description="Tags for this file")
|
||||
timestamp: str | None = Field(default=None, description="ISO timestamp")
|
||||
parser: str | list[str] | None = Field(
|
||||
default=None,
|
||||
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
|
||||
"E.g. 'iris' or ['iris', 'markitdown'].",
|
||||
)
|
||||
|
||||
|
||||
class FileRetainRequest(BaseModel):
|
||||
@@ -482,14 +490,21 @@ class FileRetainRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"parser": "iris",
|
||||
"files_metadata": [
|
||||
{"document_id": "report_2024", "tags": ["quarterly"]},
|
||||
{"context": "meeting notes"},
|
||||
{"context": "meeting notes", "parser": ["iris", "markitdown"]},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
parser: str | list[str] | None = Field(
|
||||
default=None,
|
||||
description="Default parser or ordered fallback chain for all files in this request. "
|
||||
"E.g. 'markitdown' or ['iris', 'markitdown']. Falls back to server default if not set. "
|
||||
"Per-file 'parser' in files_metadata takes precedence over this value.",
|
||||
)
|
||||
files_metadata: list[FileRetainMetadata] | None = Field(
|
||||
default=None,
|
||||
description="Metadata for each file (optional, must match number of files if provided)",
|
||||
@@ -759,14 +774,6 @@ class ReflectResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BanksResponse(BaseModel):
|
||||
"""Response model for banks list endpoint."""
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={"example": {"banks": ["user123", "bank_alice", "bank_bob"]}})
|
||||
|
||||
banks: list[str]
|
||||
|
||||
|
||||
class DispositionTraits(BaseModel):
|
||||
"""Disposition traits that influence how memories are formed and interpreted."""
|
||||
|
||||
@@ -1199,6 +1206,30 @@ class DocumentResponse(BaseModel):
|
||||
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
|
||||
|
||||
|
||||
class UpdateDocumentRequest(BaseModel):
|
||||
"""Request model for updating a document's mutable fields."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"tags": ["team-a", "team-b"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
tags: list[str] | None = Field(
|
||||
default=None,
|
||||
description="New tags for the document and its memory units. "
|
||||
"Triggers observation invalidation and re-consolidation.",
|
||||
)
|
||||
|
||||
|
||||
class UpdateDocumentResponse(BaseModel):
|
||||
"""Response model for update document endpoint."""
|
||||
|
||||
success: bool = True
|
||||
|
||||
|
||||
class DeleteDocumentResponse(BaseModel):
|
||||
"""Response model for delete document endpoint."""
|
||||
|
||||
@@ -1305,15 +1336,6 @@ class BankStatsResponse(BaseModel):
|
||||
# Mental Model models
|
||||
|
||||
|
||||
class ObservationEvidenceResponse(BaseModel):
|
||||
"""A single piece of evidence supporting an observation."""
|
||||
|
||||
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
|
||||
quote: str = Field(description="Exact quote from the memory supporting the observation")
|
||||
relevance: str = Field(description="Brief explanation of how this quote supports the observation")
|
||||
timestamp: str = Field(description="When the source memory was created (ISO format)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Directive Models
|
||||
# =========================================================================
|
||||
@@ -2139,7 +2161,7 @@ def _register_routes(app: FastAPI):
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}",
|
||||
summary="Get memory unit",
|
||||
description="Get a single memory unit by ID with all its metadata including entities and tags.",
|
||||
description="Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
|
||||
operation_id="get_memory",
|
||||
tags=["Memory"],
|
||||
)
|
||||
@@ -2169,6 +2191,39 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}/history",
|
||||
summary="Get observation history",
|
||||
description="Get the full history of an observation, with each change's source facts resolved to their text.",
|
||||
operation_id="get_observation_history",
|
||||
tags=["Memory"],
|
||||
)
|
||||
async def api_get_observation_history(
|
||||
bank_id: str,
|
||||
memory_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get the history of a single observation by ID."""
|
||||
try:
|
||||
data = await app.state.memory.get_observation_history(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found")
|
||||
return data
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}/history: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/memories/recall",
|
||||
response_model=RecallResponse,
|
||||
@@ -2224,6 +2279,9 @@ def _register_routes(app: FastAPI):
|
||||
# Determine source facts inclusion settings
|
||||
include_source_facts = request.include.source_facts is not None
|
||||
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
|
||||
max_source_facts_tokens_per_observation = (
|
||||
request.include.source_facts.max_tokens_per_observation if include_source_facts else -1
|
||||
)
|
||||
|
||||
pre_recall = time.time() - handler_start
|
||||
# Run recall with tracing (record metrics)
|
||||
@@ -2245,6 +2303,7 @@ def _register_routes(app: FastAPI):
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
@@ -2713,6 +2772,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,
|
||||
@@ -3234,6 +3328,55 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.patch(
|
||||
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
|
||||
response_model=UpdateDocumentResponse,
|
||||
summary="Update document",
|
||||
description="Update mutable fields on a document without re-processing its content.\n\n"
|
||||
"**Tags** (`tags`): Propagated to all associated memory units. Observations derived from "
|
||||
"those units are invalidated and queued for re-consolidation under the new tags. "
|
||||
"Co-source memories from other documents that shared those observations are also reset.\n\n"
|
||||
"At least one field must be provided.",
|
||||
operation_id="update_document",
|
||||
tags=["Documents"],
|
||||
)
|
||||
async def api_update_document(
|
||||
bank_id: str,
|
||||
document_id: str,
|
||||
body: UpdateDocumentRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""
|
||||
Update mutable fields on a document without re-processing its content.
|
||||
|
||||
Args:
|
||||
bank_id: Memory Bank ID (from path)
|
||||
document_id: Document ID (from path)
|
||||
body: Fields to update (tags, metadata, context)
|
||||
"""
|
||||
if body.tags is None:
|
||||
raise HTTPException(status_code=422, detail="At least one field (tags) must be provided")
|
||||
try:
|
||||
result = await app.state.memory.update_document(
|
||||
document_id,
|
||||
bank_id,
|
||||
tags=body.tags,
|
||||
request_context=request_context,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return UpdateDocumentResponse(success=True)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete(
|
||||
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
|
||||
response_model=DeleteDocumentResponse,
|
||||
@@ -4331,8 +4474,14 @@ def _register_routes(app: FastAPI):
|
||||
"Use the operations endpoint to monitor progress.\n\n"
|
||||
"**Request format:** multipart/form-data with:\n"
|
||||
"- `files`: One or more files to upload\n"
|
||||
"- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n"
|
||||
"**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
|
||||
"- `request`: JSON string with FileRetainRequest model\n\n"
|
||||
"**Parser selection:**\n"
|
||||
"- Set `parser` in the request body to override the server default for all files.\n"
|
||||
"- Set `parser` inside a `files_metadata` entry for per-file control.\n"
|
||||
"- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — "
|
||||
"each parser is tried in sequence until one succeeds.\n"
|
||||
"- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.\n"
|
||||
"- Only parsers enabled on the server may be requested; others return HTTP 400.",
|
||||
operation_id="file_retain",
|
||||
tags=["Files"],
|
||||
)
|
||||
@@ -4378,20 +4527,39 @@ def _register_routes(app: FastAPI):
|
||||
detail=f"files_metadata count ({len(request_data.files_metadata)}) must match files count ({len(files)})",
|
||||
)
|
||||
|
||||
# Resolve the registered parser names for allowlist validation
|
||||
registered_parsers = app.state.memory._parser_registry.list_parsers()
|
||||
allowlist = config.file_parser_allowlist if config.file_parser_allowlist is not None else registered_parsers
|
||||
|
||||
def _resolve_parser(raw: str | list[str] | None) -> list[str]:
|
||||
"""Normalize parser value to a non-empty list of names."""
|
||||
if raw is None:
|
||||
return config.file_parser
|
||||
return [raw] if isinstance(raw, str) else list(raw)
|
||||
|
||||
def _validate_parsers(parsers: list[str], context: str) -> None:
|
||||
"""Raise HTTP 400 if any parser name is not in the allowlist."""
|
||||
disallowed = [p for p in parsers if p not in allowlist]
|
||||
if disallowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Parser(s) not available ({context}): {disallowed}. Available: {allowlist}",
|
||||
)
|
||||
|
||||
# Validate request-level parser early (before reading files)
|
||||
if request_data.parser is not None:
|
||||
_validate_parsers(_resolve_parser(request_data.parser), "request-level parser")
|
||||
|
||||
# Prepare file items and calculate total batch size
|
||||
import io
|
||||
|
||||
file_items = []
|
||||
total_batch_size = 0
|
||||
|
||||
for i, file in enumerate(files):
|
||||
# Read file content to check size
|
||||
file_content = await file.read()
|
||||
size = len(file_content)
|
||||
total_batch_size += size
|
||||
|
||||
# Create a temporary file-like object from the bytes
|
||||
import io
|
||||
|
||||
file_obj = io.BytesIO(file_content)
|
||||
total_batch_size += len(file_content)
|
||||
|
||||
# Create a mock UploadFile with the necessary attributes
|
||||
class FileWrapper:
|
||||
@@ -4399,7 +4567,6 @@ def _register_routes(app: FastAPI):
|
||||
self._content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self._buffer = io.BytesIO(content)
|
||||
|
||||
async def read(self):
|
||||
return self._content
|
||||
@@ -4410,6 +4577,12 @@ def _register_routes(app: FastAPI):
|
||||
file_meta = request_data.files_metadata[i] if request_data.files_metadata else FileRetainMetadata()
|
||||
doc_id = file_meta.document_id or f"file_{uuid.uuid4()}"
|
||||
|
||||
# Resolve and validate per-file parser chain
|
||||
# Priority: per-file > request-level > server default
|
||||
raw_parser = file_meta.parser if file_meta.parser is not None else request_data.parser
|
||||
parser_chain = _resolve_parser(raw_parser)
|
||||
_validate_parsers(parser_chain, f"file '{file.filename}'")
|
||||
|
||||
item = {
|
||||
"file": wrapped_file,
|
||||
"document_id": doc_id,
|
||||
@@ -4417,6 +4590,7 @@ def _register_routes(app: FastAPI):
|
||||
"metadata": file_meta.metadata or {},
|
||||
"tags": file_meta.tags or [],
|
||||
"timestamp": file_meta.timestamp,
|
||||
"parser": parser_chain,
|
||||
}
|
||||
file_items.append(item)
|
||||
|
||||
@@ -4431,7 +4605,6 @@ def _register_routes(app: FastAPI):
|
||||
result = await app.state.memory.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
parser=config.file_parser,
|
||||
document_tags=None,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
@@ -280,6 +280,7 @@ ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
|
||||
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
|
||||
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
|
||||
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
|
||||
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
|
||||
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
|
||||
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
|
||||
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
|
||||
@@ -292,7 +293,13 @@ ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
||||
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
||||
"HINDSIGHT_API_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"
|
||||
@@ -435,7 +442,8 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
|
||||
|
||||
# File storage defaults
|
||||
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
|
||||
DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser)
|
||||
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
|
||||
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
|
||||
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
|
||||
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
|
||||
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
|
||||
@@ -443,9 +451,17 @@ 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
|
||||
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = (
|
||||
-1
|
||||
) # Total token budget for source facts in consolidation recall (-1 = unlimited)
|
||||
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
|
||||
256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited)
|
||||
)
|
||||
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
|
||||
|
||||
# Database migrations
|
||||
@@ -540,6 +556,11 @@ class JsonFormatter(logging.Formatter):
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
||||
def _parse_str_list(value: str) -> list[str]:
|
||||
"""Parse a comma-separated string into a non-empty list of stripped tokens."""
|
||||
return [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
|
||||
def _validate_extraction_mode(mode: str) -> str:
|
||||
"""Validate and normalize extraction mode."""
|
||||
mode_lower = mode.lower()
|
||||
@@ -699,7 +720,8 @@ class HindsightConfig:
|
||||
file_storage_azure_container: str | None # Azure container name (required for azure storage)
|
||||
file_storage_azure_account_name: str | None # Azure storage account name
|
||||
file_storage_azure_account_key: str | None # Azure storage account key
|
||||
file_parser: str # File parser to use (e.g., "markitdown", "iris")
|
||||
file_parser: list[str] # Ordered fallback chain of parsers (e.g. ["iris", "markitdown"])
|
||||
file_parser_allowlist: list[str] | None # Parsers clients may request (None = all registered)
|
||||
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
|
||||
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
|
||||
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
|
||||
@@ -709,9 +731,13 @@ 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
|
||||
consolidation_source_facts_max_tokens: int
|
||||
consolidation_source_facts_max_tokens_per_observation: int
|
||||
observations_mission: str | None
|
||||
|
||||
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
|
||||
@@ -812,6 +838,9 @@ class HindsightConfig:
|
||||
"entities_allow_free_form",
|
||||
# Consolidation settings
|
||||
"enable_observations",
|
||||
"consolidation_llm_batch_size",
|
||||
"consolidation_source_facts_max_tokens",
|
||||
"consolidation_source_facts_max_tokens_per_observation",
|
||||
"observations_mission",
|
||||
# Reflect settings
|
||||
"reflect_mission",
|
||||
@@ -1136,7 +1165,10 @@ class HindsightConfig:
|
||||
file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None,
|
||||
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
|
||||
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
|
||||
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
|
||||
file_parser=_parse_str_list(os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER)),
|
||||
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
|
||||
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
|
||||
else None,
|
||||
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
|
||||
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
|
||||
file_conversion_max_batch_size_mb=int(
|
||||
@@ -1153,6 +1185,14 @@ class HindsightConfig:
|
||||
== "true",
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
|
||||
enable_observation_history=os.getenv(
|
||||
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))
|
||||
),
|
||||
@@ -1162,6 +1202,15 @@ class HindsightConfig:
|
||||
consolidation_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
|
||||
),
|
||||
consolidation_source_facts_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS))
|
||||
),
|
||||
consolidation_source_facts_max_tokens_per_observation=int(
|
||||
os.getenv(
|
||||
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION,
|
||||
str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION),
|
||||
)
|
||||
),
|
||||
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
|
||||
entity_labels=None,
|
||||
entities_allow_free_form=True,
|
||||
|
||||
@@ -9,6 +9,10 @@ Observations are stored in memory_units with fact_type='observation' and include
|
||||
- proof_count: Number of supporting memories
|
||||
- source_memory_ids: Array of memory UUIDs that contribute to this observation
|
||||
- history: JSONB tracking changes over time
|
||||
|
||||
NOTE: Observations are distinct from mental models (pinned reflections).
|
||||
- Observations: auto-generated bottom-up by this engine from raw facts (memory_units table, fact_type='observation')
|
||||
- Mental models: user-defined queries stored in the mental_models table, refreshed on demand via reflect
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -762,13 +766,17 @@ async def _execute_update_action(
|
||||
logger.debug(f"Update skipped: observation {observation_id} not found in recall results")
|
||||
return
|
||||
|
||||
history = [
|
||||
{
|
||||
"previous_text": model.text,
|
||||
"changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_memory_ids": [str(mid) for mid in source_memory_ids],
|
||||
}
|
||||
]
|
||||
from ...config import get_config
|
||||
|
||||
history_entry = {
|
||||
"previous_text": model.text,
|
||||
"previous_tags": list(model.tags or []),
|
||||
"previous_occurred_start": model.occurred_start,
|
||||
"previous_occurred_end": model.occurred_end,
|
||||
"previous_mentioned_at": model.mentioned_at,
|
||||
"changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
|
||||
}
|
||||
|
||||
source_ids = list(model.source_fact_ids or []) + source_memory_ids
|
||||
|
||||
@@ -783,13 +791,18 @@ async def _execute_update_action(
|
||||
if perf:
|
||||
perf.record_timing("embedding", time.time() - t0)
|
||||
|
||||
config = get_config()
|
||||
history_clause = (
|
||||
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET text = $1,
|
||||
embedding = $2::vector,
|
||||
history = $3,
|
||||
{history_clause}
|
||||
source_memory_ids = $4,
|
||||
proof_count = $5,
|
||||
tags = $10,
|
||||
@@ -801,7 +814,7 @@ async def _execute_update_action(
|
||||
""",
|
||||
new_text,
|
||||
embedding_str,
|
||||
json.dumps(history),
|
||||
json.dumps([history_entry]),
|
||||
source_ids,
|
||||
len(source_ids),
|
||||
uuid.UUID(observation_id),
|
||||
@@ -913,10 +926,9 @@ async def _find_related_observations(
|
||||
"""
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
config = get_config()
|
||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
@@ -941,7 +953,8 @@ async def _find_related_observations(
|
||||
tags=tags, # Filter by source memory's tags
|
||||
tags_match=tags_match, # Use strict matching for security
|
||||
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
|
||||
max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation
|
||||
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
|
||||
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
finally:
|
||||
@@ -1005,14 +1018,17 @@ async def _consolidate_batch_with_llm(
|
||||
observations_text = "[]"
|
||||
|
||||
def _fact_line(m: dict[str, Any]) -> str:
|
||||
parts = [f"[{m['id']}] {m['text']}"]
|
||||
text = f"[{m['id']}] {m['text']}"
|
||||
temporal_parts = []
|
||||
if m.get("occurred_start"):
|
||||
parts.append(f"occurred_start={m['occurred_start']}")
|
||||
temporal_parts.append(f"occurred_start={m['occurred_start']}")
|
||||
if m.get("occurred_end"):
|
||||
parts.append(f"occurred_end={m['occurred_end']}")
|
||||
temporal_parts.append(f"occurred_end={m['occurred_end']}")
|
||||
if m.get("mentioned_at"):
|
||||
parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
return " | ".join(parts)
|
||||
temporal_parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
if temporal_parts:
|
||||
text += f" ({', '.join(temporal_parts)})"
|
||||
return text
|
||||
|
||||
facts_lines = "\n".join(_fact_line(m) for m in memories)
|
||||
|
||||
|
||||
@@ -35,8 +35,25 @@ Compare the facts against existing observations:
|
||||
_BATCH_OUTPUT_FORMAT = """
|
||||
Output a JSON object with three arrays.
|
||||
|
||||
Example (showing the required UUID format for all IDs):
|
||||
{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
|
||||
## EXAMPLE
|
||||
|
||||
Input facts:
|
||||
[a1b2c3d4-e5f6-7890-abcd-ef1234567890] Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)
|
||||
[b2c3d4e5-f6a7-8901-bcde-f12345678901] Alice said she's exhausted from the project deadlines | Involving: Alice (occurred_start=2024-01-20, mentioned_at=2024-01-20)
|
||||
|
||||
Good observation text — clean prose, no metadata, each fact tracked distinctly:
|
||||
"Alice works long hours, often past midnight."
|
||||
"Alice feels exhausted from project deadlines."
|
||||
|
||||
Bad observation text — NEVER do this (verbatim copy of fact text with metadata):
|
||||
"Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)"
|
||||
|
||||
Observation text rules:
|
||||
- Write clean prose — NEVER copy raw fact lines or their metadata (temporal fields, "Involving:", "When:" labels, UUIDs).
|
||||
- Parenthesized metadata like (occurred_start=...) and pipe-separated labels like "| Involving: ..." are fact formatting — strip them entirely from observation text.
|
||||
- How many observations to create and how much to aggregate is driven by the MISSION above.
|
||||
|
||||
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}}, {{"text": "Alice feels exhausted from project deadlines.", "source_fact_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
|
||||
"updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
|
||||
"deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -653,13 +653,19 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Retrieve file from storage
|
||||
file_data = await self._file_storage.retrieve(storage_key)
|
||||
|
||||
# Convert to markdown
|
||||
parser = self._parser_registry.get_parser(
|
||||
name=task_dict.get("parser"),
|
||||
# Convert to markdown using the ordered fallback chain stored in the task payload.
|
||||
# task_dict["parser"] is always a list[str] set at submission time.
|
||||
parser_chain: list[str] = task_dict.get("parser") or []
|
||||
if not parser_chain:
|
||||
raise ValueError("No parser chain defined for file_convert_retain task")
|
||||
convert_result = await self._parser_registry.convert_with_fallback(
|
||||
parsers=parser_chain,
|
||||
file_data=file_data,
|
||||
filename=filename,
|
||||
content_type=task_dict.get("content_type"),
|
||||
)
|
||||
markdown_content = await parser.convert(file_data, filename)
|
||||
markdown_content = convert_result.content
|
||||
winning_parser = convert_result.parser_name
|
||||
except Exception as e:
|
||||
# Re-raise with filename context for better error reporting
|
||||
error_msg = f"Failed to parse file '{filename}': {str(e)}"
|
||||
@@ -671,6 +677,31 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
f"document_id={document_id}, {len(markdown_content)} chars. Submitting retain task."
|
||||
)
|
||||
|
||||
# Fire file conversion hook (e.g., for Iris billing)
|
||||
if self._operation_validator:
|
||||
try:
|
||||
from hindsight_api.extensions.operation_validator import FileConvertResult
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
convert_context = RequestContext(
|
||||
internal=True,
|
||||
user_initiated=True,
|
||||
tenant_id=task_dict.get("_tenant_id"),
|
||||
api_key_id=task_dict.get("_api_key_id"),
|
||||
)
|
||||
await self._operation_validator.on_file_convert_complete(
|
||||
FileConvertResult(
|
||||
bank_id=bank_id,
|
||||
parser_name=winning_parser,
|
||||
filename=filename,
|
||||
output_chars=len(markdown_content),
|
||||
output_text=markdown_content,
|
||||
request_context=convert_context,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FILE_CONVERT_RETAIN] on_file_convert_complete hook failed: {e}")
|
||||
|
||||
# Build retain task payload
|
||||
retain_contents = [
|
||||
{
|
||||
@@ -2212,6 +2243,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_chunk_tokens: int = 8192,
|
||||
include_source_facts: bool = False,
|
||||
max_source_facts_tokens: int = 4096,
|
||||
max_source_facts_tokens_per_observation: int = -1,
|
||||
request_context: "RequestContext",
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
@@ -2353,6 +2385,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
quiet=_quiet,
|
||||
include_source_facts=include_source_facts,
|
||||
max_source_facts_tokens=max_source_facts_tokens,
|
||||
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
|
||||
)
|
||||
break # Success - exit retry loop
|
||||
except Exception as e:
|
||||
@@ -2479,6 +2512,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
quiet: bool = False,
|
||||
include_source_facts: bool = False,
|
||||
max_source_facts_tokens: int = 4096,
|
||||
max_source_facts_tokens_per_observation: int = -1,
|
||||
) -> RecallResultModel:
|
||||
"""
|
||||
Search implementation with modular retrieval and reranking.
|
||||
@@ -3100,18 +3134,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
encoding = _get_tiktoken_encoding()
|
||||
source_facts_dict = {}
|
||||
total_source_tokens = 0
|
||||
for sid in source_ids_ordered:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if (
|
||||
max_source_facts_tokens >= 0
|
||||
and total_source_tokens + fact_tokens > max_source_facts_tokens
|
||||
):
|
||||
break
|
||||
source_facts_dict[sid] = MemoryFact(
|
||||
|
||||
def _make_source_fact(sid: str, r: Any) -> MemoryFact:
|
||||
return MemoryFact(
|
||||
id=sid,
|
||||
text=r["text"],
|
||||
fact_type=r["fact_type"],
|
||||
@@ -3123,7 +3148,37 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
|
||||
tags=r["tags"] or None,
|
||||
)
|
||||
total_source_tokens += fact_tokens
|
||||
|
||||
if max_source_facts_tokens_per_observation >= 0:
|
||||
# Per-observation capping: each observation independently selects
|
||||
# source facts up to its token budget.
|
||||
for obs_id, sids in source_fact_ids_by_obs.items():
|
||||
obs_tokens = 0
|
||||
for sid in sids:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if obs_tokens + fact_tokens > max_source_facts_tokens_per_observation:
|
||||
break
|
||||
obs_tokens += fact_tokens
|
||||
if sid not in source_facts_dict:
|
||||
source_facts_dict[sid] = _make_source_fact(sid, r)
|
||||
else:
|
||||
# Global budget: fill in order of first appearance until exhausted.
|
||||
total_source_tokens = 0
|
||||
for sid in source_ids_ordered:
|
||||
if sid not in source_row_by_id:
|
||||
continue
|
||||
r = source_row_by_id[sid]
|
||||
fact_tokens = len(encoding.encode(r["text"]))
|
||||
if (
|
||||
max_source_facts_tokens >= 0
|
||||
and total_source_tokens + fact_tokens > max_source_facts_tokens
|
||||
):
|
||||
break
|
||||
source_facts_dict[sid] = _make_source_fact(sid, r)
|
||||
total_source_tokens += fact_tokens
|
||||
|
||||
# Get entities for each fact if include_entities is requested
|
||||
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
|
||||
@@ -3385,6 +3440,140 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
return result
|
||||
|
||||
async def update_document(
|
||||
self,
|
||||
document_id: str,
|
||||
bank_id: str,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
request_context: "RequestContext",
|
||||
) -> bool:
|
||||
"""
|
||||
Update mutable fields on a document without re-processing its content.
|
||||
|
||||
Tag changes propagate to all associated memory units and trigger observation
|
||||
invalidation + re-consolidation (same semantics as delete_document):
|
||||
- Observations referencing the document's memory units are deleted.
|
||||
- The document's own units and any co-source memories from other documents
|
||||
have consolidated_at reset so they are re-consolidated under the new tags.
|
||||
|
||||
Args:
|
||||
document_id: Document ID to update
|
||||
bank_id: Bank ID that owns the document
|
||||
tags: New tags to apply to the document and all its memory units (optional)
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
True if the document was found and updated, False if not found
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import BankWriteContext
|
||||
|
||||
ctx = BankWriteContext(bank_id=bank_id, operation="update_document", request_context=request_context)
|
||||
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
|
||||
pool = await self._get_pool()
|
||||
invalidated_obs = 0
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
async with conn.transaction():
|
||||
set_parts: list[str] = ["updated_at = now()"]
|
||||
params: list[Any] = []
|
||||
p = 1
|
||||
|
||||
if tags is not None:
|
||||
set_parts.append(f"tags = ${p}")
|
||||
params.append(tags)
|
||||
p += 1
|
||||
|
||||
params.extend([document_id, bank_id])
|
||||
doc_id_found = await conn.fetchval(
|
||||
f"""
|
||||
UPDATE {fq_table("documents")}
|
||||
SET {", ".join(set_parts)}
|
||||
WHERE id = ${p} AND bank_id = ${p + 1}
|
||||
RETURNING id
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
if not doc_id_found:
|
||||
return False
|
||||
|
||||
if tags is not None:
|
||||
unit_rows = await conn.fetch(
|
||||
f"SELECT id FROM {fq_table('memory_units')} WHERE document_id = $1 AND fact_type IN ('experience', 'world')",
|
||||
document_id,
|
||||
)
|
||||
unit_ids = [str(row["id"]) for row in unit_rows]
|
||||
|
||||
await conn.execute(
|
||||
f"UPDATE {fq_table('memory_units')} SET tags = $1 WHERE document_id = $2",
|
||||
tags,
|
||||
document_id,
|
||||
)
|
||||
|
||||
if unit_ids:
|
||||
import uuid as uuid_module
|
||||
|
||||
unit_uuids = [uuid_module.UUID(uid) for uid in unit_ids]
|
||||
unit_uuid_set = {str(u) for u in unit_uuids}
|
||||
affected_obs = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, source_memory_ids FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND fact_type = 'observation'
|
||||
AND source_memory_ids && $2::uuid[]
|
||||
""",
|
||||
bank_id,
|
||||
unit_uuids,
|
||||
)
|
||||
if affected_obs:
|
||||
obs_ids = [obs["id"] for obs in affected_obs]
|
||||
|
||||
seen: set[str] = set()
|
||||
other_source_uuids: list[uuid_module.UUID] = []
|
||||
for obs in affected_obs:
|
||||
for src_id in obs["source_memory_ids"] or []:
|
||||
src_str = str(src_id)
|
||||
if src_str not in unit_uuid_set and src_str not in seen:
|
||||
other_source_uuids.append(src_id)
|
||||
seen.add(src_str)
|
||||
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[])",
|
||||
obs_ids,
|
||||
)
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET consolidated_at = NULL
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
unit_uuids,
|
||||
)
|
||||
if other_source_uuids:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET consolidated_at = NULL
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
other_source_uuids,
|
||||
)
|
||||
invalidated_obs = len(obs_ids)
|
||||
logger.info(
|
||||
f"[OBSERVATIONS] Deleted {invalidated_obs} observations, reset "
|
||||
f"{len(unit_ids)} document source memories and "
|
||||
f"{len(other_source_uuids)} co-source memories for re-consolidation "
|
||||
f"after document update on '{document_id}' in bank {bank_id}"
|
||||
)
|
||||
|
||||
if invalidated_obs > 0:
|
||||
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
return True
|
||||
|
||||
async def delete_memory_unit(
|
||||
self,
|
||||
unit_id: str,
|
||||
@@ -4290,7 +4479,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
|
||||
}
|
||||
|
||||
# For observations, include source_memory_ids and fetch source_memories
|
||||
# For observations, include source_memory_ids
|
||||
# history is deprecated here - use GET /memories/{id}/history instead
|
||||
if row["fact_type"] == "observation":
|
||||
result["history"] = []
|
||||
|
||||
if row["fact_type"] == "observation" and row["source_memory_ids"]:
|
||||
source_ids = row["source_memory_ids"]
|
||||
result["source_memory_ids"] = [str(sid) for sid in source_ids]
|
||||
@@ -4319,6 +4512,95 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
return result
|
||||
|
||||
async def get_observation_history(
|
||||
self,
|
||||
bank_id: str,
|
||||
memory_id: str,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict] | None:
|
||||
"""
|
||||
Get the history of an observation, with source facts resolved to their text.
|
||||
|
||||
Returns None if the memory is not found or is not an observation.
|
||||
Returns a list of history entries (most recent first), each with source_facts resolved.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import BankReadContext
|
||||
|
||||
ctx = BankReadContext(bank_id=bank_id, operation="get_observation_history", request_context=request_context)
|
||||
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT fact_type, history, source_memory_ids
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
uuid.UUID(memory_id),
|
||||
bank_id,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
if row["fact_type"] != "observation":
|
||||
return []
|
||||
|
||||
raw_history = row["history"]
|
||||
if isinstance(raw_history, str):
|
||||
raw_history = json.loads(raw_history)
|
||||
if not raw_history:
|
||||
return []
|
||||
|
||||
# Collect all source memory IDs (current full set + all historical new ones)
|
||||
current_source_ids: list[str] = [str(sid) for sid in (row["source_memory_ids"] or [])]
|
||||
all_source_ids: set[uuid.UUID] = set(uuid.UUID(sid) for sid in current_source_ids)
|
||||
for entry in raw_history:
|
||||
for sid in entry.get("new_source_memory_ids", []):
|
||||
try:
|
||||
all_source_ids.add(uuid.UUID(sid))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Resolve all source memories in one query
|
||||
source_map: dict[str, dict] = {}
|
||||
if all_source_ids:
|
||||
source_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, fact_type, context
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
list(all_source_ids),
|
||||
)
|
||||
for r in source_rows:
|
||||
source_map[str(r["id"])] = {
|
||||
"id": str(r["id"]),
|
||||
"text": r["text"],
|
||||
"type": r["fact_type"],
|
||||
"context": r["context"] or None,
|
||||
}
|
||||
|
||||
# Reconstruct cumulative source IDs per change by working backwards from current state.
|
||||
# Source IDs are only ever accumulated (never removed), so:
|
||||
# after_change_N = before_change_N + new_source_memory_ids_N
|
||||
cumulative_ids: list[str] = list(current_source_ids)
|
||||
enriched: list[dict] = []
|
||||
for entry in reversed(raw_history):
|
||||
new_ids_in_entry: set[str] = set(entry.get("new_source_memory_ids", []))
|
||||
source_facts = []
|
||||
for sid in cumulative_ids:
|
||||
fact = source_map.get(sid, {"id": sid, "text": None, "type": None, "context": None})
|
||||
source_facts.append({**fact, "is_new": sid in new_ids_in_entry})
|
||||
enriched_entry = dict(entry)
|
||||
enriched_entry["source_facts"] = source_facts
|
||||
enriched.append(enriched_entry)
|
||||
# Step back: remove the new IDs added by this change to get the prior state
|
||||
cumulative_ids = [sid for sid in cumulative_ids if sid not in new_ids_in_entry]
|
||||
|
||||
enriched.reverse()
|
||||
return enriched
|
||||
|
||||
async def list_documents(
|
||||
self,
|
||||
bank_id: str,
|
||||
@@ -5851,6 +6133,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,
|
||||
@@ -6071,6 +6386,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]
|
||||
@@ -6086,6 +6412,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])
|
||||
@@ -7044,7 +7378,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
self,
|
||||
bank_id: str,
|
||||
file_items: list[dict[str, Any]],
|
||||
parser: str,
|
||||
document_tags: list[str] | None,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any]:
|
||||
@@ -7063,7 +7396,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
- metadata: Optional metadata dict
|
||||
- tags: Optional tags list
|
||||
- timestamp: Optional timestamp
|
||||
parser: Parser name (e.g., "markitdown")
|
||||
- parser: Ordered list of parser names to try (fallback chain)
|
||||
document_tags: Tags applied to all documents
|
||||
request_context: Request context for authentication
|
||||
|
||||
@@ -7119,7 +7452,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"storage_key": storage_key,
|
||||
"original_filename": file.filename,
|
||||
"content_type": file.content_type or "application/octet-stream",
|
||||
"parser": parser,
|
||||
"parser": item["parser"],
|
||||
"context": item.get("context"),
|
||||
"metadata": item.get("metadata", {}),
|
||||
"tags": item.get("tags", []),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
Mental models module for Hindsight.
|
||||
|
||||
Mental models contain directives - hard rules that are injected into reflect prompts.
|
||||
Directives are user-defined and their observations are user-provided (not LLM-generated).
|
||||
|
||||
Other types of consolidated knowledge are handled by:
|
||||
- Learnings: Automatic bottom-up consolidation from facts
|
||||
- Pinned Reflections: User-curated living documents
|
||||
"""
|
||||
|
||||
from .models import MentalModel, MentalModelSubtype
|
||||
|
||||
__all__ = ["MentalModel", "MentalModelSubtype"]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Pydantic models for mental models.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MentalModelSubtype(str, Enum):
|
||||
"""Subtype of mental model.
|
||||
|
||||
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
|
||||
are handled by:
|
||||
- Learnings: Automatic bottom-up consolidation from facts
|
||||
- Pinned Reflections: User-curated living documents
|
||||
"""
|
||||
|
||||
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
|
||||
|
||||
|
||||
class MentalModel(BaseModel):
|
||||
"""
|
||||
A mental model representing synthesized understanding.
|
||||
|
||||
Mental models are the agent's consolidated knowledge. Unlike raw facts,
|
||||
mental models provide:
|
||||
- A one-liner description for quick scanning/retrieval
|
||||
- A full summary for deep understanding
|
||||
- Links to related mental models
|
||||
"""
|
||||
|
||||
id: str = Field(description="Unique identifier within the bank")
|
||||
bank_id: str = Field(description="Bank this mental model belongs to")
|
||||
subtype: MentalModelSubtype = Field(description="How this model was created")
|
||||
name: str = Field(description="Human-readable name")
|
||||
description: str = Field(description="One-liner for quick scanning and retrieval matching")
|
||||
summary: str | None = Field(default=None, description="Full synthesized understanding")
|
||||
|
||||
# References
|
||||
entity_id: str | None = Field(default=None, description="Reference to entities table when type=entity")
|
||||
source_facts: list[str] = Field(default_factory=list, description="Fact IDs used to generate summary")
|
||||
links: list[str] = Field(default_factory=list, description="Related mental model IDs")
|
||||
|
||||
# Tags for scoped visibility (similar to document tags)
|
||||
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility filtering")
|
||||
|
||||
# Timestamps
|
||||
last_updated: datetime | None = Field(default=None, description="When summary was last regenerated")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
|
||||
)
|
||||
@@ -1,10 +1,31 @@
|
||||
"""File parser implementations."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .base import FileParser, UnsupportedFileTypeError
|
||||
from .iris import IrisParser
|
||||
from .markitdown import MarkitdownParser
|
||||
|
||||
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
|
||||
__all__ = [
|
||||
"FileParser",
|
||||
"UnsupportedFileTypeError",
|
||||
"IrisParser",
|
||||
"MarkitdownParser",
|
||||
"FileParserRegistry",
|
||||
"ConvertResult",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
"""Result of a successful file conversion."""
|
||||
|
||||
content: str
|
||||
parser_name: str
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileParserRegistry:
|
||||
@@ -57,6 +78,51 @@ class FileParserRegistry:
|
||||
|
||||
raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}")
|
||||
|
||||
async def convert_with_fallback(
|
||||
self,
|
||||
parsers: list[str],
|
||||
file_data: bytes,
|
||||
filename: str,
|
||||
content_type: str | None = None,
|
||||
) -> ConvertResult:
|
||||
"""
|
||||
Try each parser in order, falling back on failure or empty content.
|
||||
|
||||
Moves to the next parser if the current one raises UnsupportedFileTypeError
|
||||
or returns empty content. Any other exception (RuntimeError, network error,
|
||||
etc.) also triggers a fallback so the chain is exhausted before failing.
|
||||
|
||||
Args:
|
||||
parsers: Ordered list of parser names to try
|
||||
file_data: Raw file bytes
|
||||
filename: Original filename
|
||||
content_type: MIME type (optional)
|
||||
|
||||
Returns:
|
||||
ConvertResult with the parsed content and the name of the parser that succeeded
|
||||
|
||||
Raises:
|
||||
ValueError: If a parser name is not registered
|
||||
RuntimeError: If all parsers fail or return empty content
|
||||
"""
|
||||
last_error: Exception | None = None
|
||||
for name in parsers:
|
||||
parser = self.get_parser(name, filename, content_type)
|
||||
try:
|
||||
content = await parser.convert(file_data, filename)
|
||||
if content and content.strip():
|
||||
return ConvertResult(content=content, parser_name=name)
|
||||
logger.warning(f"Parser '{name}' returned empty content for '{filename}', trying next")
|
||||
last_error = RuntimeError(f"Parser '{name}' returned no content for '{filename}'")
|
||||
except UnsupportedFileTypeError as e:
|
||||
logger.warning(f"Parser '{name}' does not support '{filename}', trying next: {e}")
|
||||
last_error = e
|
||||
except Exception as e:
|
||||
logger.warning(f"Parser '{name}' failed for '{filename}', trying next: {e}")
|
||||
last_error = e
|
||||
|
||||
raise last_error or RuntimeError(f"No parsers available for '{filename}'")
|
||||
|
||||
def list_parsers(self) -> list[str]:
|
||||
"""Get list of registered parser names."""
|
||||
return list(self._parsers.keys())
|
||||
|
||||
@@ -62,7 +62,7 @@ class IrisParser(FileParser):
|
||||
"""
|
||||
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
|
||||
# Step 1: Request a presigned upload URL
|
||||
init_resp = await client.post(
|
||||
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
|
||||
@@ -75,9 +75,10 @@ class IrisParser(FileParser):
|
||||
upload_url: str = init_data["uploadUrl"]
|
||||
|
||||
# Step 2: Upload the file bytes to the presigned URL (no auth header)
|
||||
# Ensure file_data is plain bytes (GCS storage may return obstore.Bytes)
|
||||
upload_resp = await client.put(
|
||||
upload_url,
|
||||
content=file_data,
|
||||
content=bytes(file_data),
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
_raise_for_status(upload_resp, filename, "file upload")
|
||||
|
||||
@@ -522,6 +522,19 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Normalize named tool_choice dicts to "required" + filter tools.
|
||||
# Some providers (e.g. LM Studio, Ollama) reject the OpenAI named format
|
||||
# {"type": "function", "function": {"name": "..."}}. The semantics are
|
||||
# identical to tool_choice="required" with the tools list restricted to
|
||||
# just the requested tool, so we apply that transformation universally.
|
||||
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
|
||||
forced_name = tool_choice.get("function", {}).get("name")
|
||||
if forced_name:
|
||||
filtered = [t for t in tools if t.get("function", {}).get("name") == forced_name]
|
||||
if filtered:
|
||||
tools = filtered
|
||||
tool_choice = "required"
|
||||
|
||||
# Build call parameters
|
||||
call_params: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Google Cloud Storage backend using obstore."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import obstore as obs
|
||||
from obstore.store import GCSStore
|
||||
@@ -11,6 +12,30 @@ from .base import FileStorage
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_google_auth_credential_provider():
|
||||
"""Create a credential provider using google.auth (supports all credential types).
|
||||
|
||||
obstore's built-in credential parsing only supports service_account and
|
||||
authorized_user JSON types. This provider uses the google-auth library
|
||||
which additionally handles external_account (Workload Identity Federation),
|
||||
impersonated credentials, and metadata-server credentials.
|
||||
"""
|
||||
import google.auth
|
||||
import google.auth.transport.requests
|
||||
|
||||
credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
def _provide():
|
||||
credentials.refresh(request)
|
||||
expiry = credentials.expiry
|
||||
if expiry and expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
return {"token": credentials.token, "expires_at": expiry}
|
||||
|
||||
return _provide
|
||||
|
||||
|
||||
class GCSFileStorage(FileStorage):
|
||||
"""
|
||||
Google Cloud Storage backend.
|
||||
@@ -27,8 +52,29 @@ class GCSFileStorage(FileStorage):
|
||||
kwargs: dict = {}
|
||||
if service_account_key:
|
||||
kwargs["service_account_key"] = service_account_key
|
||||
else:
|
||||
# Use google.auth credential provider for broad credential type support
|
||||
# (service_account, authorized_user, external_account, metadata server, etc.)
|
||||
try:
|
||||
kwargs["credential_provider"] = _make_google_auth_credential_provider()
|
||||
logger.info("Using google.auth credential provider for GCS")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to create google.auth credential provider, falling back to obstore defaults: {e}"
|
||||
)
|
||||
|
||||
self._store = GCSStore(bucket, **kwargs)
|
||||
# Workaround for https://github.com/developmentseed/obstore/issues/605
|
||||
# obstore's Rust layer doesn't support external_account credentials (Workload
|
||||
# Identity Federation) and eagerly parses GOOGLE_APPLICATION_CREDENTIALS even
|
||||
# when credential_provider is given. Per the obstore maintainer's guidance,
|
||||
# remove env vars so the Rust code doesn't try to authenticate itself.
|
||||
# google.auth (used by credential_provider above) has already loaded credentials.
|
||||
gac = os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None)
|
||||
try:
|
||||
self._store = GCSStore(bucket, **kwargs)
|
||||
finally:
|
||||
if gac is not None:
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = gac
|
||||
logger.info(f"Initialized GCS file storage: bucket={bucket}")
|
||||
|
||||
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
|
||||
|
||||
@@ -30,6 +30,8 @@ from hindsight_api.extensions.operation_validator import (
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
ConsolidateResult,
|
||||
# File Conversion
|
||||
FileConvertResult,
|
||||
# Mental Model operations
|
||||
MentalModelGetContext,
|
||||
MentalModelGetResult,
|
||||
@@ -83,6 +85,8 @@ __all__ = [
|
||||
# Operation Validator - Consolidation
|
||||
"ConsolidateContext",
|
||||
"ConsolidateResult",
|
||||
# Operation Validator - File Conversion
|
||||
"FileConvertResult",
|
||||
# Operation Validator - Mental Model
|
||||
"MentalModelGetContext",
|
||||
"MentalModelGetResult",
|
||||
|
||||
@@ -289,6 +289,28 @@ class MentalModelRefreshResult:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File Conversion Post-operation Context
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileConvertResult:
|
||||
"""Result context for post-file-conversion hook.
|
||||
|
||||
Fired after a file is converted to markdown, before the retain step.
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
parser_name: str
|
||||
filename: str
|
||||
output_chars: int
|
||||
output_text: str
|
||||
request_context: "RequestContext"
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
Validates and hooks into retain/recall/reflect/consolidate operations.
|
||||
@@ -496,6 +518,31 @@ class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# File Conversion - Post-operation hook (optional - override to implement)
|
||||
# =========================================================================
|
||||
|
||||
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
|
||||
"""
|
||||
Called after a file is converted to markdown (before the retain step).
|
||||
|
||||
Override to implement post-conversion logic such as:
|
||||
- Billing for premium parsers (e.g., Iris)
|
||||
- Usage tracking
|
||||
- Audit logging
|
||||
|
||||
Args:
|
||||
result: Result context containing:
|
||||
- bank_id: Bank identifier
|
||||
- parser_name: Name of the parser used (e.g., 'markitdown', 'iris')
|
||||
- filename: Original filename
|
||||
- output_chars: Character count of the converted markdown
|
||||
- request_context: Request context with auth info
|
||||
- success: Whether the conversion succeeded
|
||||
- error: Error message (if failed)
|
||||
"""
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Mental Model - Pre-operation validation hook (optional - override to implement)
|
||||
# =========================================================================
|
||||
|
||||
@@ -268,6 +268,7 @@ def main():
|
||||
file_storage_azure_account_name=config.file_storage_azure_account_name,
|
||||
file_storage_azure_account_key=config.file_storage_azure_account_key,
|
||||
file_parser=config.file_parser,
|
||||
file_parser_allowlist=config.file_parser_allowlist,
|
||||
file_parser_iris_token=config.file_parser_iris_token,
|
||||
file_parser_iris_org_id=config.file_parser_iris_org_id,
|
||||
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
|
||||
@@ -275,9 +276,13 @@ def main():
|
||||
enable_file_upload_api=config.enable_file_upload_api,
|
||||
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,
|
||||
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
|
||||
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
observations_mission=config.observations_mission,
|
||||
entity_labels=config.entity_labels,
|
||||
entities_allow_free_form=config.entities_allow_free_form,
|
||||
|
||||
@@ -18,6 +18,7 @@ No alembic.ini required - all configuration is done programmatically.
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,6 +34,13 @@ logger = logging.getLogger(__name__)
|
||||
# Advisory lock ID for migrations (arbitrary unique number)
|
||||
MIGRATION_LOCK_ID = 123456789
|
||||
|
||||
# Alembic's command.upgrade() is NOT thread-safe: it uses module-level global
|
||||
# proxies (context._proxy, script) that get overwritten when two threads call
|
||||
# upgrade() concurrently. This causes migrations to target the wrong schema
|
||||
# and crash with "relation already exists" or KeyError: 'script'.
|
||||
# Serialize all Alembic invocations with a process-level lock.
|
||||
_alembic_lock = threading.Lock()
|
||||
|
||||
|
||||
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
|
||||
"""
|
||||
@@ -144,9 +152,12 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
|
||||
if schema:
|
||||
alembic_cfg.set_main_option("target_schema", schema)
|
||||
|
||||
# Run migrations
|
||||
# Run migrations under a process-level lock. Alembic uses module-level
|
||||
# global proxies that are not thread-safe, so concurrent command.upgrade()
|
||||
# calls from different threads corrupt each other's context.
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
with _alembic_lock:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except ResolutionError as e:
|
||||
# This happens during rolling deployments when a newer version of the code
|
||||
# has already run migrations, and this older replica doesn't have the new
|
||||
|
||||
@@ -6,12 +6,14 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.consolidation.consolidator import (
|
||||
_aggregate_source_fields,
|
||||
_find_related_observations,
|
||||
run_consolidation_job,
|
||||
)
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
@@ -2418,3 +2420,81 @@ class TestAggregateSourceFields:
|
||||
assert agg.occurred_end == d
|
||||
assert agg.mentioned_at == d
|
||||
assert agg.tags == ["x"]
|
||||
|
||||
|
||||
class TestConsolidationSourceFactsConfig:
|
||||
"""Tests that consolidation uses the source_facts token config when calling recall."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations(self):
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_passes_source_facts_max_tokens_to_recall(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""consolidation_source_facts_max_tokens from config is forwarded to recall_async."""
|
||||
bank_id = f"test-sf-config-total-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
raw = _get_raw_config()
|
||||
fake_config = type(raw)(**{
|
||||
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
|
||||
"consolidation_source_facts_max_tokens": 999,
|
||||
"consolidation_source_facts_max_tokens_per_observation": -1,
|
||||
})
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
|
||||
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
|
||||
):
|
||||
await _find_related_observations(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
query="test query",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert mock_recall.called
|
||||
_, kwargs = mock_recall.call_args
|
||||
assert kwargs.get("max_source_facts_tokens") == 999
|
||||
assert kwargs.get("max_source_facts_tokens_per_observation") == -1
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_passes_source_facts_per_obs_tokens_to_recall(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""consolidation_source_facts_max_tokens_per_observation from config is forwarded to recall_async."""
|
||||
bank_id = f"test-sf-config-per-obs-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
raw = _get_raw_config()
|
||||
fake_config = type(raw)(**{
|
||||
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
|
||||
"consolidation_source_facts_max_tokens": -1,
|
||||
"consolidation_source_facts_max_tokens_per_observation": 128,
|
||||
})
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
|
||||
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
|
||||
):
|
||||
await _find_related_observations(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
query="test query",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert mock_recall.called
|
||||
_, kwargs = mock_recall.call_args
|
||||
assert kwargs.get("max_source_facts_tokens") == -1
|
||||
assert kwargs.get("max_source_facts_tokens_per_observation") == 128
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
End-to-end tests for file retain (upload, convert, retain) functionality.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from hindsight_api.extensions import FileConvertResult, OperationValidatorExtension, ValidationResult
|
||||
from hindsight_api.extensions.operation_validator import (
|
||||
RecallContext,
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pdf_content():
|
||||
@@ -393,13 +403,13 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
|
||||
"metadata": {"source": "test"},
|
||||
"tags": ["test_tag"],
|
||||
"timestamp": None,
|
||||
"parser": ["markitdown"],
|
||||
}
|
||||
]
|
||||
|
||||
result = await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
parser="markitdown",
|
||||
document_tags=["two_phase_test"],
|
||||
request_context=context,
|
||||
)
|
||||
@@ -511,6 +521,7 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["failing_converter"],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -518,7 +529,6 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
|
||||
result = await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
parser="failing_converter",
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
@@ -551,3 +561,207 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
|
||||
assert operation["error_message"] is not None
|
||||
assert "Mock conversion error" in operation["error_message"]
|
||||
assert "test.fail" in operation["error_message"]
|
||||
|
||||
|
||||
class FileConvertTrackingValidator(OperationValidatorExtension):
|
||||
"""Validator that tracks on_file_convert_complete hook calls."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({})
|
||||
self.convert_calls: list[FileConvertResult] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def on_retain_complete(self, result: RetainResult) -> None:
|
||||
pass
|
||||
|
||||
async def on_recall_complete(self, result: RecallResult) -> None:
|
||||
pass
|
||||
|
||||
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
|
||||
self.convert_calls.append(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_file_convert_complete_hook_called(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that on_file_convert_complete hook is called after file conversion with correct parameters."""
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = "test_file_convert_hook_bank"
|
||||
validator = FileConvertTrackingValidator()
|
||||
memory_no_llm_verify._operation_validator = validator
|
||||
|
||||
context = RequestContext(internal=True, api_key_id="test-key-id", tenant_id="test-tenant")
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
mock_file = MockFile(sample_txt_content, "report.txt", "text/plain")
|
||||
|
||||
file_items = [
|
||||
{
|
||||
"file": mock_file,
|
||||
"document_id": "hook_test_doc",
|
||||
"context": "test context",
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["markitdown"],
|
||||
}
|
||||
]
|
||||
|
||||
await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(validator.convert_calls) == 1
|
||||
result = validator.convert_calls[0]
|
||||
assert result.bank_id == bank_id
|
||||
assert result.filename == "report.txt"
|
||||
assert result.parser_name == "markitdown"
|
||||
assert result.output_chars > 0
|
||||
assert result.output_text is not None
|
||||
assert len(result.output_text) == result.output_chars
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
assert result.request_context is not None
|
||||
assert result.request_context.api_key_id == "test-key-id"
|
||||
assert result.request_context.tenant_id == "test-tenant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_file_convert_complete_hook_called_for_each_file(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that on_file_convert_complete is called once per file when uploading multiple files."""
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = "test_file_convert_hook_multi_bank"
|
||||
validator = FileConvertTrackingValidator()
|
||||
memory_no_llm_verify._operation_validator = validator
|
||||
|
||||
context = RequestContext(internal=True)
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
file_items = [
|
||||
{
|
||||
"file": MockFile(b"First document content", "first.txt", "text/plain"),
|
||||
"document_id": "doc_1",
|
||||
"context": None,
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["markitdown"],
|
||||
},
|
||||
{
|
||||
"file": MockFile(b"Second document content", "second.txt", "text/plain"),
|
||||
"document_id": "doc_2",
|
||||
"context": None,
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["markitdown"],
|
||||
},
|
||||
]
|
||||
|
||||
await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(validator.convert_calls) == 2
|
||||
filenames = {r.filename for r in validator.convert_calls}
|
||||
assert filenames == {"first.txt", "second.txt"}
|
||||
for result in validator.convert_calls:
|
||||
assert result.bank_id == bank_id
|
||||
assert result.parser_name == "markitdown"
|
||||
assert result.output_chars > 0
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_file_convert_complete_hook_not_called_on_conversion_failure(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that on_file_convert_complete is NOT called when file conversion fails."""
|
||||
from hindsight_api.engine.parsers.base import FileParser
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = "test_file_convert_hook_fail_bank"
|
||||
validator = FileConvertTrackingValidator()
|
||||
memory_no_llm_verify._operation_validator = validator
|
||||
|
||||
class FailingParser(FileParser):
|
||||
async def convert(self, file_data: bytes, filename: str) -> str:
|
||||
raise RuntimeError("Mock conversion failure")
|
||||
|
||||
def supports(self, filename: str, content_type: str | None = None) -> bool:
|
||||
return filename.endswith(".hookfail")
|
||||
|
||||
def name(self) -> str:
|
||||
return "hookfail_parser"
|
||||
|
||||
memory_no_llm_verify._parser_registry.register(FailingParser())
|
||||
|
||||
context = RequestContext(internal=True)
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
file_items = [
|
||||
{
|
||||
"file": MockFile(sample_txt_content, "bad.hookfail", "application/octet-stream"),
|
||||
"document_id": "fail_hook_doc",
|
||||
"context": None,
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["hookfail_parser"],
|
||||
}
|
||||
]
|
||||
|
||||
await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=file_items,
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(validator.convert_calls) == 0
|
||||
|
||||
@@ -75,6 +75,9 @@ async def test_hierarchical_fields_categorization():
|
||||
assert "retain_custom_instructions" in configurable
|
||||
assert "retain_chunk_size" in configurable
|
||||
assert "enable_observations" in configurable
|
||||
assert "consolidation_llm_batch_size" in configurable
|
||||
assert "consolidation_source_facts_max_tokens" in configurable
|
||||
assert "consolidation_source_facts_max_tokens_per_observation" in configurable
|
||||
assert "observations_mission" in configurable
|
||||
assert "reflect_mission" in configurable
|
||||
assert "disposition_skepticism" in configurable
|
||||
@@ -86,7 +89,7 @@ async def test_hierarchical_fields_categorization():
|
||||
assert "entity_labels" in configurable
|
||||
|
||||
# Verify count is correct
|
||||
assert len(configurable) == 14
|
||||
assert len(configurable) == 17
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Reproduce issue #520: Reflect fails with LM Studio due to unsupported tool_choice format.
|
||||
|
||||
The reflect agent forces tool selection via named tool_choice dicts on the first few iterations:
|
||||
{"type": "function", "function": {"name": "search_mental_models"}}
|
||||
|
||||
LM Studio (and Ollama) reject this format with HTTP 400:
|
||||
"Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'."
|
||||
|
||||
The fix should convert named tool_choice to "required" and filter the tools list
|
||||
to only the requested tool for providers that don't support named tool_choice.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import APIStatusError
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
# Reflect agent tools (subset matching what agent.py uses)
|
||||
REFLECT_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_mental_models",
|
||||
"description": "Search consolidated mental models",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_observations",
|
||||
"description": "Search raw observations",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall",
|
||||
"description": "Recall semantic memories",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "done",
|
||||
"description": "Finish and return the answer",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}},
|
||||
"required": ["answer"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _make_lmstudio_llm() -> OpenAICompatibleLLM:
|
||||
return OpenAICompatibleLLM(
|
||||
provider="lmstudio",
|
||||
api_key="local",
|
||||
base_url="http://localhost:1234/v1",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
|
||||
def _lmstudio_400_error(msg: str = "Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'.") -> APIStatusError:
|
||||
"""Simulate the HTTP 400 LM Studio returns for unsupported tool_choice format."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.headers = {}
|
||||
return APIStatusError(
|
||||
message=msg,
|
||||
response=mock_response,
|
||||
body={"error": {"message": msg, "type": "invalid_request_error"}},
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_call_response(tool_name: str, arguments: dict) -> MagicMock:
|
||||
"""Build a mock successful tool call response from the LLM API."""
|
||||
mock_tc = MagicMock()
|
||||
mock_tc.id = "call_abc123"
|
||||
mock_tc.function.name = tool_name
|
||||
mock_tc.function.arguments = json.dumps(arguments)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage.prompt_tokens = 120
|
||||
mock_response.usage.completion_tokens = 40
|
||||
mock_response.usage.total_tokens = 160
|
||||
mock_response.choices[0].finish_reason = "tool_calls"
|
||||
mock_response.choices[0].message.content = None
|
||||
mock_response.choices[0].message.tool_calls = [mock_tc]
|
||||
return mock_response
|
||||
|
||||
|
||||
class TestLMStudioNamedToolChoiceBug:
|
||||
"""
|
||||
Reproduces issue #520.
|
||||
|
||||
The reflect agent (agent.py lines 546-555) sets tool_choice to a named dict
|
||||
on the first iterations to force sequential retrieval:
|
||||
|
||||
iteration=0, has_mental_models=True → {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
iteration=0, has_mental_models=False → {"type": "function", "function": {"name": "search_observations"}}
|
||||
iteration=1, has_mental_models=True → {"type": "function", "function": {"name": "search_observations"}}
|
||||
iteration=1 or (2 with models) → {"type": "function", "function": {"name": "recall"}}
|
||||
|
||||
LM Studio rejects these dict formats with HTTP 400.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lmstudio_named_tool_choice_no_longer_causes_400(self):
|
||||
"""
|
||||
Regression test for issue #520: named tool_choice dict is converted to
|
||||
"required" + filtered tools before the API call, so LM Studio never
|
||||
sees the unsupported format and the 400 error no longer occurs.
|
||||
"""
|
||||
llm = _make_lmstudio_llm()
|
||||
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
|
||||
|
||||
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = success_response
|
||||
|
||||
# Should succeed — no 400 because the dict is converted before sending
|
||||
result = await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "What is the user's name?"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice=named_tool_choice,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "search_mental_models"
|
||||
|
||||
sent_kwargs = mock_create.call_args.kwargs
|
||||
assert sent_kwargs["tool_choice"] == "required"
|
||||
assert len(sent_kwargs["tools"]) == 1
|
||||
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"forced_tool_name",
|
||||
["search_mental_models", "search_observations", "recall"],
|
||||
)
|
||||
async def test_all_reflect_forced_tools_fail_on_lmstudio(self, forced_tool_name: str):
|
||||
"""
|
||||
Each named tool_choice the reflect agent uses on iterations 0-2 triggers
|
||||
the same 400 error on LM Studio.
|
||||
"""
|
||||
llm = _make_lmstudio_llm()
|
||||
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
|
||||
|
||||
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.side_effect = _lmstudio_400_error()
|
||||
|
||||
with pytest.raises(APIStatusError) as exc_info:
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "Test query"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice=named_tool_choice,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lmstudio_string_tool_choice_works_fine(self):
|
||||
"""
|
||||
String tool_choice values ("auto", "none", "required") ARE supported by LM Studio.
|
||||
Only the dict format {"type": "function", "function": {"name": "..."}} fails.
|
||||
This test confirms the control case works.
|
||||
"""
|
||||
llm = _make_lmstudio_llm()
|
||||
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
|
||||
|
||||
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = success_response
|
||||
|
||||
result = await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "What is the user's name?"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice="required", # string form — LM Studio accepts this
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "search_mental_models"
|
||||
|
||||
# Confirm "required" was sent, not a dict
|
||||
sent_kwargs = mock_create.call_args.kwargs
|
||||
assert sent_kwargs["tool_choice"] == "required"
|
||||
|
||||
|
||||
class TestExpectedFixBehavior:
|
||||
"""
|
||||
Tests that document the EXPECTED behavior after the fix is applied.
|
||||
|
||||
For lmstudio (and ollama) providers, when tool_choice is a named dict:
|
||||
{"type": "function", "function": {"name": "search_mental_models"}}
|
||||
|
||||
The fix should:
|
||||
1. Convert tool_choice to "required"
|
||||
2. Filter tools to only the requested tool
|
||||
|
||||
These tests currently FAIL (because the fix is not yet implemented).
|
||||
After the fix is applied, they should PASS.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_converts_named_tool_choice_to_required(self):
|
||||
"""
|
||||
After fix: named tool_choice dict is converted to "required" for lmstudio.
|
||||
The API receives tool_choice="required" instead of the unsupported dict.
|
||||
"""
|
||||
llm = _make_lmstudio_llm()
|
||||
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
|
||||
|
||||
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = success_response
|
||||
|
||||
result = await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "What is the user's name?"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice=named_tool_choice,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].name == "search_mental_models"
|
||||
|
||||
sent_kwargs = mock_create.call_args.kwargs
|
||||
# Fix: dict was converted to "required"
|
||||
assert sent_kwargs["tool_choice"] == "required", (
|
||||
f"Expected tool_choice='required', got {sent_kwargs['tool_choice']!r}"
|
||||
)
|
||||
# Fix: tools filtered to just the requested one
|
||||
assert len(sent_kwargs["tools"]) == 1
|
||||
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"forced_tool_name",
|
||||
["search_mental_models", "search_observations", "recall"],
|
||||
)
|
||||
async def test_fix_filters_tools_to_requested_tool(self, forced_tool_name: str):
|
||||
"""
|
||||
After fix: tools list is filtered to only the forced tool so the model
|
||||
can only call that one tool (equivalent to the named tool_choice behavior).
|
||||
"""
|
||||
llm = _make_lmstudio_llm()
|
||||
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
|
||||
success_response = _make_tool_call_response(forced_tool_name, {"query": "test"})
|
||||
|
||||
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = success_response
|
||||
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "Test query"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice=named_tool_choice,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
sent_kwargs = mock_create.call_args.kwargs
|
||||
assert sent_kwargs["tool_choice"] == "required"
|
||||
assert len(sent_kwargs["tools"]) == 1
|
||||
assert sent_kwargs["tools"][0]["function"]["name"] == forced_tool_name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_also_applies_to_openai_provider(self):
|
||||
"""
|
||||
The fix is generalized: all providers convert named tool_choice to
|
||||
"required" + filtered tools. OpenAI natively supports the dict format
|
||||
too, so the behaviour is semantically identical either way.
|
||||
"""
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
openai_llm = OpenAICompatibleLLM(
|
||||
provider="openai",
|
||||
api_key="sk-test",
|
||||
base_url="",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
success_response = _make_tool_call_response("search_mental_models", {"query": "test"})
|
||||
|
||||
with patch.object(openai_llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = success_response
|
||||
|
||||
await openai_llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "Test"}],
|
||||
tools=REFLECT_TOOLS,
|
||||
tool_choice=named_tool_choice,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
sent_kwargs = mock_create.call_args.kwargs
|
||||
# Generalized fix applies to OpenAI too
|
||||
assert sent_kwargs["tool_choice"] == "required"
|
||||
assert len(sent_kwargs["tools"]) == 1
|
||||
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
from hindsight_api import migrations
|
||||
|
||||
|
||||
def test_run_migrations_internal_serializes_alembic_upgrade(monkeypatch):
|
||||
max_concurrent_upgrades = 0
|
||||
active_upgrades = 0
|
||||
active_lock = threading.Lock()
|
||||
start_barrier = threading.Barrier(2)
|
||||
|
||||
def fake_upgrade(_cfg, _revision):
|
||||
nonlocal max_concurrent_upgrades, active_upgrades
|
||||
with active_lock:
|
||||
active_upgrades += 1
|
||||
max_concurrent_upgrades = max(max_concurrent_upgrades, active_upgrades)
|
||||
time.sleep(0.05)
|
||||
with active_lock:
|
||||
active_upgrades -= 1
|
||||
|
||||
monkeypatch.setattr(migrations.command, "upgrade", fake_upgrade)
|
||||
|
||||
errors = []
|
||||
|
||||
def run_in_thread(schema):
|
||||
try:
|
||||
start_barrier.wait()
|
||||
migrations._run_migrations_internal(
|
||||
"postgresql://user:pass@localhost/db",
|
||||
"/tmp/alembic",
|
||||
schema=schema,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - diagnostic path
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=run_in_thread, args=("tenant_alpha",)),
|
||||
threading.Thread(target=run_in_thread, args=("tenant_beta",)),
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert not errors
|
||||
assert max_concurrent_upgrades == 1
|
||||
@@ -455,3 +455,281 @@ class TestClearObservationsForMemory:
|
||||
assert await _get_consolidated_at(conn, m2) is None
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: update_document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _insert_document_with_memories(
|
||||
conn, bank_id: str, doc_id: str, memories: list[tuple[str, str]]
|
||||
) -> list[uuid.UUID]:
|
||||
"""Insert a document and attach memory units to it. Returns list of memory UUIDs."""
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at)
|
||||
VALUES ($1, $2, 'some doc', 'hash123', NOW(), NOW())
|
||||
""",
|
||||
doc_id,
|
||||
bank_id,
|
||||
)
|
||||
mem_ids = []
|
||||
for text, fact_type in memories:
|
||||
mem_id = uuid.uuid4()
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_units (id, bank_id, text, fact_type, event_date, document_id, created_at, updated_at, consolidated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), $5, NOW(), NOW(), NOW())
|
||||
""",
|
||||
mem_id,
|
||||
bank_id,
|
||||
text,
|
||||
fact_type,
|
||||
doc_id,
|
||||
)
|
||||
mem_ids.append(mem_id)
|
||||
return mem_ids
|
||||
|
||||
|
||||
class TestUpdateDocumentTagsObservationCleanup:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_returns_updated_document(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""update_document returns the updated document with new tags."""
|
||||
bank_id = f"test-tag-update-basic-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
await _insert_document_with_memories(conn, bank_id, doc_id, [("Alice loves hiking.", "experience")])
|
||||
|
||||
result = await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_returns_none_for_missing_document(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""update_document returns False when document does not exist."""
|
||||
bank_id = f"test-tag-update-missing-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
result = await memory.update_document(
|
||||
"nonexistent-doc", bank_id, tags=["tag"], request_context=request_context
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_propagates_to_memory_units(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Changing document tags also updates all associated memory unit tags."""
|
||||
bank_id = f"test-tag-update-propagate-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience"), ("Alice hikes weekly.", "world")]
|
||||
)
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for mem_id in mem_ids:
|
||||
tags = await conn.fetchval(
|
||||
"SELECT tags FROM memory_units WHERE id = $1", mem_id
|
||||
)
|
||||
assert list(tags) == ["new-tag"], f"Memory unit {mem_id} should have updated tags"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_invalidates_observations(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Observations referencing the document's memory units are deleted on tag change."""
|
||||
bank_id = f"test-tag-update-obs-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
obs_ids = await _get_observation_ids(conn, bank_id)
|
||||
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_resets_consolidated_at_on_affected_units(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Affected memory units get consolidated_at reset for re-consolidation under new tags."""
|
||||
bank_id = f"test-tag-update-reset-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
|
||||
|
||||
# Verify memory starts consolidated
|
||||
assert await _get_consolidated_at(conn, mem_ids[0]) is not None
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
consolidated_at = await _get_consolidated_at(conn, mem_ids[0])
|
||||
assert consolidated_at is None, "Memory unit should be reset for re-consolidation"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_triggers_consolidation_when_observations_invalidated(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""submit_async_consolidation is called when observations are invalidated."""
|
||||
bank_id = f"test-tag-update-cons-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
mock_consolidate.assert_awaited_once()
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_no_consolidation_when_no_observations(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""submit_async_consolidation is NOT called when no observations are invalidated."""
|
||||
bank_id = f"test-tag-update-nocons-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
# No observations inserted
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
mock_consolidate.assert_not_awaited()
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_resets_co_source_memories_from_other_documents(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Co-source memories from other documents that shared an invalidated observation are also reset."""
|
||||
bank_id = f"test-tag-update-cosource-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
doc_mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
# Unrelated memory from another document — co-sourced in the same observation
|
||||
other_mem = await _insert_memory(conn, bank_id, "Alice also rock-climbs.")
|
||||
obs_id = await _insert_observation(
|
||||
conn, bank_id, "Alice loves outdoor activities.", doc_mem_ids + [other_mem]
|
||||
)
|
||||
|
||||
# Verify other_mem starts consolidated
|
||||
assert await _get_consolidated_at(conn, other_mem) is not None
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
obs_ids = await _get_observation_ids(conn, bank_id)
|
||||
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
|
||||
|
||||
# other_mem (co-source from another document) must also be reset
|
||||
consolidated_at = await _get_consolidated_at(conn, other_mem)
|
||||
assert consolidated_at is None, "Co-source memory from other document should be reset"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_does_not_affect_unrelated_observations(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Observations referencing memories from a different document are not affected."""
|
||||
bank_id = f"test-tag-update-unrelated-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
|
||||
mem_ids = await _insert_document_with_memories(
|
||||
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
|
||||
)
|
||||
# Unrelated memory not in the document
|
||||
unrelated = await _insert_memory(conn, bank_id, "Bob likes cycling.")
|
||||
unrelated_obs_id = await _insert_observation(
|
||||
conn, bank_id, "Bob is a cyclist.", [unrelated]
|
||||
)
|
||||
|
||||
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
|
||||
await memory.update_document(
|
||||
doc_id, bank_id, tags=["new-tag"], request_context=request_context
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
obs_ids = await _get_observation_ids(conn, bank_id)
|
||||
assert str(unrelated_obs_id) in obs_ids, "Unrelated observation should remain untouched"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for source_facts token limiting in recall.
|
||||
|
||||
Covers:
|
||||
- max_source_facts_tokens: total token budget across all source facts
|
||||
- max_source_facts_tokens_per_observation: per-observation cap
|
||||
|
||||
Both parameters are tested at the recall_async level and verified to produce
|
||||
fewer source facts when the budget is tight vs. unlimited.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
|
||||
async def _setup_bank_with_observations(memory, bank_id, request_context):
|
||||
"""Retain several memories and trigger consolidation to produce observations with source facts."""
|
||||
contents = [
|
||||
"Alice is a software engineer who loves Python programming.",
|
||||
"Alice has been working at TechCorp for 5 years.",
|
||||
"Alice recently completed a machine learning certification course.",
|
||||
"Alice mentors junior developers on the team.",
|
||||
"Alice prefers functional programming patterns in her code.",
|
||||
]
|
||||
for content in contents:
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=content,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestRecallSourceFactsPerObservationCap:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_observation_cap_reduces_source_facts(self, memory, request_context):
|
||||
"""A tight per-observation token cap should return fewer source facts than unlimited."""
|
||||
bank_id = "test-sf-per-obs-cap"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result_limited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result_unlimited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens_per_observation=-1,
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
|
||||
limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0
|
||||
|
||||
if unlimited_count > 0:
|
||||
assert limited_count <= unlimited_count, (
|
||||
f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})"
|
||||
)
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context):
|
||||
"""Each observation's source facts are capped independently — not as a shared pool."""
|
||||
bank_id = "test-sf-per-obs-independent"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
# With a generous per-observation limit each observation can have facts;
|
||||
# with a global limit of 1 token the first observation would consume the whole budget.
|
||||
result_per_obs = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=4096, # large global budget
|
||||
max_source_facts_tokens_per_observation=512, # reasonable per-obs limit
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should not raise; source_facts may be populated for multiple observations
|
||||
assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestRecallSourceFactsTotalBudget:
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_budget_limits_source_facts(self, memory, request_context):
|
||||
"""A tight total token budget should return fewer source facts than unlimited."""
|
||||
bank_id = "test-sf-total-budget"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result_tight = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=1, # Effectively cuts all source facts
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result_unlimited = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=-1,
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
|
||||
tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0
|
||||
|
||||
if unlimited_count > 0:
|
||||
assert tight_count <= unlimited_count, (
|
||||
f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})"
|
||||
)
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_source_facts_without_flag(self, memory, request_context):
|
||||
"""source_facts should be None when include_source_facts is not set."""
|
||||
bank_id = "test-sf-no-flag"
|
||||
try:
|
||||
await _setup_bank_with_observations(memory, bank_id, request_context)
|
||||
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice engineer",
|
||||
fact_type=["observation"],
|
||||
max_tokens=4096,
|
||||
include_source_facts=False, # default
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result.source_facts is None or len(result.source_facts) == 0
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -210,8 +210,9 @@ paths:
|
||||
- Memory
|
||||
/v1/default/banks/{bank_id}/memories/{memory_id}:
|
||||
get:
|
||||
description: Get a single memory unit by ID with all its metadata including
|
||||
entities and tags.
|
||||
description: "Get a single memory unit by ID with all its metadata including\
|
||||
\ entities and tags. Note: the 'history' field is deprecated and always returns\
|
||||
\ an empty list - use GET /memories/{memory_id}/history instead."
|
||||
operationId: get_memory
|
||||
parameters:
|
||||
- explode: false
|
||||
@@ -253,6 +254,51 @@ paths:
|
||||
summary: Get memory unit
|
||||
tags:
|
||||
- Memory
|
||||
/v1/default/banks/{bank_id}/memories/{memory_id}/history:
|
||||
get:
|
||||
description: "Get the full history of an observation, with each change's source\
|
||||
\ facts resolved to their text."
|
||||
operationId: get_observation_history
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: memory_id
|
||||
required: true
|
||||
schema:
|
||||
title: Memory 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 observation history
|
||||
tags:
|
||||
- Memory
|
||||
/v1/default/banks/{bank_id}/memories/recall:
|
||||
post:
|
||||
description: |-
|
||||
@@ -838,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
|
||||
@@ -1344,6 +1435,61 @@ paths:
|
||||
summary: Get document details
|
||||
tags:
|
||||
- Documents
|
||||
patch:
|
||||
description: |-
|
||||
Update mutable fields on a document without re-processing its content.
|
||||
|
||||
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
|
||||
|
||||
At least one field must be provided.
|
||||
operationId: update_document
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: path
|
||||
name: document_id
|
||||
required: true
|
||||
schema:
|
||||
title: Document Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateDocumentRequest'
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateDocumentResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Update document
|
||||
tags:
|
||||
- Documents
|
||||
/v1/default/banks/{bank_id}/tags:
|
||||
get:
|
||||
description: "List all unique tags in a memory bank with usage counts. Supports\
|
||||
@@ -2492,9 +2638,14 @@ paths:
|
||||
|
||||
**Request format:** multipart/form-data with:
|
||||
- `files`: One or more files to upload
|
||||
- `request`: JSON string with FileRetainRequest model (files_metadata)
|
||||
- `request`: JSON string with FileRetainRequest model
|
||||
|
||||
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
**Parser selection:**
|
||||
- Set `parser` in the request body to override the server default for all files.
|
||||
- Set `parser` inside a `files_metadata` entry for per-file control.
|
||||
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
|
||||
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
|
||||
- Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
operationId: file_retain
|
||||
parameters:
|
||||
- explode: false
|
||||
@@ -4591,9 +4742,15 @@ components:
|
||||
properties:
|
||||
max_tokens:
|
||||
default: 4096
|
||||
description: Maximum tokens for source facts
|
||||
description: Maximum total tokens for source facts across all observations
|
||||
(-1 = unlimited)
|
||||
title: Max Tokens
|
||||
type: integer
|
||||
max_tokens_per_observation:
|
||||
default: -1
|
||||
description: Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
title: Max Tokens Per Observation
|
||||
type: integer
|
||||
title: SourceFactsIncludeOptions
|
||||
TagItem:
|
||||
description: Single tag with usage count.
|
||||
@@ -4689,6 +4846,29 @@ components:
|
||||
required:
|
||||
- disposition
|
||||
title: UpdateDispositionRequest
|
||||
UpdateDocumentRequest:
|
||||
description: Request model for updating a document's mutable fields.
|
||||
example:
|
||||
tags:
|
||||
- team-a
|
||||
- team-b
|
||||
properties:
|
||||
tags:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
title: UpdateDocumentRequest
|
||||
UpdateDocumentResponse:
|
||||
description: Response model for update document endpoint.
|
||||
example:
|
||||
success: true
|
||||
properties:
|
||||
success:
|
||||
default: true
|
||||
title: Success
|
||||
type: boolean
|
||||
title: UpdateDocumentResponse
|
||||
UpdateMentalModelRequest:
|
||||
description: Request model for updating a mental model.
|
||||
example:
|
||||
|
||||
@@ -591,3 +591,144 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (*
|
||||
|
||||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiUpdateDocumentRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *DocumentsAPIService
|
||||
bankId string
|
||||
documentId string
|
||||
updateDocumentRequest *UpdateDocumentRequest
|
||||
authorization *string
|
||||
}
|
||||
|
||||
func (r ApiUpdateDocumentRequest) UpdateDocumentRequest(updateDocumentRequest UpdateDocumentRequest) ApiUpdateDocumentRequest {
|
||||
r.updateDocumentRequest = &updateDocumentRequest
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiUpdateDocumentRequest) Authorization(authorization string) ApiUpdateDocumentRequest {
|
||||
r.authorization = &authorization
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiUpdateDocumentRequest) Execute() (*UpdateDocumentResponse, *http.Response, error) {
|
||||
return r.ApiService.UpdateDocumentExecute(r)
|
||||
}
|
||||
|
||||
/*
|
||||
UpdateDocument Update document
|
||||
|
||||
Update mutable fields on a document without re-processing its content.
|
||||
|
||||
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
|
||||
|
||||
At least one field must be provided.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@param documentId
|
||||
@return ApiUpdateDocumentRequest
|
||||
*/
|
||||
func (a *DocumentsAPIService) UpdateDocument(ctx context.Context, bankId string, documentId string) ApiUpdateDocumentRequest {
|
||||
return ApiUpdateDocumentRequest{
|
||||
ApiService: a,
|
||||
ctx: ctx,
|
||||
bankId: bankId,
|
||||
documentId: documentId,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the request
|
||||
// @return UpdateDocumentResponse
|
||||
func (a *DocumentsAPIService) UpdateDocumentExecute(r ApiUpdateDocumentRequest) (*UpdateDocumentResponse, *http.Response, error) {
|
||||
var (
|
||||
localVarHTTPMethod = http.MethodPatch
|
||||
localVarPostBody interface{}
|
||||
formFiles []formFile
|
||||
localVarReturnValue *UpdateDocumentResponse
|
||||
)
|
||||
|
||||
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DocumentsAPIService.UpdateDocument")
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
|
||||
}
|
||||
|
||||
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/documents/{document_id}"
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"document_id"+"}", url.PathEscape(parameterValueToString(r.documentId, "documentId")), -1)
|
||||
|
||||
localVarHeaderParams := make(map[string]string)
|
||||
localVarQueryParams := url.Values{}
|
||||
localVarFormParams := url.Values{}
|
||||
if r.updateDocumentRequest == nil {
|
||||
return localVarReturnValue, nil, reportError("updateDocumentRequest is required and must be specified")
|
||||
}
|
||||
|
||||
// to determine the Content-Type header
|
||||
localVarHTTPContentTypes := []string{"application/json"}
|
||||
|
||||
// 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", "")
|
||||
}
|
||||
// body params
|
||||
localVarPostBody = r.updateDocumentRequest
|
||||
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
|
||||
}
|
||||
|
||||
@@ -78,9 +78,14 @@ Use the operations endpoint to monitor progress.
|
||||
|
||||
**Request format:** multipart/form-data with:
|
||||
- `files`: One or more files to upload
|
||||
- `request`: JSON string with FileRetainRequest model (files_metadata)
|
||||
- `request`: JSON string with FileRetainRequest model
|
||||
|
||||
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
**Parser selection:**
|
||||
- Set `parser` in the request body to override the server default for all files.
|
||||
- Set `parser` inside a `files_metadata` entry for per-file control.
|
||||
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
|
||||
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
|
||||
- Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
|
||||
@@ -483,7 +483,7 @@ func (r ApiGetMemoryRequest) Execute() (interface{}, *http.Response, error) {
|
||||
/*
|
||||
GetMemory Get memory unit
|
||||
|
||||
Get a single memory unit by ID with all its metadata including entities and tags.
|
||||
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@@ -589,6 +589,132 @@ func (a *MemoryAPIService) GetMemoryExecute(r ApiGetMemoryRequest) (interface{},
|
||||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiGetObservationHistoryRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *MemoryAPIService
|
||||
bankId string
|
||||
memoryId string
|
||||
authorization *string
|
||||
}
|
||||
|
||||
func (r ApiGetObservationHistoryRequest) Authorization(authorization string) ApiGetObservationHistoryRequest {
|
||||
r.authorization = &authorization
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiGetObservationHistoryRequest) Execute() (interface{}, *http.Response, error) {
|
||||
return r.ApiService.GetObservationHistoryExecute(r)
|
||||
}
|
||||
|
||||
/*
|
||||
GetObservationHistory Get observation history
|
||||
|
||||
Get the full history of an observation, with each change's source facts resolved to their text.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@param memoryId
|
||||
@return ApiGetObservationHistoryRequest
|
||||
*/
|
||||
func (a *MemoryAPIService) GetObservationHistory(ctx context.Context, bankId string, memoryId string) ApiGetObservationHistoryRequest {
|
||||
return ApiGetObservationHistoryRequest{
|
||||
ApiService: a,
|
||||
ctx: ctx,
|
||||
bankId: bankId,
|
||||
memoryId: memoryId,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the request
|
||||
// @return interface{}
|
||||
func (a *MemoryAPIService) GetObservationHistoryExecute(r ApiGetObservationHistoryRequest) (interface{}, *http.Response, error) {
|
||||
var (
|
||||
localVarHTTPMethod = http.MethodGet
|
||||
localVarPostBody interface{}
|
||||
formFiles []formFile
|
||||
localVarReturnValue interface{}
|
||||
)
|
||||
|
||||
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.GetObservationHistory")
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
|
||||
}
|
||||
|
||||
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}/history"
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"memory_id"+"}", url.PathEscape(parameterValueToString(r.memoryId, "memoryId")), -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 ApiListMemoriesRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *MemoryAPIService
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,8 +19,10 @@ var _ MappedNullable = &SourceFactsIncludeOptions{}
|
||||
|
||||
// SourceFactsIncludeOptions Options for including source facts for observation-type results.
|
||||
type SourceFactsIncludeOptions struct {
|
||||
// Maximum tokens for source facts
|
||||
// Maximum total tokens for source facts across all observations (-1 = unlimited)
|
||||
MaxTokens *int32 `json:"max_tokens,omitempty"`
|
||||
// Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
MaxTokensPerObservation *int32 `json:"max_tokens_per_observation,omitempty"`
|
||||
}
|
||||
|
||||
// NewSourceFactsIncludeOptions instantiates a new SourceFactsIncludeOptions object
|
||||
@@ -31,6 +33,8 @@ func NewSourceFactsIncludeOptions() *SourceFactsIncludeOptions {
|
||||
this := SourceFactsIncludeOptions{}
|
||||
var maxTokens int32 = 4096
|
||||
this.MaxTokens = &maxTokens
|
||||
var maxTokensPerObservation int32 = -1
|
||||
this.MaxTokensPerObservation = &maxTokensPerObservation
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -41,6 +45,8 @@ func NewSourceFactsIncludeOptionsWithDefaults() *SourceFactsIncludeOptions {
|
||||
this := SourceFactsIncludeOptions{}
|
||||
var maxTokens int32 = 4096
|
||||
this.MaxTokens = &maxTokens
|
||||
var maxTokensPerObservation int32 = -1
|
||||
this.MaxTokensPerObservation = &maxTokensPerObservation
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -76,6 +82,38 @@ func (o *SourceFactsIncludeOptions) SetMaxTokens(v int32) {
|
||||
o.MaxTokens = &v
|
||||
}
|
||||
|
||||
// GetMaxTokensPerObservation returns the MaxTokensPerObservation field value if set, zero value otherwise.
|
||||
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservation() int32 {
|
||||
if o == nil || IsNil(o.MaxTokensPerObservation) {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
return *o.MaxTokensPerObservation
|
||||
}
|
||||
|
||||
// GetMaxTokensPerObservationOk returns a tuple with the MaxTokensPerObservation field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservationOk() (*int32, bool) {
|
||||
if o == nil || IsNil(o.MaxTokensPerObservation) {
|
||||
return nil, false
|
||||
}
|
||||
return o.MaxTokensPerObservation, true
|
||||
}
|
||||
|
||||
// HasMaxTokensPerObservation returns a boolean if a field has been set.
|
||||
func (o *SourceFactsIncludeOptions) HasMaxTokensPerObservation() bool {
|
||||
if o != nil && !IsNil(o.MaxTokensPerObservation) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetMaxTokensPerObservation gets a reference to the given int32 and assigns it to the MaxTokensPerObservation field.
|
||||
func (o *SourceFactsIncludeOptions) SetMaxTokensPerObservation(v int32) {
|
||||
o.MaxTokensPerObservation = &v
|
||||
}
|
||||
|
||||
func (o SourceFactsIncludeOptions) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -89,6 +127,9 @@ func (o SourceFactsIncludeOptions) ToMap() (map[string]interface{}, error) {
|
||||
if !IsNil(o.MaxTokens) {
|
||||
toSerialize["max_tokens"] = o.MaxTokens
|
||||
}
|
||||
if !IsNil(o.MaxTokensPerObservation) {
|
||||
toSerialize["max_tokens_per_observation"] = o.MaxTokensPerObservation
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// checks if the UpdateDocumentRequest type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &UpdateDocumentRequest{}
|
||||
|
||||
// UpdateDocumentRequest Request model for updating a document's mutable fields.
|
||||
type UpdateDocumentRequest struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// NewUpdateDocumentRequest instantiates a new UpdateDocumentRequest object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewUpdateDocumentRequest() *UpdateDocumentRequest {
|
||||
this := UpdateDocumentRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewUpdateDocumentRequestWithDefaults instantiates a new UpdateDocumentRequest object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewUpdateDocumentRequestWithDefaults() *UpdateDocumentRequest {
|
||||
this := UpdateDocumentRequest{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *UpdateDocumentRequest) GetTags() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.Tags
|
||||
}
|
||||
|
||||
// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *UpdateDocumentRequest) GetTagsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.Tags) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Tags, true
|
||||
}
|
||||
|
||||
// HasTags returns a boolean if a field has been set.
|
||||
func (o *UpdateDocumentRequest) HasTags() bool {
|
||||
if o != nil && !IsNil(o.Tags) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTags gets a reference to the given []string and assigns it to the Tags field.
|
||||
func (o *UpdateDocumentRequest) SetTags(v []string) {
|
||||
o.Tags = v
|
||||
}
|
||||
|
||||
func (o UpdateDocumentRequest) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o UpdateDocumentRequest) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
if o.Tags != nil {
|
||||
toSerialize["tags"] = o.Tags
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
type NullableUpdateDocumentRequest struct {
|
||||
value *UpdateDocumentRequest
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentRequest) Get() *UpdateDocumentRequest {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentRequest) Set(val *UpdateDocumentRequest) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentRequest) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentRequest) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableUpdateDocumentRequest(val *UpdateDocumentRequest) *NullableUpdateDocumentRequest {
|
||||
return &NullableUpdateDocumentRequest{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentRequest) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentRequest) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// checks if the UpdateDocumentResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &UpdateDocumentResponse{}
|
||||
|
||||
// UpdateDocumentResponse Response model for update document endpoint.
|
||||
type UpdateDocumentResponse struct {
|
||||
Success *bool `json:"success,omitempty"`
|
||||
}
|
||||
|
||||
// NewUpdateDocumentResponse instantiates a new UpdateDocumentResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewUpdateDocumentResponse() *UpdateDocumentResponse {
|
||||
this := UpdateDocumentResponse{}
|
||||
var success bool = true
|
||||
this.Success = &success
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewUpdateDocumentResponseWithDefaults instantiates a new UpdateDocumentResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewUpdateDocumentResponseWithDefaults() *UpdateDocumentResponse {
|
||||
this := UpdateDocumentResponse{}
|
||||
var success bool = true
|
||||
this.Success = &success
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetSuccess returns the Success field value if set, zero value otherwise.
|
||||
func (o *UpdateDocumentResponse) GetSuccess() bool {
|
||||
if o == nil || IsNil(o.Success) {
|
||||
var ret bool
|
||||
return ret
|
||||
}
|
||||
return *o.Success
|
||||
}
|
||||
|
||||
// GetSuccessOk returns a tuple with the Success field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *UpdateDocumentResponse) GetSuccessOk() (*bool, bool) {
|
||||
if o == nil || IsNil(o.Success) {
|
||||
return nil, false
|
||||
}
|
||||
return o.Success, true
|
||||
}
|
||||
|
||||
// HasSuccess returns a boolean if a field has been set.
|
||||
func (o *UpdateDocumentResponse) HasSuccess() bool {
|
||||
if o != nil && !IsNil(o.Success) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetSuccess gets a reference to the given bool and assigns it to the Success field.
|
||||
func (o *UpdateDocumentResponse) SetSuccess(v bool) {
|
||||
o.Success = &v
|
||||
}
|
||||
|
||||
func (o UpdateDocumentResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o UpdateDocumentResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
if !IsNil(o.Success) {
|
||||
toSerialize["success"] = o.Success
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
type NullableUpdateDocumentResponse struct {
|
||||
value *UpdateDocumentResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentResponse) Get() *UpdateDocumentResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentResponse) Set(val *UpdateDocumentResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableUpdateDocumentResponse(val *UpdateDocumentResponse) *NullableUpdateDocumentResponse {
|
||||
return &NullableUpdateDocumentResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableUpdateDocumentResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableUpdateDocumentResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ hindsight_client_api/models/token_usage.py
|
||||
hindsight_client_api/models/tool_calls_include_options.py
|
||||
hindsight_client_api/models/update_directive_request.py
|
||||
hindsight_client_api/models/update_disposition_request.py
|
||||
hindsight_client_api/models/update_document_request.py
|
||||
hindsight_client_api/models/update_document_response.py
|
||||
hindsight_client_api/models/update_mental_model_request.py
|
||||
hindsight_client_api/models/update_webhook_request.py
|
||||
hindsight_client_api/models/validation_error.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(
|
||||
|
||||
@@ -113,6 +113,8 @@ from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
||||
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
|
||||
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
|
||||
@@ -23,6 +23,8 @@ from hindsight_client_api.models.chunk_response import ChunkResponse
|
||||
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
|
||||
from hindsight_client_api.models.document_response import DocumentResponse
|
||||
from hindsight_client_api.models.list_documents_response import ListDocumentsResponse
|
||||
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
|
||||
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
|
||||
|
||||
from hindsight_client_api.api_client import ApiClient, RequestSerialized
|
||||
from hindsight_client_api.api_response import ApiResponse
|
||||
@@ -1268,3 +1270,324 @@ class DocumentsApi:
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def update_document(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
document_id: StrictStr,
|
||||
update_document_request: UpdateDocumentRequest,
|
||||
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,
|
||||
) -> UpdateDocumentResponse:
|
||||
"""Update document
|
||||
|
||||
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param document_id: (required)
|
||||
:type document_id: str
|
||||
:param update_document_request: (required)
|
||||
:type update_document_request: UpdateDocumentRequest
|
||||
: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._update_document_serialize(
|
||||
bank_id=bank_id,
|
||||
document_id=document_id,
|
||||
update_document_request=update_document_request,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "UpdateDocumentResponse",
|
||||
'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 update_document_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
document_id: StrictStr,
|
||||
update_document_request: UpdateDocumentRequest,
|
||||
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[UpdateDocumentResponse]:
|
||||
"""Update document
|
||||
|
||||
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param document_id: (required)
|
||||
:type document_id: str
|
||||
:param update_document_request: (required)
|
||||
:type update_document_request: UpdateDocumentRequest
|
||||
: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._update_document_serialize(
|
||||
bank_id=bank_id,
|
||||
document_id=document_id,
|
||||
update_document_request=update_document_request,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "UpdateDocumentResponse",
|
||||
'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 update_document_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
document_id: StrictStr,
|
||||
update_document_request: UpdateDocumentRequest,
|
||||
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:
|
||||
"""Update document
|
||||
|
||||
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param document_id: (required)
|
||||
:type document_id: str
|
||||
:param update_document_request: (required)
|
||||
:type update_document_request: UpdateDocumentRequest
|
||||
: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._update_document_serialize(
|
||||
bank_id=bank_id,
|
||||
document_id=document_id,
|
||||
update_document_request=update_document_request,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "UpdateDocumentResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
return response_data.response
|
||||
|
||||
|
||||
def _update_document_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
document_id,
|
||||
update_document_request,
|
||||
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 document_id is not None:
|
||||
_path_params['document_id'] = document_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
|
||||
if update_document_request is not None:
|
||||
_body_params = update_document_request
|
||||
|
||||
|
||||
# set the HTTP header `Accept`
|
||||
if 'Accept' not in _header_params:
|
||||
_header_params['Accept'] = self.api_client.select_header_accept(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
|
||||
# set the HTTP header `Content-Type`
|
||||
if _content_type:
|
||||
_header_params['Content-Type'] = _content_type
|
||||
else:
|
||||
_default_content_type = (
|
||||
self.api_client.select_header_content_type(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
)
|
||||
if _default_content_type is not None:
|
||||
_header_params['Content-Type'] = _default_content_type
|
||||
|
||||
# authentication setting
|
||||
_auth_settings: List[str] = [
|
||||
]
|
||||
|
||||
return self.api_client.param_serialize(
|
||||
method='PATCH',
|
||||
resource_path='/v1/default/banks/{bank_id}/documents/{document_id}',
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class FilesApi:
|
||||
) -> FileRetainResponse:
|
||||
"""Convert files to memories
|
||||
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
@@ -141,7 +141,7 @@ class FilesApi:
|
||||
) -> ApiResponse[FileRetainResponse]:
|
||||
"""Convert files to memories
|
||||
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
@@ -221,7 +221,7 @@ class FilesApi:
|
||||
) -> RESTResponseType:
|
||||
"""Convert files to memories
|
||||
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
|
||||
@@ -1022,7 +1022,7 @@ class MemoryApi:
|
||||
) -> object:
|
||||
"""Get memory unit
|
||||
|
||||
Get a single memory unit by ID with all its metadata including entities and tags.
|
||||
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
@@ -1098,7 +1098,7 @@ class MemoryApi:
|
||||
) -> ApiResponse[object]:
|
||||
"""Get memory unit
|
||||
|
||||
Get a single memory unit by ID with all its metadata including entities and tags.
|
||||
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
@@ -1174,7 +1174,7 @@ class MemoryApi:
|
||||
) -> RESTResponseType:
|
||||
"""Get memory unit
|
||||
|
||||
Get a single memory unit by ID with all its metadata including entities and tags.
|
||||
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
@@ -1294,6 +1294,299 @@ class MemoryApi:
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_observation_history(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
memory_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 observation history
|
||||
|
||||
Get the full history of an observation, with each change's source facts resolved to their text.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param memory_id: (required)
|
||||
:type memory_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_observation_history_serialize(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_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_observation_history_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
memory_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 observation history
|
||||
|
||||
Get the full history of an observation, with each change's source facts resolved to their text.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param memory_id: (required)
|
||||
:type memory_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_observation_history_serialize(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_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_observation_history_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
memory_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 observation history
|
||||
|
||||
Get the full history of an observation, with each change's source facts resolved to their text.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param memory_id: (required)
|
||||
:type memory_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_observation_history_serialize(
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_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_observation_history_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
memory_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 memory_id is not None:
|
||||
_path_params['memory_id'] = memory_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}/memories/{memory_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_memories(
|
||||
self,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,8 @@ from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
||||
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
|
||||
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
|
||||
+5
-3
@@ -26,8 +26,9 @@ class SourceFactsIncludeOptions(BaseModel):
|
||||
"""
|
||||
Options for including source facts for observation-type results.
|
||||
""" # noqa: E501
|
||||
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for source facts")
|
||||
__properties: ClassVar[List[str]] = ["max_tokens"]
|
||||
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)")
|
||||
max_tokens_per_observation: Optional[StrictInt] = Field(default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)")
|
||||
__properties: ClassVar[List[str]] = ["max_tokens", "max_tokens_per_observation"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -80,7 +81,8 @@ class SourceFactsIncludeOptions(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096,
|
||||
"max_tokens_per_observation": obj.get("max_tokens_per_observation") if obj.get("max_tokens_per_observation") is not None else -1
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.16
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class UpdateDocumentRequest(BaseModel):
|
||||
"""
|
||||
Request model for updating a document's mutable fields.
|
||||
""" # noqa: E501
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
__properties: ClassVar[List[str]] = ["tags"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDocumentRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if tags (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.tags is None and "tags" in self.model_fields_set:
|
||||
_dict['tags'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDocumentRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"tags": obj.get("tags")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.16
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictBool
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class UpdateDocumentResponse(BaseModel):
|
||||
"""
|
||||
Response model for update document endpoint.
|
||||
""" # noqa: E501
|
||||
success: Optional[StrictBool] = True
|
||||
__properties: ClassVar[List[str]] = ["success"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDocumentResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDocumentResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"success": obj.get("success") if obj.get("success") is not None else True
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -82,7 +82,13 @@ import type {
|
||||
GetMemoryResponses,
|
||||
GetMentalModelData,
|
||||
GetMentalModelErrors,
|
||||
GetMentalModelHistoryData,
|
||||
GetMentalModelHistoryErrors,
|
||||
GetMentalModelHistoryResponses,
|
||||
GetMentalModelResponses,
|
||||
GetObservationHistoryData,
|
||||
GetObservationHistoryErrors,
|
||||
GetObservationHistoryResponses,
|
||||
GetOperationStatusData,
|
||||
GetOperationStatusErrors,
|
||||
GetOperationStatusResponses,
|
||||
@@ -155,6 +161,9 @@ import type {
|
||||
UpdateDirectiveData,
|
||||
UpdateDirectiveErrors,
|
||||
UpdateDirectiveResponses,
|
||||
UpdateDocumentData,
|
||||
UpdateDocumentErrors,
|
||||
UpdateDocumentResponses,
|
||||
UpdateMentalModelData,
|
||||
UpdateMentalModelErrors,
|
||||
UpdateMentalModelResponses,
|
||||
@@ -252,7 +261,7 @@ export const listMemories = <ThrowOnError extends boolean = false>(
|
||||
/**
|
||||
* Get memory unit
|
||||
*
|
||||
* Get a single memory unit by ID with all its metadata including entities and tags.
|
||||
* Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
|
||||
*/
|
||||
export const getMemory = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetMemoryData, ThrowOnError>,
|
||||
@@ -263,6 +272,23 @@ export const getMemory = <ThrowOnError extends boolean = false>(
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/memories/{memory_id}", ...options });
|
||||
|
||||
/**
|
||||
* Get observation history
|
||||
*
|
||||
* Get the full history of an observation, with each change's source facts resolved to their text.
|
||||
*/
|
||||
export const getObservationHistory = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetObservationHistoryData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetObservationHistoryResponses,
|
||||
GetObservationHistoryErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Recall memory
|
||||
*
|
||||
@@ -483,6 +509,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
|
||||
*
|
||||
@@ -639,6 +682,31 @@ export const getDocument = <ThrowOnError extends boolean = false>(
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/documents/{document_id}", ...options });
|
||||
|
||||
/**
|
||||
* Update document
|
||||
*
|
||||
* Update mutable fields on a document without re-processing its content.
|
||||
*
|
||||
* **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
|
||||
*
|
||||
* At least one field must be provided.
|
||||
*/
|
||||
export const updateDocument = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateDocumentData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).patch<
|
||||
UpdateDocumentResponses,
|
||||
UpdateDocumentErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/documents/{document_id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List tags
|
||||
*
|
||||
@@ -1096,9 +1164,14 @@ export const retainMemories = <ThrowOnError extends boolean = false>(
|
||||
*
|
||||
* **Request format:** multipart/form-data with:
|
||||
* - `files`: One or more files to upload
|
||||
* - `request`: JSON string with FileRetainRequest model (files_metadata)
|
||||
* - `request`: JSON string with FileRetainRequest model
|
||||
*
|
||||
* **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
* **Parser selection:**
|
||||
* - Set `parser` in the request body to override the server default for all files.
|
||||
* - Set `parser` inside a `files_metadata` entry for per-file control.
|
||||
* - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
|
||||
* - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
|
||||
* - Only parsers enabled on the server may be requested; others return HTTP 400.
|
||||
*/
|
||||
export const fileRetain = <ThrowOnError extends boolean = false>(
|
||||
options: Options<FileRetainData, ThrowOnError>,
|
||||
|
||||
@@ -1960,9 +1960,15 @@ export type SourceFactsIncludeOptions = {
|
||||
/**
|
||||
* Max Tokens
|
||||
*
|
||||
* Maximum tokens for source facts
|
||||
* Maximum total tokens for source facts across all observations (-1 = unlimited)
|
||||
*/
|
||||
max_tokens?: number;
|
||||
/**
|
||||
* Max Tokens Per Observation
|
||||
*
|
||||
* Maximum tokens of source facts per observation (-1 = unlimited)
|
||||
*/
|
||||
max_tokens_per_observation?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2075,6 +2081,32 @@ export type UpdateDispositionRequest = {
|
||||
disposition: DispositionTraits;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateDocumentRequest
|
||||
*
|
||||
* Request model for updating a document's mutable fields.
|
||||
*/
|
||||
export type UpdateDocumentRequest = {
|
||||
/**
|
||||
* Tags
|
||||
*
|
||||
* New tags for the document and its memory units. Triggers observation invalidation and re-consolidation.
|
||||
*/
|
||||
tags?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateDocumentResponse
|
||||
*
|
||||
* Response model for update document endpoint.
|
||||
*/
|
||||
export type UpdateDocumentResponse = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateMentalModelRequest
|
||||
*
|
||||
@@ -2543,6 +2575,45 @@ export type GetMemoryResponses = {
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetObservationHistoryData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Memory Id
|
||||
*/
|
||||
memory_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history";
|
||||
};
|
||||
|
||||
export type GetObservationHistoryErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetObservationHistoryError =
|
||||
GetObservationHistoryErrors[keyof GetObservationHistoryErrors];
|
||||
|
||||
export type GetObservationHistoryResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type RecallMemoriesData = {
|
||||
body: RecallRequest;
|
||||
headers?: {
|
||||
@@ -3037,6 +3108,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?: {
|
||||
@@ -3451,6 +3561,48 @@ export type GetDocumentResponses = {
|
||||
export type GetDocumentResponse =
|
||||
GetDocumentResponses[keyof GetDocumentResponses];
|
||||
|
||||
export type UpdateDocumentData = {
|
||||
body: UpdateDocumentRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Document Id
|
||||
*/
|
||||
document_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/documents/{document_id}";
|
||||
};
|
||||
|
||||
export type UpdateDocumentErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateDocumentError =
|
||||
UpdateDocumentErrors[keyof UpdateDocumentErrors];
|
||||
|
||||
export type UpdateDocumentResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: UpdateDocumentResponse;
|
||||
};
|
||||
|
||||
export type UpdateDocumentResponse2 =
|
||||
UpdateDocumentResponses[keyof UpdateDocumentResponses];
|
||||
|
||||
export type ListTagsData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-*/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -3,8 +3,14 @@ import path from "path";
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||
|
||||
// Use a port-scoped distDir so multiple dev instances don't collide on the lock file
|
||||
const distDir = process.env.PORT && process.env.PORT !== '9999'
|
||||
? `.next-${process.env.PORT}`
|
||||
: '.next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
distDir,
|
||||
basePath: basePath,
|
||||
assetPrefix: basePath,
|
||||
// Disable request logging in production
|
||||
|
||||
+36
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,14 @@ export async function GET(
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`,
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${modelId}`,
|
||||
{ method: "GET", headers: getDataplaneHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("API error getting mental model:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to get mental model" },
|
||||
{ status: response.status }
|
||||
);
|
||||
console.error("API error getting observation:", errorText);
|
||||
return NextResponse.json({ error: "Failed to get observation" }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
|
||||
import { sdk, lowLevelClient, DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -26,6 +26,42 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ documentId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { documentId } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const bankId = searchParams.get("bank_id");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
return NextResponse.json(error, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error updating document tags:", error);
|
||||
return NextResponse.json({ error: "Failed to update document tags" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ documentId: string }> }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ memoryId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { memoryId } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const bankId = searchParams.get("bank_id");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${memoryId}/history`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json({ error: "Memory 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 observation history:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch observation history" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ type RetainEdits = {
|
||||
|
||||
type ObservationsEdits = {
|
||||
enable_observations: boolean | null;
|
||||
consolidation_llm_batch_size: number | null;
|
||||
consolidation_source_facts_max_tokens: number | null;
|
||||
consolidation_source_facts_max_tokens_per_observation: number | null;
|
||||
observations_mission: string | null;
|
||||
};
|
||||
|
||||
@@ -143,6 +146,10 @@ function retainSlice(config: Record<string, any>): RetainEdits {
|
||||
function observationsSlice(config: Record<string, any>): ObservationsEdits {
|
||||
return {
|
||||
enable_observations: config.enable_observations ?? null,
|
||||
consolidation_llm_batch_size: config.consolidation_llm_batch_size ?? null,
|
||||
consolidation_source_facts_max_tokens: config.consolidation_source_facts_max_tokens ?? null,
|
||||
consolidation_source_facts_max_tokens_per_observation:
|
||||
config.consolidation_source_facts_max_tokens_per_observation ?? null,
|
||||
observations_mission: config.observations_mission ?? null,
|
||||
};
|
||||
}
|
||||
@@ -489,7 +496,7 @@ export function BankConfigView() {
|
||||
>
|
||||
<FieldRow
|
||||
label="Free Form Entities"
|
||||
description="Extract regular named entities (people, places, concepts) alongside label groups. Disable to restrict extraction to label groups only."
|
||||
description="Extract regular named entities (people, places, concepts) alongside entity labels. Disable to restrict extraction to entity labels only."
|
||||
>
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<Label
|
||||
@@ -550,6 +557,64 @@ export function BankConfigView() {
|
||||
placeholder="e.g. Observations are stable facts about people and projects. Always include preferences, skills, and recurring patterns. Ignore one-off events and ephemeral state."
|
||||
rows={3}
|
||||
/>
|
||||
<FieldRow
|
||||
label="LLM Batch Size"
|
||||
description="Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls at the cost of larger prompts. Leave blank to use the server default."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={64}
|
||||
value={observationsEdits.consolidation_llm_batch_size ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_llm_batch_size: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label="Source Facts Max Tokens"
|
||||
description="Total token budget for source facts included with observations during consolidation. -1 = unlimited."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
value={observationsEdits.consolidation_source_facts_max_tokens ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_source_facts_max_tokens: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label="Source Facts Max Tokens Per Observation"
|
||||
description="Per-observation token cap for source facts during consolidation. Each observation gets at most this many tokens of source facts. -1 = unlimited."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
value={observationsEdits.consolidation_source_facts_max_tokens_per_observation ?? ""}
|
||||
onChange={(e) =>
|
||||
setObservationsEdits((prev) => ({
|
||||
...prev,
|
||||
consolidation_source_facts_max_tokens_per_observation: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
placeholder="Server default"
|
||||
/>
|
||||
</FieldRow>
|
||||
</ConfigSection>
|
||||
|
||||
{/* Reflect Section */}
|
||||
@@ -1015,7 +1080,7 @@ function EntityLabelsEditor({
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Label Groups</p>
|
||||
<p className="text-sm font-medium">Entity Labels</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Classification labels extracted at retain time. Leave empty to disable.
|
||||
</p>
|
||||
@@ -1028,7 +1093,7 @@ function EntityLabelsEditor({
|
||||
</div>
|
||||
|
||||
{value.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">No label groups defined.</p>
|
||||
<p className="text-xs text-muted-foreground italic">No entity labels defined.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -280,7 +280,7 @@ export function DataView({ factType }: DataViewProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading ? (
|
||||
{loading && !data ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<p className="text-muted-foreground">Loading memories...</p>
|
||||
@@ -370,11 +370,11 @@ export function DataView({ factType }: DataViewProps) {
|
||||
|
||||
{/* Consolidation status for observations */}
|
||||
{factType === "observation" && consolidationStatus && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium border ${
|
||||
consolidationStatus.pending_consolidation === 0
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
|
||||
? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20"
|
||||
: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20"
|
||||
}`}
|
||||
title={
|
||||
consolidationStatus.pending_consolidation === 0
|
||||
@@ -391,9 +391,23 @@ export function DataView({ factType }: DataViewProps) {
|
||||
<>
|
||||
<Clock className="w-3 h-3" />
|
||||
{consolidationStatus.pending_consolidation} Pending
|
||||
<button
|
||||
onClick={() =>
|
||||
loadData(
|
||||
fetchLimit,
|
||||
searchQuery || undefined,
|
||||
tagFilters.length > 0 ? tagFilters : undefined
|
||||
)
|
||||
}
|
||||
disabled={loading}
|
||||
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
|
||||
title="Refresh observations"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
@@ -874,6 +888,7 @@ export function DataView({ factType }: DataViewProps) {
|
||||
data={data}
|
||||
filteredRows={filteredTableRows}
|
||||
bankId={currentBank || undefined}
|
||||
onMemoryClick={(id) => setModalMemoryId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -899,12 +914,13 @@ function TimelineView({
|
||||
data,
|
||||
filteredRows,
|
||||
bankId,
|
||||
onMemoryClick,
|
||||
}: {
|
||||
data: any;
|
||||
filteredRows: any[];
|
||||
bankId?: string;
|
||||
onMemoryClick: (id: string) => void;
|
||||
}) {
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [granularity, setGranularity] = useState<Granularity>("month");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1182,10 +1198,8 @@ function TimelineView({
|
||||
{group.items.map((item: any, idx: number) => (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`flex items-start cursor-pointer group ${
|
||||
selectedItem?.id === item.id ? "opacity-100" : "hover:opacity-80"
|
||||
}`}
|
||||
onClick={() => onMemoryClick(item.id)}
|
||||
className={`flex items-start cursor-pointer group ${"hover:opacity-80"}`}
|
||||
>
|
||||
{/* Date & Time */}
|
||||
<div className="w-[60px] text-right pr-3 pt-1 flex-shrink-0">
|
||||
@@ -1200,21 +1214,13 @@ function TimelineView({
|
||||
{/* Connector dot */}
|
||||
<div className="flex-shrink-0 pt-2">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full z-10 ${
|
||||
selectedItem?.id === item.id
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/50 group-hover:bg-primary"
|
||||
}`}
|
||||
className={`w-1.5 h-1.5 rounded-full z-10 ${"bg-muted-foreground/50 group-hover:bg-primary"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className={`ml-3 flex-1 p-2 rounded border transition-colors ${
|
||||
selectedItem?.id === item.id
|
||||
? "bg-primary/10 border-primary"
|
||||
: "bg-card border-border hover:border-primary/50"
|
||||
}`}
|
||||
className={`ml-3 flex-1 p-2 rounded border transition-colors ${"bg-card border-border hover:border-primary/50"}`}
|
||||
>
|
||||
<p className="text-xs text-foreground line-clamp-2 leading-relaxed">
|
||||
{item.text}
|
||||
@@ -1252,18 +1258,6 @@ function TimelineView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel - Fixed on Right */}
|
||||
{selectedItem && (
|
||||
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
|
||||
<MemoryDetailPanel
|
||||
memory={selectedItem}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
inPanel
|
||||
bankId={bankId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,16 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { X, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Pencil,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
@@ -44,6 +53,11 @@ export function DocumentsView() {
|
||||
const [loadingDocument, setLoadingDocument] = useState(false);
|
||||
const [deletingDocumentId, setDeletingDocumentId] = useState<string | null>(null);
|
||||
|
||||
// Tag editing state
|
||||
const [editingTags, setEditingTags] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
|
||||
// Delete confirmation dialog state
|
||||
const [documentToDelete, setDocumentToDelete] = useState<{
|
||||
id: string;
|
||||
@@ -85,6 +99,8 @@ export function DocumentsView() {
|
||||
|
||||
setLoadingDocument(true);
|
||||
setSelectedDocument({ id: documentId }); // Set placeholder to show loading
|
||||
setEditingTags(false);
|
||||
setTagInput("");
|
||||
|
||||
try {
|
||||
const doc: any = await client.getDocument(documentId, currentBank);
|
||||
@@ -133,6 +149,41 @@ export function DocumentsView() {
|
||||
setDocumentToDelete({ id: documentId, memoryCount });
|
||||
};
|
||||
|
||||
const startEditTags = () => {
|
||||
setTagInput((selectedDocument?.tags ?? []).join(", "));
|
||||
setEditingTags(true);
|
||||
};
|
||||
|
||||
const cancelEditTags = () => {
|
||||
setEditingTags(false);
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const saveDocumentTags = async () => {
|
||||
if (!currentBank || !selectedDocument) return;
|
||||
|
||||
const newTags = tagInput
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
setSavingTags(true);
|
||||
try {
|
||||
await client.updateDocument(selectedDocument.id, currentBank, newTags);
|
||||
setSelectedDocument({ ...selectedDocument, tags: newTags });
|
||||
// Update tags in the documents list too
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.id === selectedDocument.id ? { ...d, tags: newTags } : d))
|
||||
);
|
||||
setEditingTags(false);
|
||||
setTagInput("");
|
||||
} catch (error) {
|
||||
console.error("Error updating document tags:", error);
|
||||
} finally {
|
||||
setSavingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-load documents when component mounts or bank changes
|
||||
useEffect(() => {
|
||||
if (currentBank) {
|
||||
@@ -417,11 +468,64 @@ export function DocumentsView() {
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{selectedDocument.tags && selectedDocument.tags.length > 0 && (
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Tags
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase">Tags</div>
|
||||
{!editingTags && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startEditTags}
|
||||
className="h-6 px-2 gap-1 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editingTags ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
className="text-sm h-8"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveDocumentTags();
|
||||
if (e.key === "Escape") cancelEditTags();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated. Leave empty to remove all tags.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveDocumentTags}
|
||||
disabled={savingTags}
|
||||
className="h-7 px-3 gap-1 text-xs"
|
||||
>
|
||||
{savingTags ? (
|
||||
<span className="animate-spin">⏳</span>
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelEditTags}
|
||||
disabled={savingTags}
|
||||
className="h-7 px-3 gap-1 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedDocument.tags && selectedDocument.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedDocument.tags.map((tag: string, i: number) => (
|
||||
<span
|
||||
@@ -432,8 +536,10 @@ export function DocumentsView() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No tags</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
|
||||
@@ -5,9 +5,10 @@ import { client } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Loader2, Calendar, Users, FileText, Layers, Tag } from "lucide-react";
|
||||
import { Loader2, Calendar, Users, FileText, Layers, Tag, History } from "lucide-react";
|
||||
import { TagList } from "@/components/ui/tag-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ObservationHistoryView, type HistoryEntry } from "@/components/observation-history-view";
|
||||
|
||||
interface SourceMemory {
|
||||
id: string;
|
||||
@@ -38,14 +39,15 @@ interface MemoryDetail {
|
||||
interface MemoryDetailModalProps {
|
||||
memoryId: string | null;
|
||||
onClose: () => void;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) {
|
||||
export function MemoryDetailModal({ memoryId, onClose, initialTab }: MemoryDetailModalProps) {
|
||||
const { currentBank } = useBank();
|
||||
const [memory, setMemory] = useState<MemoryDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("memory");
|
||||
const [activeTab, setActiveTab] = useState(initialTab ?? "memory");
|
||||
|
||||
// Document and chunk data
|
||||
const [document, setDocument] = useState<any>(null);
|
||||
@@ -53,6 +55,10 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
const [loadingDocument, setLoadingDocument] = useState(false);
|
||||
const [loadingChunk, setLoadingChunk] = useState(false);
|
||||
|
||||
// History data (fetched lazily from dedicated endpoint)
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
// Source memory modal (for viewing source memories of observations)
|
||||
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
|
||||
|
||||
@@ -66,7 +72,8 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
setMemory(null);
|
||||
setDocument(null);
|
||||
setChunk(null);
|
||||
setActiveTab("memory");
|
||||
setHistory(null);
|
||||
setActiveTab(initialTab ?? "memory");
|
||||
|
||||
try {
|
||||
const data = await client.getMemory(memoryId, currentBank);
|
||||
@@ -82,6 +89,33 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
loadMemory();
|
||||
}, [memoryId, currentBank]);
|
||||
|
||||
// Load history lazily when history tab is selected
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "history" ||
|
||||
!memory ||
|
||||
memory.type !== "observation" ||
|
||||
!currentBank ||
|
||||
history !== null
|
||||
)
|
||||
return;
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const data = await client.getObservationHistory(memory.id, currentBank);
|
||||
setHistory(data);
|
||||
} catch (err) {
|
||||
console.error("Error loading history:", err);
|
||||
setHistory([]);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
}, [activeTab, memory, currentBank, history]);
|
||||
|
||||
// Load document when tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab !== "document" || !memory?.document_id || !currentBank || document) return;
|
||||
@@ -152,162 +186,208 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
</div>
|
||||
) : memory ? (
|
||||
isObservation ? (
|
||||
/* Observation view - no tabs since chunk/document don't apply */
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{/* Text */}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">Text</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
|
||||
</div>
|
||||
/* Observation view - tabs for Info and History */
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="memory" className="flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Observation
|
||||
</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>
|
||||
|
||||
{/* Dates */}
|
||||
{memory.occurred_start && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Occurred
|
||||
<div className="flex-1 overflow-y-auto mt-4">
|
||||
<TabsContent value="memory" className="mt-0 space-y-4">
|
||||
{/* Text */}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Text
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span>
|
||||
{new Date(memory.occurred_start).toLocaleString()}
|
||||
{memory.occurred_end && memory.occurred_end !== memory.occurred_start && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1">→</span>
|
||||
{new Date(memory.occurred_end).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.mentioned_at && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Mentioned
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span>{new Date(memory.mentioned_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entities */}
|
||||
{memory.entities && memory.entities.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
Entities
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{memory.entities.map((entity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
|
||||
>
|
||||
{entity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<TagList tags={memory.tags} showLabel />
|
||||
|
||||
{/* Observation Scopes */}
|
||||
{memory.observation_scopes && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
Observation Scopes
|
||||
</div>
|
||||
{typeof memory.observation_scopes === "string" ? (
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{memory.observation_scopes}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(memory.observation_scopes as string[][]).map((scope, i) => (
|
||||
<TagList key={i} tags={scope} />
|
||||
))}
|
||||
{/* Dates */}
|
||||
{memory.occurred_start && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Occurred
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span>
|
||||
{new Date(memory.occurred_start).toLocaleString()}
|
||||
{memory.occurred_end &&
|
||||
memory.occurred_end !== memory.occurred_start && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1">→</span>
|
||||
{new Date(memory.occurred_end).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Memories */}
|
||||
{memory.source_memories && memory.source_memories.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-3">
|
||||
Source Memories ({memory.source_memories.length})
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{memory.source_memories.map((source, i) => (
|
||||
<div
|
||||
key={source.id || i}
|
||||
className="p-3 bg-muted/50 rounded-lg border border-border/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs flex-shrink-0 ${
|
||||
source.type === "experience"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{source.type}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setSourceMemoryModalId(source.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-foreground mb-2">{source.text}</p>
|
||||
{source.context && (
|
||||
<p className="text-xs text-muted-foreground mb-2 italic">
|
||||
Context: {source.context}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{source.occurred_start && (
|
||||
<div className="p-2 bg-background/50 rounded">
|
||||
<div className="text-muted-foreground mb-0.5">Occurred</div>
|
||||
<div className="font-medium">
|
||||
{new Date(source.occurred_start).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{source.mentioned_at && (
|
||||
<div className="p-2 bg-background/50 rounded">
|
||||
<div className="text-muted-foreground mb-0.5">Mentioned</div>
|
||||
<div className="font-medium">
|
||||
{new Date(source.mentioned_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{memory.mentioned_at && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Mentioned
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span>{new Date(memory.mentioned_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ID */}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
|
||||
Memory ID
|
||||
</div>
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{memory.id}
|
||||
</code>
|
||||
{/* Entities */}
|
||||
{memory.entities && memory.entities.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
Entities
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{memory.entities.map((entity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
|
||||
>
|
||||
{entity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<TagList tags={memory.tags} showLabel />
|
||||
|
||||
{/* Observation Scopes */}
|
||||
{memory.observation_scopes && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
Observation Scopes
|
||||
</div>
|
||||
{typeof memory.observation_scopes === "string" ? (
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{memory.observation_scopes}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(memory.observation_scopes as string[][]).map((scope, i) => (
|
||||
<TagList key={i} tags={scope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Memories */}
|
||||
{memory.source_memories && memory.source_memories.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-3">
|
||||
Source Memories ({memory.source_memories.length})
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{memory.source_memories.map((source, i) => (
|
||||
<div
|
||||
key={source.id || i}
|
||||
className="p-3 bg-muted/50 rounded-lg border border-border/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs flex-shrink-0 ${
|
||||
source.type === "experience"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{source.type}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setSourceMemoryModalId(source.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-foreground mb-2">{source.text}</p>
|
||||
{source.context && (
|
||||
<p className="text-xs text-muted-foreground mb-2 italic">
|
||||
Context: {source.context}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{source.occurred_start && (
|
||||
<div className="p-2 bg-background/50 rounded">
|
||||
<div className="text-muted-foreground mb-0.5">Occurred</div>
|
||||
<div className="font-medium">
|
||||
{new Date(source.occurred_start).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{source.mentioned_at && (
|
||||
<div className="p-2 bg-background/50 rounded">
|
||||
<div className="text-muted-foreground mb-0.5">Mentioned</div>
|
||||
<div className="font-medium">
|
||||
{new Date(source.mentioned_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ID */}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
|
||||
Memory ID
|
||||
</div>
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{memory.id}
|
||||
</code>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="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 ? (
|
||||
<ObservationHistoryView
|
||||
history={history}
|
||||
current={{
|
||||
text: memory.text,
|
||||
tags: memory.tags,
|
||||
occurred_start: memory.occurred_start,
|
||||
occurred_end: memory.occurred_end,
|
||||
mentioned_at: memory.mentioned_at,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history recorded yet.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
) : (
|
||||
/* World/Experience view - with tabs */
|
||||
<Tabs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TagList } from "@/components/ui/tag-list";
|
||||
import { Copy, Check, X, Loader2, Calendar } from "lucide-react";
|
||||
import { Copy, Check, X, Loader2, Calendar, History } from "lucide-react";
|
||||
import { DocumentChunkModal } from "./document-chunk-modal";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { client } from "@/lib/api";
|
||||
@@ -29,6 +29,7 @@ export function MemoryDetailPanel({
|
||||
const [fullMemory, setFullMemory] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
|
||||
// Fetch full memory data when panel opens
|
||||
// For mental models, use getMentalModel to get source memories
|
||||
@@ -296,6 +297,20 @@ export function MemoryDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View History button (observations only) */}
|
||||
{isObservation && (
|
||||
<div className="border-t border-border pt-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-2"
|
||||
onClick={() => setHistoryModalOpen(true)}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
View History
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory ID */}
|
||||
{memoryId && (
|
||||
<div>
|
||||
@@ -333,6 +348,15 @@ export function MemoryDetailPanel({
|
||||
memoryId={sourceMemoryModalId}
|
||||
onClose={() => setSourceMemoryModalId(null)}
|
||||
/>
|
||||
|
||||
{/* History Modal */}
|
||||
{historyModalOpen && memoryId && bankId && (
|
||||
<MemoryDetailModal
|
||||
memoryId={memoryId}
|
||||
onClose={() => setHistoryModalOpen(false)}
|
||||
initialTab="history"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface HistoryEntry {
|
||||
previous_text: string;
|
||||
previous_tags: string[];
|
||||
previous_occurred_start: string | null;
|
||||
previous_occurred_end: string | null;
|
||||
previous_mentioned_at: string | null;
|
||||
changed_at: string;
|
||||
new_source_memory_ids: string[];
|
||||
source_facts?: {
|
||||
id: string;
|
||||
text: string | null;
|
||||
type: string | null;
|
||||
context: string | null;
|
||||
is_new: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface CurrentState {
|
||||
text: string;
|
||||
tags: string[];
|
||||
occurred_start: string | null;
|
||||
occurred_end: string | null;
|
||||
mentioned_at: string | null;
|
||||
}
|
||||
|
||||
function diffWords(a: string, b: string): { type: "same" | "removed" | "added"; text: string }[] {
|
||||
const aWords = a.split(/(\s+)/);
|
||||
const bWords = b.split(/(\s+)/);
|
||||
const m = aWords.length;
|
||||
const n = bWords.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] =
|
||||
aWords[i - 1] === bWords[j - 1]
|
||||
? dp[i - 1][j - 1] + 1
|
||||
: Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
let i = m,
|
||||
j = n;
|
||||
const ops: { type: "same" | "removed" | "added"; text: string }[] = [];
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && aWords[i - 1] === bWords[j - 1]) {
|
||||
ops.push({ type: "same", text: aWords[i - 1] });
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.push({ type: "added", text: bWords[j - 1] });
|
||||
j--;
|
||||
} else {
|
||||
ops.push({ type: "removed", text: aWords[i - 1] });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
return ops.reverse();
|
||||
}
|
||||
|
||||
function TextDiff({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffWords(before, after);
|
||||
const hasChanges = parts.some((p) => p.type !== "same");
|
||||
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
|
||||
return (
|
||||
<span className="text-sm leading-relaxed">
|
||||
{parts.map((part, idx) =>
|
||||
part.type === "same" ? (
|
||||
<span key={idx}>{part.text}</span>
|
||||
) : part.type === "removed" ? (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5"
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5"
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
|
||||
const removed = before.filter((t) => !after.includes(t));
|
||||
const added = after.filter((t) => !before.includes(t));
|
||||
const kept = before.filter((t) => after.includes(t));
|
||||
if (removed.length === 0 && added.length === 0)
|
||||
return <span className="text-sm text-muted-foreground italic">unchanged</span>;
|
||||
return (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{kept.map((t, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-700 border border-amber-500/20 font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
{removed.map((t, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-md bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/20 font-mono line-through"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
{added.map((t, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-md bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/20 font-mono"
|
||||
>
|
||||
+#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateDiff({
|
||||
label,
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
label: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
}) {
|
||||
if (!before && !after) return null;
|
||||
const changed = before !== after;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{label}: </span>
|
||||
{changed ? (
|
||||
<>
|
||||
<span className="text-xs bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5">
|
||||
{before ? new Date(before).toLocaleString() : "—"}
|
||||
</span>
|
||||
{" → "}
|
||||
<span className="text-xs bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5">
|
||||
{after ? new Date(after).toLocaleString() : "—"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs">{after ? new Date(after).toLocaleString() : "—"}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceFactItem({ fact }: { fact: NonNullable<HistoryEntry["source_facts"]>[number] }) {
|
||||
const typeColors =
|
||||
fact.type === "experience"
|
||||
? "bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
: "bg-blue-500/10 text-blue-700 dark:text-blue-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-2 rounded border space-y-1 ${
|
||||
fact.is_new ? "border-green-500/40 bg-green-500/5" : "border-border/50 bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{fact.type && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0 ${typeColors}`}
|
||||
>
|
||||
{fact.type}
|
||||
</span>
|
||||
)}
|
||||
{fact.is_new && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/30">
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{fact.context && (
|
||||
<span className="text-[10px] text-muted-foreground italic truncate">{fact.context}</span>
|
||||
)}
|
||||
</div>
|
||||
{fact.text ? (
|
||||
<p className="text-xs text-foreground leading-relaxed">{fact.text}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">(memory no longer available)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationHistoryView({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: HistoryEntry[];
|
||||
current: CurrentState;
|
||||
}) {
|
||||
// index 0 = most recent change
|
||||
const entries = [...history].reverse();
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
const entry = entries[idx];
|
||||
const isLatest = idx === 0;
|
||||
const afterText = isLatest ? current.text : entries[idx - 1].previous_text;
|
||||
const afterTags = isLatest ? current.tags : entries[idx - 1].previous_tags;
|
||||
const afterOccurredStart = isLatest
|
||||
? current.occurred_start
|
||||
: entries[idx - 1].previous_occurred_start;
|
||||
const afterOccurredEnd = isLatest ? current.occurred_end : entries[idx - 1].previous_occurred_end;
|
||||
const afterMentionedAt = isLatest ? current.mentioned_at : entries[idx - 1].previous_mentioned_at;
|
||||
|
||||
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 === entries.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 */}
|
||||
<div className="border border-border rounded-lg p-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Text</div>
|
||||
<TextDiff before={entry.previous_text} after={afterText} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Tags</div>
|
||||
<TagsDiff before={entry.previous_tags} after={afterTags} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Dates</div>
|
||||
<DateDiff
|
||||
label="Occurred start"
|
||||
before={entry.previous_occurred_start}
|
||||
after={afterOccurredStart}
|
||||
/>
|
||||
<DateDiff
|
||||
label="Occurred end"
|
||||
before={entry.previous_occurred_end}
|
||||
after={afterOccurredEnd}
|
||||
/>
|
||||
<DateDiff
|
||||
label="Mentioned at"
|
||||
before={entry.previous_mentioned_at}
|
||||
after={afterMentionedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{entry.source_facts && entry.source_facts.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
|
||||
Source Facts ({entry.source_facts.length})
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{entry.source_facts.map((fact) => (
|
||||
<SourceFactItem key={fact.id} fact={fact} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -336,6 +336,20 @@ export class ControlPlaneClient {
|
||||
return this.fetchApi(`/api/documents/${documentId}?bank_id=${bankId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tags on a document and its associated memory units
|
||||
*/
|
||||
async updateDocument(documentId: string, bankId: string, tags: string[]) {
|
||||
return this.fetchApi<{ success: boolean }>(
|
||||
`/api/documents/${encodeURIComponent(documentId)}?bank_id=${bankId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tags }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete document and all its associated memory units
|
||||
*/
|
||||
@@ -413,9 +427,42 @@ export class ControlPlaneClient {
|
||||
chunk_id: string | null;
|
||||
tags: string[];
|
||||
observation_scopes: string | string[][] | null;
|
||||
history?: {
|
||||
previous_text: string;
|
||||
previous_tags: string[];
|
||||
previous_occurred_start: string | null;
|
||||
previous_occurred_end: string | null;
|
||||
previous_mentioned_at: string | null;
|
||||
changed_at: string;
|
||||
new_source_memory_ids: string[];
|
||||
}[];
|
||||
}>(`/api/memories/${memoryId}?bank_id=${bankId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the history of an observation with resolved source facts
|
||||
*/
|
||||
async getObservationHistory(memoryId: string, bankId: string) {
|
||||
return this.fetchApi<
|
||||
{
|
||||
previous_text: string;
|
||||
previous_tags: string[];
|
||||
previous_occurred_start: string | null;
|
||||
previous_occurred_end: string | null;
|
||||
previous_mentioned_at: string | null;
|
||||
changed_at: string;
|
||||
new_source_memory_ids: string[];
|
||||
source_facts: {
|
||||
id: string;
|
||||
text: string | null;
|
||||
type: string | null;
|
||||
context: string | null;
|
||||
is_new: boolean;
|
||||
}[];
|
||||
}[]
|
||||
>(`/api/memories/${memoryId}/history?bank_id=${bankId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bank profile
|
||||
*/
|
||||
@@ -780,6 +827,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
|
||||
|
||||
@@ -34,7 +34,25 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"types/**/*.d.ts"
|
||||
"types/**/*.d.ts",
|
||||
".next-*/types/**/*.ts",
|
||||
".next-*/dev/types/**/*.ts",
|
||||
".next-49944/types/**/*.ts",
|
||||
".next-49944/dev/types/**/*.ts",
|
||||
".next-50612/types/**/*.ts",
|
||||
".next-50612/dev/types/**/*.ts",
|
||||
".next-54508/types/**/*.ts",
|
||||
".next-54508/dev/types/**/*.ts",
|
||||
".next-55630/types/**/*.ts",
|
||||
".next-55630/dev/types/**/*.ts",
|
||||
".next-58976/types/**/*.ts",
|
||||
".next-58976/dev/types/**/*.ts",
|
||||
".next-64080/types/**/*.ts",
|
||||
".next-64080/dev/types/**/*.ts",
|
||||
".next-50432/types/**/*.ts",
|
||||
".next-50432/dev/types/**/*.ts",
|
||||
".next-54840/types/**/*.ts",
|
||||
".next-54840/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
title: "The Memory Upgrade Every OpenClaw User Needs"
|
||||
authors: [hindsight]
|
||||
date: 2026-03-06
|
||||
tags: [openclaw]
|
||||
image: /img/blog/adding-memory-to-openclaw-with-hindsight.png
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
- OpenClaw's built-in memory is file-based -- markdown files on disk with SQLite vector search. It works, but the agent has to decide what to remember. Hindsight automates the entire pipeline.
|
||||
- Hindsight is open source and runs locally by default. Your conversations, extracted knowledge, and memory store never leave your machine unless you choose otherwise.
|
||||
- One plugin install, three commands to set up. The `hindsight-embed` daemon bundles the full memory engine (API + PostgreSQL) into a single process.
|
||||
- Memories auto-inject into context before each response -- no tool calls, no retrieval logic to write.
|
||||
- For teams, an external API mode connects to a shared Hindsight server so multiple OpenClaw instances can share memory.
|
||||
|
||||
## The Problem
|
||||
|
||||
OpenClaw is an always-on AI assistant that lives in your messaging apps -- WhatsApp, Telegram, Slack, Discord, iMessage, and more. It connects to an LLM, executes tasks on your behalf, and communicates through the channels you already use.
|
||||
|
||||
OpenClaw has memory built in, and it's a thoughtful design. The system uses plain Markdown files on disk: daily notes in `memory/YYYY-MM-DD.md` for session-level context, and a curated `MEMORY.md` for long-term knowledge. A SQLite-backed vector index (using `sqlite-vec`) enables semantic search over these files, and there's even an experimental QMD backend that combines BM25 keyword search with vector retrieval.
|
||||
|
||||
But there's a fundamental constraint: **the agent has to decide what to remember**. The docs say it directly -- "If you want something to stick, ask the bot to write it." Memory is append-only text that the model must explicitly choose to save. Today's and yesterday's daily notes load automatically at session start, but anything older requires the agent to actively search with the `memory_search` tool.
|
||||
|
||||
In practice, this means:
|
||||
|
||||
- Important facts slip through because the model didn't think to write them down.
|
||||
- The quality of memory depends on how well the LLM follows its own instructions to persist information.
|
||||
- As daily notes accumulate, finding the right context requires the agent to search at the right time with the right query -- and models don't do this consistently.
|
||||
|
||||
There's also a data question that matters for OpenClaw users specifically. OpenClaw runs on *your* machine and talks to *your* messaging apps. The expectation is local-first, private by default. Any memory solution that routes your conversations through a third-party cloud service breaks that model.
|
||||
|
||||
## The Approach
|
||||
|
||||
[Hindsight](https://github.com/vectorize-io/hindsight) is an open-source memory engine that replaces OpenClaw's memory layer with automated, structured knowledge extraction. The key differences from the built-in system:
|
||||
|
||||
**Automatic capture, not manual.** Every conversation is captured after each turn without the agent needing to decide what's worth remembering. Hindsight extracts facts, entities, and relationships in the background -- the model doesn't need to be prompted to "save this."
|
||||
|
||||
**Structured knowledge, not flat text.** Instead of appending lines to a Markdown file, Hindsight extracts discrete facts ("production database runs on port 5433"), tracks entities (people, services, projects), and maps relationships between them ("auth service depends on Redis for sessions").
|
||||
|
||||
**Auto-recall, not tool-based retrieval.** OpenClaw's built-in memory exposes a `memory_search` tool that the agent can call, but models don't use search tools consistently. Hindsight sidesteps this entirely by injecting relevant memory into context *before* every agent response. The agent doesn't need to know the memory system exists.
|
||||
|
||||
**Feedback loop prevention.** When memories are injected into context before a response, they become part of the conversation. Without care, those injected memories would get re-stored and re-extracted as new facts, causing exponential growth and duplicates. The plugin automatically strips its own `<hindsight_memories>` tags before retention, preventing this loop.
|
||||
|
||||
**Local-first, open source.** Hindsight runs through `hindsight-embed`, a daemon that bundles the memory API and a PostgreSQL instance into a single process on your machine. No data leaves your environment. The entire codebase is open source.
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌────────────────────────────────┐
|
||||
│ OpenClaw │ │ hindsight-embed daemon │
|
||||
│ Gateway │──────▶│ ┌──────────┐ ┌───────────┐ │
|
||||
│ │◀──────│ │ Memory │ │PostgreSQL │ │
|
||||
│ WhatsApp/Slack/ │ │ │ API │─│(embedded) │ │
|
||||
│ Telegram/... │ │ └──────────┘ └───────────┘ │
|
||||
└─────────────────┘ └────────────────────────────────┘
|
||||
runs locally · port 9077
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Configure an LLM Provider
|
||||
|
||||
Hindsight needs an LLM for memory extraction. This is separate from your agent's primary model -- it runs in the background and handles fact/entity/relationship extraction.
|
||||
|
||||
```bash
|
||||
# Option A: OpenAI (uses gpt-4o-mini)
|
||||
export OPENAI_API_KEY="YOUR_API_KEY"
|
||||
|
||||
# Option B: Anthropic (uses claude-3-5-haiku)
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY"
|
||||
|
||||
# Option C: Gemini (uses gemini-2.5-flash)
|
||||
export GEMINI_API_KEY="YOUR_API_KEY"
|
||||
|
||||
# Option D: Groq (uses openai/gpt-oss-20b)
|
||||
export GROQ_API_KEY="YOUR_API_KEY"
|
||||
|
||||
# Option E: Claude Code (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=claude-code
|
||||
|
||||
# Option F: OpenAI Codex (no API key needed)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
|
||||
```
|
||||
|
||||
You can also point it at any OpenAI-compatible endpoint, including OpenRouter:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
export HINDSIGHT_API_LLM_MODEL=xiaomi/mimo-v2-flash
|
||||
export HINDSIGHT_API_LLM_API_KEY=YOUR_API_KEY
|
||||
export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
```
|
||||
|
||||
A smaller, cheaper model is the right call here. Memory extraction doesn't need your most capable model.
|
||||
|
||||
### Step 2: Install the Plugin
|
||||
|
||||
```bash
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
You should see output confirming the install and that Hindsight takes over the memory slot:
|
||||
|
||||
```
|
||||
Exclusive slot "memory" switched from "memory-core" to "hindsight-openclaw".
|
||||
Installed plugin: hindsight-openclaw
|
||||
```
|
||||
|
||||
This confirms Hindsight is replacing OpenClaw's built-in memory, not running alongside it.
|
||||
|
||||
### Step 3: Launch
|
||||
|
||||
```bash
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
The Hindsight daemon starts automatically on port 9077. You should see confirmation in the gateway output:
|
||||
|
||||
```
|
||||
[Hindsight] ✓ Using provider: openai, model: gpt-4o-mini
|
||||
```
|
||||
|
||||
That's the entire setup. No Docker, no database provisioning, no config files. Everything runs on your machine.
|
||||
|
||||
### Verifying It's Working
|
||||
|
||||
After a few conversations, check the gateway logs to confirm memory operations are happening:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
|
||||
```
|
||||
|
||||
You should see lines like:
|
||||
|
||||
```
|
||||
[Hindsight] Retained X messages for session ...
|
||||
[Hindsight] Auto-recall: Injecting X memories
|
||||
```
|
||||
|
||||
If you want to browse what your agent has learned, the Hindsight daemon includes a web UI:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed@latest -p openclaw ui
|
||||
```
|
||||
|
||||
### External API Mode: Shared Memory Across Instances
|
||||
|
||||
The default local daemon is ideal for a single OpenClaw instance. But if you're running multiple instances -- say, one on your laptop and one on a server -- or if your team wants shared agent memory, the plugin supports connecting to a remote Hindsight API server.
|
||||
|
||||
Configure via environment variables:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_EMBED_API_URL=https://your-hindsight-server.example.com
|
||||
export HINDSIGHT_EMBED_API_TOKEN=YOUR_API_TOKEN
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
Or in `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hindsightApiUrl": "https://your-hindsight-server.example.com",
|
||||
"hindsightApiToken": "YOUR_API_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this mode, no local daemon starts. The plugin performs a health check against the remote API on startup and routes all memory operations -- retain, recall, reflect -- through it. You can verify it's working in the logs:
|
||||
|
||||
```bash
|
||||
# [Hindsight] External API mode enabled: https://your-hindsight-server.example.com
|
||||
# [Hindsight] External API health check passed
|
||||
```
|
||||
|
||||
> **Note:** Environment variables take precedence over plugin config. If you have both set, the env var wins.
|
||||
|
||||
> **Want to skip self-hosting entirely?** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) works as the external API endpoint — just use your Cloud URL and API token above. This does mean your memory data leaves your machine, which breaks the fully-local setup. For personal use where privacy is paramount, stick with the local daemon. But for teams or multi-instance setups where shared memory matters more, Cloud is the fastest path.
|
||||
|
||||
## Memory Isolation
|
||||
|
||||
By default, the plugin creates separate memory banks based on the agent, channel, and user context -- so each unique combination gets its own isolated memory store. The bank ID is derived from configurable fields via `dynamicBankGranularity`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"dynamicBankGranularity": ["provider", "user"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example, memories are isolated per provider + user, meaning the same user shares memories across all channels within a provider.
|
||||
|
||||
Available isolation fields:
|
||||
- `agent` -- the bot identity
|
||||
- `channel` -- the conversation or group ID
|
||||
- `user` -- the person interacting with the bot
|
||||
- `provider` -- the messaging platform (Slack, Telegram, etc.)
|
||||
|
||||
The default is `["agent", "channel", "user"]`, which gives full isolation per user per channel per agent. Set `dynamicBankId: false` to use a single shared bank for all conversations. Use `bankIdPrefix` to namespace banks across environments (e.g. `"prod"`, `"staging"`).
|
||||
|
||||
## Retention and Recall Controls
|
||||
|
||||
The plugin ships with sensible defaults, but most behaviors are configurable.
|
||||
|
||||
**Retention** controls what gets stored:
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `autoRetain` | `true` | Auto-retain conversations after each turn. Set `false` to disable. |
|
||||
| `retainRoles` | `["user", "assistant"]` | Which message roles to include in retained transcript. |
|
||||
| `retainEveryNTurns` | `1` | Retain every Nth turn. Values > 1 enable chunked retention with a sliding window. |
|
||||
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. |
|
||||
|
||||
**Recall** controls what gets injected:
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `autoRecall` | `true` | Auto-inject memories before each turn. Set `false` when the agent has its own recall tool. |
|
||||
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
|
||||
| `recallMaxTokens` | `1024` | Max tokens for recall response -- controls how much memory context is injected per turn. |
|
||||
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Excludes verbose `observation` entries by default. |
|
||||
| `recallTopK` | unlimited | Hard cap on number of memories injected per turn. |
|
||||
| `recallContextTurns` | `1` | Number of prior user turns to include when composing the recall query. |
|
||||
| `recallPromptPreamble` | built-in string | Custom text placed above recalled memories in the injected context. |
|
||||
|
||||
Example: high-fidelity recall with multi-turn context:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"recallBudget": "high",
|
||||
"recallMaxTokens": 2048,
|
||||
"recallContextTurns": 3,
|
||||
"recallTopK": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pitfalls & Edge Cases
|
||||
|
||||
**Memory extraction is asynchronous.** Facts are extracted after each turn in the background. If you end a session and immediately start a new one, the most recent facts may still be processing. In practice this might be a second or two, so don't expect instant availability across sessions.
|
||||
|
||||
**Extraction quality depends on your model choice.** A very small or low-quality extraction model will miss nuanced technical details. `gpt-4o-mini` and `claude-3-5-haiku` are solid defaults -- capable enough for reliable fact extraction, cheap enough to run on every turn.
|
||||
|
||||
**The recall window is bounded.** The default `recallMaxTokens` of 1024 means not every relevant memory will appear in every response. Retrieval is relevance-ranked, so the most pertinent facts surface first, but be aware of the ceiling. You can increase this to 2048 or higher in config.
|
||||
|
||||
**Memory isolation defaults to per-agent + per-channel + per-user.** This means your Slack group chat memories don't bleed into your Telegram DM memories and vice versa. If you want unified memory across channels, adjust `dynamicBankGranularity` to just `["user"]` or set `dynamicBankId: false` for a single shared bank.
|
||||
|
||||
**The embedded PostgreSQL needs system libraries.** `hindsight-embed` bundles PostgreSQL via [pg0](https://github.com/vectorize-io/pg0). On minimal Docker images (e.g. `ubuntu:latest`), you'll need to install `libxml2` and `libreadline` before the daemon can start:
|
||||
|
||||
```bash
|
||||
apt-get install -y libxml2 libreadline8t64
|
||||
```
|
||||
|
||||
Check the pg0 README for your platform if you hit other missing library errors.
|
||||
|
||||
**PostgreSQL cannot run as root.** If you're running inside Docker, make sure you're using a non-root user. PostgreSQL's `initdb` refuses to run as root, and the daemon will fail with `initdb: error: cannot be run as root`. Create a user and switch to it:
|
||||
|
||||
```dockerfile
|
||||
RUN useradd -m -s /bin/bash myuser
|
||||
USER myuser
|
||||
```
|
||||
|
||||
**First run downloads ~3GB of dependencies.** On the very first `openclaw gateway` launch, `hindsight-embed` downloads Python packages including PyTorch, sentence-transformers, and (on x86) CUDA libraries. This can take several minutes and may cause the daemon start to time out. The plugin auto-retries, and subsequent launches use the cached packages -- so this is a one-time cost.
|
||||
|
||||
**Debug logging.** If something isn't working as expected, enable `debug: true` in the plugin config. This produces verbose logging of recall queries, retention transcripts, bank ID derivation, and more.
|
||||
|
||||
## Tradeoffs & Alternatives
|
||||
|
||||
**When to stick with OpenClaw's built-in memory:**
|
||||
|
||||
- You prefer the transparency of plain Markdown files you can edit in any text editor and version control with Git.
|
||||
- Your use case is lightweight and session-scoped -- daily notes plus `MEMORY.md` cover your needs.
|
||||
- You don't want any additional processes running alongside the Gateway.
|
||||
|
||||
**When Hindsight is the better choice:**
|
||||
|
||||
- You want automated memory extraction without relying on the model to decide what to save.
|
||||
- You need consistent auto-recall rather than hoping the agent calls `memory_search` at the right time.
|
||||
- You want structured knowledge (facts, entities, relationships) rather than raw text.
|
||||
- You need shared memory across multiple OpenClaw instances or team members.
|
||||
- You want fine-grained control over what gets retained, what gets recalled, and how memory is isolated across agents and channels.
|
||||
|
||||
**Local daemon vs. external API:**
|
||||
|
||||
The local daemon is simpler and keeps everything on one machine -- the right default for personal use. External API mode adds network latency but enables shared memory and survives machine restarts. Since Hindsight is open source, you can self-host the server on your own infrastructure and keep the same data ownership guarantees.
|
||||
|
||||
## Recap
|
||||
|
||||
OpenClaw's built-in memory is file-based and transparent, but it depends on the agent deciding what to remember and when to search. Hindsight replaces this with automated extraction and auto-recall -- conversations are captured, facts are extracted in the background, and relevant knowledge is injected into context before every response.
|
||||
|
||||
The core insight: memory that works automatically is qualitatively different from memory that depends on model behavior. When the agent doesn't have to choose what to save or when to search, it just has the right context.
|
||||
|
||||
And because Hindsight is open source and local-first, you keep the same data ownership model that makes OpenClaw compelling in the first place.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Install the plugin and have a few conversations across different channels. Then open the web UI (`uvx hindsight-embed@latest -p openclaw ui`) to see what was captured.
|
||||
- Experiment with different LLM providers for extraction and compare the quality of captured facts.
|
||||
- Tune recall with `recallBudget`, `recallMaxTokens`, and `recallContextTurns` to find the right balance for your use case.
|
||||
- Adjust `dynamicBankGranularity` if you want memories shared across channels or isolated per provider.
|
||||
- Browse the [Hindsight source on GitHub](https://github.com/vectorize-io/hindsight) to understand the extraction pipeline.
|
||||
- Read the [full integration docs](https://hindsight.vectorize.io/sdks/integrations/openclaw) for the complete configuration reference.
|
||||
- If you're running multiple OpenClaw instances, try external API mode with a self-hosted Hindsight server for shared memory.
|
||||
@@ -107,6 +107,34 @@ hindsight document get my-bank meeting-2024-03-15
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Update Document
|
||||
|
||||
Update mutable fields on an existing document without re-processing the content. Currently supports updating `tags`.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={documentsPy} section="document-update" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={documentsMjs} section="document-update" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
# Replace tags with new values
|
||||
hindsight document update-tags my-bank meeting-2024-03-15 --tags team-a --tags team-b
|
||||
|
||||
# Remove all tags
|
||||
hindsight document update-tags my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::info Observations are re-consolidated
|
||||
When tags change, any consolidated observations derived from the document's memories are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
|
||||
:::
|
||||
|
||||
## Delete Document
|
||||
|
||||
Remove a document and all its associated memories:
|
||||
|
||||
@@ -81,6 +81,12 @@ Controls how aggressively facts are extracted:
|
||||
|
||||
Only active when `retain_extraction_mode` is `custom`. Replaces the built-in extraction rules entirely with your own instructions.
|
||||
|
||||
### retain_chunk_size
|
||||
|
||||
Maximum number of characters per chunk when splitting content for fact extraction. Larger chunks mean fewer LLM calls but may reduce extraction quality on long inputs; smaller chunks improve granularity at the cost of more calls.
|
||||
|
||||
Default: `3000`
|
||||
|
||||
See [Retain configuration](/developer/configuration#retain) for environment variable names and defaults.
|
||||
|
||||
### entity_labels {#entity-labels}
|
||||
@@ -164,9 +170,21 @@ e.g. Observations are stable facts about people and projects.
|
||||
Ignore one-off events and ephemeral state.
|
||||
```
|
||||
|
||||
### 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. Leave unset to use the server default (`8`).
|
||||
|
||||
### consolidation_source_facts_max_tokens
|
||||
|
||||
Total token budget for source facts included with observations in the consolidation prompt. Source facts give the LLM evidence to compare new facts against existing observations. `-1` = unlimited. Leave unset to use the server default (`-1`).
|
||||
|
||||
### consolidation_source_facts_max_tokens_per_observation
|
||||
|
||||
Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts, preventing a single observation with many source facts from consuming the entire budget. `-1` = unlimited. Leave unset to use the server default (`256`).
|
||||
|
||||
See [Observations configuration](/developer/configuration#observations) for environment variable names and defaults.
|
||||
|
||||
### mission
|
||||
### reflect_mission
|
||||
|
||||
A first-person narrative that provides identity and framing context for `reflect`. The agent uses this to ground its reasoning and apply a consistent perspective.
|
||||
|
||||
@@ -216,9 +234,32 @@ How much to weight emotional context when reasoning during `reflect`. Scale 1–
|
||||
| `5` | Empathetic — considers emotional context |
|
||||
|
||||
:::info
|
||||
Disposition traits and `mission` only affect the `reflect` operation. `retain_mission` and `observations_mission` are separate per-operation settings.
|
||||
Disposition traits and `reflect_mission` only affect the `reflect` operation. `retain_mission` and `observations_mission` are separate per-operation settings.
|
||||
:::
|
||||
|
||||
### mcp_enabled_tools
|
||||
|
||||
An allowlist of MCP tool names that are enabled for this bank. When set, only the listed tools can be invoked; any tool not in the list returns an error (tools still appear in the MCP tools list for protocol compatibility). Set to `null` (or omit) to allow all tools.
|
||||
|
||||
```json
|
||||
["recall", "reflect"]
|
||||
```
|
||||
|
||||
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `delete_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
|
||||
|
||||
### llm_gemini_safety_settings
|
||||
|
||||
Controls content filtering thresholds for Gemini and VertexAI providers. Accepts a list of safety setting objects in the [Google AI safety settings format](https://ai.google.dev/api/generate-content#v1beta.SafetySetting). When `null` (default), Gemini's built-in safety defaults are used.
|
||||
|
||||
```json
|
||||
[
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}
|
||||
]
|
||||
```
|
||||
|
||||
Only applies when `HINDSIGHT_API_LLM_PROVIDER` is `gemini` or `vertexai`.
|
||||
|
||||
---
|
||||
|
||||
## Updating Configuration
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -603,11 +604,37 @@ Configuration for the file upload and conversion pipeline (used by `POST /v1/def
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_ENABLE_FILE_UPLOAD_API` | Enable the file upload API endpoint | `true` |
|
||||
| `HINDSIGHT_API_FILE_PARSER` | File parser to use (`markitdown`, `iris`) | `markitdown` |
|
||||
| `HINDSIGHT_API_FILE_PARSER` | Server-side default parser or fallback chain (comma-separated, e.g. `iris,markitdown`) | `markitdown` |
|
||||
| `HINDSIGHT_API_FILE_PARSER_ALLOWLIST` | Comma-separated list of parsers clients are allowed to request. If not set, all registered parsers are allowed. | — |
|
||||
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE` | Max files per upload request | `10` |
|
||||
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB` | Max total upload size per request (MB) | `100` |
|
||||
| `HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN` | Delete stored files after memory extraction completes | `true` |
|
||||
|
||||
#### Parser selection
|
||||
|
||||
Clients can override the server default by passing `parser` in the request body of `POST /v1/default/banks/{bank_id}/files/retain`. Both the server default and the per-request field accept a single parser name or an ordered **fallback chain** — each parser is tried in sequence until one succeeds.
|
||||
|
||||
```bash
|
||||
# Server default: try iris first, fall back to markitdown if iris fails
|
||||
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
|
||||
|
||||
# Restrict what clients may request (optional — defaults to all registered parsers)
|
||||
export HINDSIGHT_API_FILE_PARSER_ALLOWLIST=markitdown,iris
|
||||
```
|
||||
|
||||
```json
|
||||
// Per-request override (in the JSON body of the file retain endpoint)
|
||||
{
|
||||
"parser": "iris",
|
||||
"files_metadata": [
|
||||
{ "document_id": "report" },
|
||||
{ "document_id": "fallback_doc", "parser": ["iris", "markitdown"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients that request a parser not in the allowlist receive HTTP 400.
|
||||
|
||||
#### Parser: markitdown (default)
|
||||
|
||||
Local file-to-markdown conversion using [Microsoft's markitdown](https://github.com/microsoft/markitdown). No external service required.
|
||||
@@ -626,10 +653,13 @@ Cloud-based extraction via [Vectorize Iris](https://docs.vectorize.io/build-depl
|
||||
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, JPEG, PNG, GIF, BMP, TIFF, WEBP), HTML, TXT, MD, CSV.
|
||||
|
||||
```bash
|
||||
# Use iris parser (requires Vectorize account)
|
||||
# Use iris as the only parser
|
||||
export HINDSIGHT_API_FILE_PARSER=iris
|
||||
export HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN=your-vectorize-token
|
||||
export HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID=your-org-id
|
||||
|
||||
# Or: try iris first, fall back to markitdown if iris fails or rejects the file type
|
||||
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -733,9 +763,12 @@ 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. | `8` |
|
||||
| `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` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS` | Total token budget for source facts included with observations in the consolidation prompt. `-1` = unlimited. Configurable per bank. | `-1` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION` | Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts. `-1` = unlimited. Configurable per bank. | `256` |
|
||||
| `HINDSIGHT_API_OBSERVATIONS_MISSION` | What this bank should synthesise into durable observations. Replaces the built-in consolidation rules — leave unset to use the server default. | - |
|
||||
|
||||
#### Customizing observations: when to use what
|
||||
|
||||
@@ -103,6 +103,29 @@ console.log(`Created: ${doc.created_at}`);
|
||||
// [/docs:document-get]
|
||||
|
||||
|
||||
// [docs:document-update]
|
||||
// Fix tags on a document retained with the wrong scope
|
||||
const { data: updateResult, error: updateError } = await sdk.updateDocument({
|
||||
client: apiClient,
|
||||
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' },
|
||||
body: { tags: ['team-a', 'team-b'] }
|
||||
});
|
||||
|
||||
if (updateError) {
|
||||
throw new Error(`Failed to update tags: ${JSON.stringify(updateError)}`);
|
||||
}
|
||||
|
||||
console.log(`Updated: ${updateResult.success}`);
|
||||
|
||||
// Remove all tags (make document visible everywhere)
|
||||
await sdk.updateDocument({
|
||||
client: apiClient,
|
||||
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' },
|
||||
body: { tags: [] }
|
||||
});
|
||||
// [/docs:document-update]
|
||||
|
||||
|
||||
// [docs:document-delete]
|
||||
// Delete document and all its memories
|
||||
const { data: deleteResult } = await sdk.deleteDocument({
|
||||
|
||||
@@ -120,6 +120,35 @@ asyncio.run(get_document_example())
|
||||
# [/docs:document-get]
|
||||
|
||||
|
||||
# [docs:document-update]
|
||||
from hindsight_client_api import ApiClient, Configuration
|
||||
from hindsight_client_api.api import DocumentsApi
|
||||
from hindsight_client_api.models import UpdateDocumentRequest
|
||||
|
||||
async def update_document_example():
|
||||
config = Configuration(host="http://localhost:8888")
|
||||
api_client = ApiClient(config)
|
||||
api = DocumentsApi(api_client)
|
||||
|
||||
# Fix tags on a document retained with the wrong scope
|
||||
result = await api.update_document(
|
||||
bank_id="my-bank",
|
||||
document_id="meeting-2024-03-15",
|
||||
update_document_request=UpdateDocumentRequest(tags=["team-a", "team-b"]),
|
||||
)
|
||||
print(f"Updated: {result.success}")
|
||||
|
||||
# Remove all tags (make document visible everywhere)
|
||||
await api.update_document(
|
||||
bank_id="my-bank",
|
||||
document_id="meeting-2024-03-15",
|
||||
update_document_request=UpdateDocumentRequest(tags=[]),
|
||||
)
|
||||
|
||||
asyncio.run(update_document_example())
|
||||
# [/docs:document-update]
|
||||
|
||||
|
||||
# [docs:document-delete]
|
||||
from hindsight_client_api import ApiClient, Configuration
|
||||
from hindsight_client_api.api import DocumentsApi
|
||||
|
||||
@@ -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(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -322,7 +322,7 @@
|
||||
"Memory"
|
||||
],
|
||||
"summary": "Get memory unit",
|
||||
"description": "Get a single memory unit by ID with all its metadata including entities and tags.",
|
||||
"description": "Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
|
||||
"operationId": "get_memory",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -382,6 +382,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}/history": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Memory"
|
||||
],
|
||||
"summary": "Get observation history",
|
||||
"description": "Get the full history of an observation, with each change's source facts resolved to their text.",
|
||||
"operationId": "get_observation_history",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "memory_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Memory 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}/memories/recall": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1244,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": [
|
||||
@@ -1916,6 +2048,82 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Documents"
|
||||
],
|
||||
"summary": "Update document",
|
||||
"description": "Update mutable fields on a document without re-processing its content.\n\n**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.\n\nAt least one field must be provided.",
|
||||
"operationId": "update_document",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "document_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Document Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDocumentRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDocumentResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Documents"
|
||||
@@ -3660,7 +3868,7 @@
|
||||
"Files"
|
||||
],
|
||||
"summary": "Convert files to memories",
|
||||
"description": "Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\nThis endpoint handles file upload, conversion, and memory creation in a single operation.\n\n**Features:**\n- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n- Automatic file-to-markdown conversion using pluggable parsers\n- Files stored in object storage (PostgreSQL by default, S3 for production)\n- Each file becomes a separate document with optional metadata/tags\n- Always processes asynchronously \u2014 returns operation IDs immediately\n\n**The system automatically:**\n1. Stores uploaded files in object storage\n2. Converts files to markdown\n3. Creates document records with file metadata\n4. Extracts facts and creates memory units (same as regular retain)\n\nUse the operations endpoint to monitor progress.\n\n**Request format:** multipart/form-data with:\n- `files`: One or more files to upload\n- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
|
||||
"description": "Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\nThis endpoint handles file upload, conversion, and memory creation in a single operation.\n\n**Features:**\n- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n- Automatic file-to-markdown conversion using pluggable parsers\n- Files stored in object storage (PostgreSQL by default, S3 for production)\n- Each file becomes a separate document with optional metadata/tags\n- Always processes asynchronously \u2014 returns operation IDs immediately\n\n**The system automatically:**\n1. Stores uploaded files in object storage\n2. Converts files to markdown\n3. Creates document records with file metadata\n4. Extracts facts and creates memory units (same as regular retain)\n\nUse the operations endpoint to monitor progress.\n\n**Request format:** multipart/form-data with:\n- `files`: One or more files to upload\n- `request`: JSON string with FileRetainRequest model\n\n**Parser selection:**\n- Set `parser` in the request body to override the server default for all files.\n- Set `parser` inside a `files_metadata` entry for per-file control.\n- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain \u2014 each parser is tried in sequence until one succeeds.\n- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.\n- Only parsers enabled on the server may be requested; others return HTTP 400.",
|
||||
"operationId": "file_retain",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -7210,8 +7418,14 @@
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens",
|
||||
"description": "Maximum tokens for source facts",
|
||||
"description": "Maximum total tokens for source facts across all observations (-1 = unlimited)",
|
||||
"default": 4096
|
||||
},
|
||||
"max_tokens_per_observation": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens Per Observation",
|
||||
"description": "Maximum tokens of source facts per observation (-1 = unlimited)",
|
||||
"default": -1
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -7365,6 +7579,46 @@
|
||||
"title": "UpdateDispositionRequest",
|
||||
"description": "Request model for updating disposition traits."
|
||||
},
|
||||
"UpdateDocumentRequest": {
|
||||
"properties": {
|
||||
"tags": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Tags",
|
||||
"description": "New tags for the document and its memory units. Triggers observation invalidation and re-consolidation."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "UpdateDocumentRequest",
|
||||
"description": "Request model for updating a document's mutable fields.",
|
||||
"example": {
|
||||
"tags": [
|
||||
"team-a",
|
||||
"team-b"
|
||||
]
|
||||
}
|
||||
},
|
||||
"UpdateDocumentResponse": {
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"title": "Success",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "UpdateDocumentResponse",
|
||||
"description": "Response model for update document endpoint."
|
||||
},
|
||||
"UpdateMentalModelRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
|
||||
@@ -18,6 +18,10 @@ echo "✅ SDK built successfully"
|
||||
echo ""
|
||||
|
||||
echo "🚀 Starting Control Plane (Next.js dev server)..."
|
||||
# Save caller-provided values before .env can overwrite them
|
||||
_CALLER_PORT="${PORT:-}"
|
||||
_CALLER_DATAPLANE_URL="${HINDSIGHT_CP_DATAPLANE_API_URL:-}"
|
||||
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
echo "📄 Loading environment from $ROOT_DIR/.env"
|
||||
# Load env vars from root .env file
|
||||
@@ -28,7 +32,9 @@ fi
|
||||
|
||||
# Map prefixed env vars to Next.js standard vars
|
||||
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
|
||||
export PORT="${HINDSIGHT_CP_PORT:-9999}"
|
||||
# Caller-provided values take priority over .env
|
||||
export PORT="${_CALLER_PORT:-${HINDSIGHT_CP_PORT:-9999}}"
|
||||
export HINDSIGHT_CP_DATAPLANE_API_URL="${_CALLER_DATAPLANE_URL:-${HINDSIGHT_CP_DATAPLANE_API_URL:-http://localhost:8888}}"
|
||||
|
||||
# Run dev server
|
||||
npm run dev -w @vectorize-io/hindsight-control-plane
|
||||
+23
-4
@@ -3,6 +3,14 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Parse --random-port flag
|
||||
RANDOM_PORT=false
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--random-port" ]; then
|
||||
RANDOM_PORT=true
|
||||
fi
|
||||
done
|
||||
|
||||
# Load .env to pick up HINDSIGHT_API_PORT if set
|
||||
ROOT_DIR="$(git rev-parse --show-toplevel)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
@@ -10,8 +18,19 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
source "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
API_PORT="${HINDSIGHT_API_PORT:-8888}"
|
||||
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
|
||||
|
||||
get_free_port() {
|
||||
python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()"
|
||||
}
|
||||
|
||||
if [ "$RANDOM_PORT" = true ]; then
|
||||
API_PORT="$(get_free_port)"
|
||||
CP_PORT="$(get_free_port)"
|
||||
echo "Using random ports — API: $API_PORT, Control Plane: $CP_PORT"
|
||||
else
|
||||
API_PORT="${HINDSIGHT_API_PORT:-8888}"
|
||||
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
|
||||
fi
|
||||
|
||||
PIDS=()
|
||||
|
||||
@@ -37,7 +56,7 @@ trap cleanup EXIT INT TERM
|
||||
|
||||
# Start API
|
||||
echo "Starting API server..."
|
||||
"$SCRIPT_DIR/start-api.sh" &
|
||||
"$SCRIPT_DIR/start-api.sh" --port "$API_PORT" &
|
||||
API_PID=$!
|
||||
PIDS+=($API_PID)
|
||||
|
||||
@@ -63,7 +82,7 @@ fi
|
||||
|
||||
# Start Control Plane
|
||||
echo ""
|
||||
"$SCRIPT_DIR/start-control-plane.sh" &
|
||||
PORT="$CP_PORT" HINDSIGHT_CP_DATAPLANE_API_URL="http://localhost:${API_PORT}" "$SCRIPT_DIR/start-control-plane.sh" &
|
||||
CP_PID=$!
|
||||
PIDS+=($CP_PID)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user