feat: add retain update_mode='append' for document content concatenation (#932)
* feat: add update_mode='append' for retain to concatenate content to existing documents When retaining with update_mode='append' and a document_id that already exists, the new content is appended to the existing document text and the full document is reprocessed. Delta retain automatically skips unchanged chunks, so only the new content triggers LLM extraction. - Add update_mode field to MemoryItem (API), RetainContentDict (internal), MCP tools - Validate that update_mode='append' requires a document_id - Fetch existing document content and prepend before processing in orchestrator - Update Python, TypeScript, Go generated clients and top-level client wrappers - Add tests for append, multiple appends, no-existing-doc, validation, and default replace * fix: add update_mode field to Rust CLI and client MemoryItem initializers * chore: regenerate docs skill references for update_mode
This commit is contained in:
parent
cf0537ba7e
commit
3c633e5e16
17 changed files with 465 additions and 25 deletions
|
|
@ -463,6 +463,12 @@ class MemoryItem(BaseModel):
|
||||||
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
|
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
|
||||||
"Strategies are defined in the bank config under 'retain_strategies'.",
|
"Strategies are defined in the bank config under 'retain_strategies'.",
|
||||||
)
|
)
|
||||||
|
update_mode: Literal["replace", "append"] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="How to handle an existing document with the same document_id. "
|
||||||
|
"'replace' (default) deletes old data and reprocesses from scratch. "
|
||||||
|
"'append' concatenates new content to the existing document text and reprocesses.",
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("timestamp", mode="before")
|
@field_validator("timestamp", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -5307,6 +5313,8 @@ def _register_routes(app: FastAPI):
|
||||||
content_dict["tags"] = item.tags
|
content_dict["tags"] = item.tags
|
||||||
if item.observation_scopes is not None:
|
if item.observation_scopes is not None:
|
||||||
content_dict["observation_scopes"] = item.observation_scopes
|
content_dict["observation_scopes"] = item.observation_scopes
|
||||||
|
if item.update_mode is not None:
|
||||||
|
content_dict["update_mode"] = item.update_mode
|
||||||
strategy_groups[effective].append(content_dict)
|
strategy_groups[effective].append(content_dict)
|
||||||
|
|
||||||
if request.async_:
|
if request.async_:
|
||||||
|
|
|
||||||
|
|
@ -2146,6 +2146,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
f"Each content item in a batch must have a unique document_id to avoid race conditions."
|
f"Each content item in a batch must have a unique document_id to avoid race conditions."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate update_mode=append requires document_id
|
||||||
|
for item in contents:
|
||||||
|
if item.get("update_mode") == "append" and not item.get("document_id"):
|
||||||
|
raise ValueError("update_mode='append' requires a document_id")
|
||||||
|
|
||||||
# Auto-chunk large batches by token count to avoid timeouts and memory issues
|
# Auto-chunk large batches by token count to avoid timeouts and memory issues
|
||||||
# Calculate total token count
|
# Calculate total token count
|
||||||
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
|
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,23 @@ from .types import ProcessedFact
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_document_content(
|
||||||
|
conn,
|
||||||
|
bank_id: str,
|
||||||
|
document_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Fetch the original_text of an existing document.
|
||||||
|
|
||||||
|
Returns None if the document does not exist.
|
||||||
|
"""
|
||||||
|
row = await conn.fetchval(
|
||||||
|
f"SELECT original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
|
||||||
|
document_id,
|
||||||
|
bank_id,
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def insert_facts_batch(
|
async def insert_facts_batch(
|
||||||
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
|
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,35 @@ async def retain_batch(
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist generated document_id", exc_info=True)
|
logger.warning("Failed to persist generated document_id", exc_info=True)
|
||||||
|
|
||||||
|
# --- Append mode: prepend existing document content to new content ---
|
||||||
|
# When update_mode="append", fetch the existing document text and prepend it
|
||||||
|
# so the full document is reprocessed (delta retain will skip unchanged chunks).
|
||||||
|
update_mode = None
|
||||||
|
for item in contents_dicts:
|
||||||
|
item_mode = item.get("update_mode")
|
||||||
|
if item_mode:
|
||||||
|
update_mode = item_mode
|
||||||
|
break
|
||||||
|
|
||||||
|
if update_mode == "append" and effective_doc_id and is_first_batch:
|
||||||
|
async with acquire_with_retry(pool) as conn:
|
||||||
|
existing_text = await fact_storage.get_document_content(conn, bank_id, effective_doc_id)
|
||||||
|
if existing_text:
|
||||||
|
# Prepend existing text as a new content item at the beginning
|
||||||
|
existing_content: RetainContentDict = {"content": existing_text}
|
||||||
|
# Copy context/tags from first item for consistency
|
||||||
|
first = contents_dicts[0]
|
||||||
|
if first.get("context"):
|
||||||
|
existing_content["context"] = first["context"]
|
||||||
|
if first.get("tags"):
|
||||||
|
existing_content["tags"] = first["tags"]
|
||||||
|
contents_dicts = [existing_content, *contents_dicts]
|
||||||
|
# Rebuild contents list to match
|
||||||
|
contents = _build_contents(contents_dicts, document_tags)
|
||||||
|
log_buffer.append(
|
||||||
|
f"[append] Prepended {len(existing_text):,} chars from existing document {effective_doc_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# --- Delta retain: check if we can skip unchanged chunks ---
|
# --- Delta retain: check if we can skip unchanged chunks ---
|
||||||
if is_first_batch:
|
if is_first_batch:
|
||||||
delta_result = await _try_delta_retain(
|
delta_result = await _try_delta_retain(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ class RetainContentDict(TypedDict, total=False):
|
||||||
observation_scopes: How to scope observations for consolidation (optional).
|
observation_scopes: How to scope observations for consolidation (optional).
|
||||||
"per_tag" runs one pass per individual tag; "combined" (default) runs a
|
"per_tag" runs one pass per individual tag; "combined" (default) runs a
|
||||||
single pass with all tags; a list[list[str]] specifies exact passes.
|
single pass with all tags; a list[list[str]] specifies exact passes.
|
||||||
|
update_mode: How to handle existing documents with the same document_id (optional).
|
||||||
|
"replace" (default) deletes old data and reprocesses. "append" concatenates
|
||||||
|
new content to the existing document and reprocesses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
content: str # Required
|
content: str # Required
|
||||||
|
|
@ -37,6 +40,7 @@ class RetainContentDict(TypedDict, total=False):
|
||||||
observation_scopes: (
|
observation_scopes: (
|
||||||
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
|
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
|
||||||
) # Observation scopes for consolidation
|
) # Observation scopes for consolidation
|
||||||
|
update_mode: Literal["replace", "append"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ def build_content_dict(
|
||||||
metadata: dict[str, str] | None = None,
|
metadata: dict[str, str] | None = None,
|
||||||
document_id: str | None = None,
|
document_id: str | None = None,
|
||||||
strategy: str | None = None,
|
strategy: str | None = None,
|
||||||
|
update_mode: str | None = None,
|
||||||
) -> tuple[dict[str, Any], str | None]:
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
"""Build a content dict for retain operations.
|
"""Build a content dict for retain operations.
|
||||||
|
|
||||||
|
|
@ -151,6 +152,7 @@ def build_content_dict(
|
||||||
metadata: Optional key-value metadata to attach to the memory
|
metadata: Optional key-value metadata to attach to the memory
|
||||||
document_id: Optional document ID to associate the memory with
|
document_id: Optional document ID to associate the memory with
|
||||||
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
|
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
|
||||||
|
update_mode: How to handle existing documents ('replace' or 'append')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (content_dict, error_message). error_message is None if successful.
|
Tuple of (content_dict, error_message). error_message is None if successful.
|
||||||
|
|
@ -185,6 +187,8 @@ def build_content_dict(
|
||||||
content_dict["document_id"] = document_id
|
content_dict["document_id"] = document_id
|
||||||
if strategy is not None:
|
if strategy is not None:
|
||||||
content_dict["strategy"] = strategy
|
content_dict["strategy"] = strategy
|
||||||
|
if update_mode is not None:
|
||||||
|
content_dict["update_mode"] = update_mode
|
||||||
|
|
||||||
return content_dict, None
|
return content_dict, None
|
||||||
|
|
||||||
|
|
@ -544,6 +548,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||||
document_id: str | None = None,
|
document_id: str | None = None,
|
||||||
bank_id: str | None = None,
|
bank_id: str | None = None,
|
||||||
strategy: str | None = None,
|
strategy: str | None = None,
|
||||||
|
update_mode: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -555,12 +560,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||||
document_id: Optional document ID to associate this memory with
|
document_id: Optional document ID to associate this memory with
|
||||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||||
|
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
|
||||||
"""
|
"""
|
||||||
target_bank = bank_id or config.bank_id_resolver()
|
target_bank = bank_id or config.bank_id_resolver()
|
||||||
if target_bank is None:
|
if target_bank is None:
|
||||||
return {"status": "error", "message": "No bank_id configured"}
|
return {"status": "error", "message": "No bank_id configured"}
|
||||||
|
|
||||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
content_dict, error = build_content_dict(
|
||||||
|
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return {"status": "error", "message": error}
|
return {"status": "error", "message": error}
|
||||||
|
|
||||||
|
|
@ -595,6 +603,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||||
metadata: dict[str, str] | None = None,
|
metadata: dict[str, str] | None = None,
|
||||||
document_id: str | None = None,
|
document_id: str | None = None,
|
||||||
strategy: str | None = None,
|
strategy: str | None = None,
|
||||||
|
update_mode: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -605,12 +614,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||||
document_id: Optional document ID to associate this memory with
|
document_id: Optional document ID to associate this memory with
|
||||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||||
|
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
|
||||||
"""
|
"""
|
||||||
target_bank = config.bank_id_resolver()
|
target_bank = config.bank_id_resolver()
|
||||||
if target_bank is None:
|
if target_bank is None:
|
||||||
return {"status": "error", "message": "No bank_id configured"}
|
return {"status": "error", "message": "No bank_id configured"}
|
||||||
|
|
||||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
content_dict, error = build_content_dict(
|
||||||
|
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return {"status": "error", "message": error}
|
return {"status": "error", "message": error}
|
||||||
|
|
||||||
|
|
|
||||||
240
hindsight-api-slim/tests/test_retain_append_mode.py
Normal file
240
hindsight-api-slim/tests/test_retain_append_mode.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""
|
||||||
|
Tests for retain update_mode='append' — appends new content to existing documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hindsight_api.engine.memory_engine import Budget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ts():
|
||||||
|
return datetime.now(timezone.utc).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_mode_concatenates_content(memory, request_context):
|
||||||
|
"""
|
||||||
|
When update_mode='append', new content should be appended to the existing
|
||||||
|
document and the full document should be reprocessed. Facts from both
|
||||||
|
old and new content should be recallable.
|
||||||
|
"""
|
||||||
|
bank_id = f"test_append_{_ts()}"
|
||||||
|
document_id = "conversation-append"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First retain — initial content
|
||||||
|
v1_units = await memory.retain_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
content="Alice works at Google as a software engineer.",
|
||||||
|
context="team info",
|
||||||
|
document_id=document_id,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
assert len(v1_units) > 0, "v1 should create facts"
|
||||||
|
|
||||||
|
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||||
|
v1_text = doc_v1["original_text"]
|
||||||
|
assert "Alice works at Google" in v1_text
|
||||||
|
|
||||||
|
# Second retain with append — add new content
|
||||||
|
v2_units = await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Bob works at Microsoft as a data scientist.",
|
||||||
|
"context": "team info",
|
||||||
|
"document_id": document_id,
|
||||||
|
"update_mode": "append",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify document now contains both old and new content
|
||||||
|
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||||
|
v2_text = doc_v2["original_text"]
|
||||||
|
assert "Alice works at Google" in v2_text, "Original content should be preserved"
|
||||||
|
assert "Bob works at Microsoft" in v2_text, "New content should be appended"
|
||||||
|
|
||||||
|
# Verify facts from both old and new content are recallable
|
||||||
|
result_alice = await memory.recall_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
query="Where does Alice work?",
|
||||||
|
budget=Budget.MID,
|
||||||
|
max_tokens=1000,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
assert len(result_alice.results) > 0, "Should recall facts about Alice"
|
||||||
|
|
||||||
|
result_bob = await memory.recall_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
query="Where does Bob work?",
|
||||||
|
budget=Budget.MID,
|
||||||
|
max_tokens=1000,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
assert len(result_bob.results) > 0, "Should recall facts about Bob"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await memory.delete_bank(bank_id, request_context=request_context)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_mode_no_existing_document(memory, request_context):
|
||||||
|
"""
|
||||||
|
When update_mode='append' but no existing document exists,
|
||||||
|
it should behave like a normal retain (no content to prepend).
|
||||||
|
"""
|
||||||
|
bank_id = f"test_append_new_{_ts()}"
|
||||||
|
document_id = "new-doc-append"
|
||||||
|
|
||||||
|
try:
|
||||||
|
units = await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Charlie is a product manager at Stripe.",
|
||||||
|
"context": "team info",
|
||||||
|
"document_id": document_id,
|
||||||
|
"update_mode": "append",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(units) > 0, "Should create facts even with no existing document"
|
||||||
|
# Flatten if nested
|
||||||
|
flat_units = units[0] if units and isinstance(units[0], list) else units
|
||||||
|
assert len(flat_units) > 0
|
||||||
|
|
||||||
|
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||||
|
assert "Charlie is a product manager" in doc["original_text"]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await memory.delete_bank(bank_id, request_context=request_context)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_mode_requires_document_id(memory, request_context):
|
||||||
|
"""update_mode='append' without document_id should raise ValueError."""
|
||||||
|
bank_id = f"test_append_no_docid_{_ts()}"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="update_mode='append' requires a document_id"):
|
||||||
|
await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Some content",
|
||||||
|
"update_mode": "append",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_mode_multiple_appends(memory, request_context):
|
||||||
|
"""Multiple appends should accumulate content over successive retains."""
|
||||||
|
bank_id = f"test_multi_append_{_ts()}"
|
||||||
|
document_id = "multi-append-doc"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initial retain
|
||||||
|
await memory.retain_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
content="Day 1: Alice joined the team.",
|
||||||
|
context="journal",
|
||||||
|
document_id=document_id,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# First append
|
||||||
|
await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Day 2: Alice completed her onboarding.",
|
||||||
|
"context": "journal",
|
||||||
|
"document_id": document_id,
|
||||||
|
"update_mode": "append",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second append
|
||||||
|
await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Day 3: Alice shipped her first feature.",
|
||||||
|
"context": "journal",
|
||||||
|
"document_id": document_id,
|
||||||
|
"update_mode": "append",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify all content is present
|
||||||
|
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||||
|
text = doc["original_text"]
|
||||||
|
assert "Day 1" in text, "Original content should be present"
|
||||||
|
assert "Day 2" in text, "First append should be present"
|
||||||
|
assert "Day 3" in text, "Second append should be present"
|
||||||
|
|
||||||
|
# All days should be recallable
|
||||||
|
result = await memory.recall_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
query="What happened on Alice's first days?",
|
||||||
|
budget=Budget.MID,
|
||||||
|
max_tokens=1000,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
assert len(result.results) > 0, "Should recall facts from all appends"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await memory.delete_bank(bank_id, request_context=request_context)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_replace_mode_is_default(memory, request_context):
|
||||||
|
"""Without update_mode (or update_mode='replace'), retain should replace content."""
|
||||||
|
bank_id = f"test_replace_default_{_ts()}"
|
||||||
|
document_id = "replace-doc"
|
||||||
|
|
||||||
|
try:
|
||||||
|
await memory.retain_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
content="Alice works at Google.",
|
||||||
|
context="team info",
|
||||||
|
document_id=document_id,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retain again without update_mode — should replace
|
||||||
|
await memory.retain_batch_async(
|
||||||
|
bank_id=bank_id,
|
||||||
|
contents=[
|
||||||
|
{
|
||||||
|
"content": "Bob works at Microsoft.",
|
||||||
|
"context": "team info",
|
||||||
|
"document_id": document_id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||||
|
text = doc["original_text"]
|
||||||
|
# With replace, only new content should remain
|
||||||
|
assert "Bob works at Microsoft" in text, "New content should be present"
|
||||||
|
assert "Alice works at Google" not in text, "Old content should be replaced"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await memory.delete_bank(bank_id, request_context=request_context)
|
||||||
|
|
@ -418,6 +418,7 @@ pub fn retain(
|
||||||
tags: None,
|
tags: None,
|
||||||
observation_scopes: None,
|
observation_scopes: None,
|
||||||
strategy: None,
|
strategy: None,
|
||||||
|
update_mode: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = RetainRequest {
|
let request = RetainRequest {
|
||||||
|
|
|
||||||
|
|
@ -4784,6 +4784,12 @@ components:
|
||||||
strategy:
|
strategy:
|
||||||
nullable: true
|
nullable: true
|
||||||
type: string
|
type: string
|
||||||
|
update_mode:
|
||||||
|
enum:
|
||||||
|
- replace
|
||||||
|
- append
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- content
|
- content
|
||||||
title: MemoryItem
|
title: MemoryItem
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type MemoryItem struct {
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
|
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
|
||||||
Strategy NullableString `json:"strategy,omitempty"`
|
Strategy NullableString `json:"strategy,omitempty"`
|
||||||
|
UpdateMode NullableString `json:"update_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type _MemoryItem MemoryItem
|
type _MemoryItem MemoryItem
|
||||||
|
|
@ -385,6 +386,48 @@ func (o *MemoryItem) UnsetStrategy() {
|
||||||
o.Strategy.Unset()
|
o.Strategy.Unset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUpdateMode returns the UpdateMode field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||||
|
func (o *MemoryItem) GetUpdateMode() string {
|
||||||
|
if o == nil || IsNil(o.UpdateMode.Get()) {
|
||||||
|
var ret string
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
return *o.UpdateMode.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdateModeOk returns a tuple with the UpdateMode 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 *MemoryItem) GetUpdateModeOk() (*string, bool) {
|
||||||
|
if o == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return o.UpdateMode.Get(), o.UpdateMode.IsSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUpdateMode returns a boolean if a field has been set.
|
||||||
|
func (o *MemoryItem) HasUpdateMode() bool {
|
||||||
|
if o != nil && o.UpdateMode.IsSet() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateMode gets a reference to the given NullableString and assigns it to the UpdateMode field.
|
||||||
|
func (o *MemoryItem) SetUpdateMode(v string) {
|
||||||
|
o.UpdateMode.Set(&v)
|
||||||
|
}
|
||||||
|
// SetUpdateModeNil sets the value for UpdateMode to be an explicit nil
|
||||||
|
func (o *MemoryItem) SetUpdateModeNil() {
|
||||||
|
o.UpdateMode.Set(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsetUpdateMode ensures that no value is present for UpdateMode, not even an explicit nil
|
||||||
|
func (o *MemoryItem) UnsetUpdateMode() {
|
||||||
|
o.UpdateMode.Unset()
|
||||||
|
}
|
||||||
|
|
||||||
func (o MemoryItem) MarshalJSON() ([]byte, error) {
|
func (o MemoryItem) MarshalJSON() ([]byte, error) {
|
||||||
toSerialize,err := o.ToMap()
|
toSerialize,err := o.ToMap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -420,6 +463,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) {
|
||||||
if o.Strategy.IsSet() {
|
if o.Strategy.IsSet() {
|
||||||
toSerialize["strategy"] = o.Strategy.Get()
|
toSerialize["strategy"] = o.Strategy.Get()
|
||||||
}
|
}
|
||||||
|
if o.UpdateMode.IsSet() {
|
||||||
|
toSerialize["update_mode"] = o.UpdateMode.Get()
|
||||||
|
}
|
||||||
return toSerialize, nil
|
return toSerialize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@ class Hindsight:
|
||||||
metadata: dict[str, str] | None = None,
|
metadata: dict[str, str] | None = None,
|
||||||
entities: list[dict[str, str]] | None = None,
|
entities: list[dict[str, str]] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
|
update_mode: str | None = None,
|
||||||
) -> RetainResponse:
|
) -> RetainResponse:
|
||||||
"""
|
"""
|
||||||
Store a single memory (sync wrapper — prefer :meth:`aretain` in async code).
|
Store a single memory (sync wrapper — prefer :meth:`aretain` in async code).
|
||||||
|
|
@ -251,22 +252,24 @@ class Hindsight:
|
||||||
metadata: Optional user-defined metadata
|
metadata: Optional user-defined metadata
|
||||||
entities: Optional list of entities [{"text": "...", "type": "..."}]
|
entities: Optional list of entities [{"text": "...", "type": "..."}]
|
||||||
tags: Optional list of tags for filtering memories during recall/reflect
|
tags: Optional list of tags for filtering memories during recall/reflect
|
||||||
|
update_mode: How to handle existing documents ('replace' or 'append')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RetainResponse with success status
|
RetainResponse with success status
|
||||||
"""
|
"""
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"content": content,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"context": context,
|
||||||
|
"metadata": metadata,
|
||||||
|
"entities": entities,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
if update_mode is not None:
|
||||||
|
item["update_mode"] = update_mode
|
||||||
return self.retain_batch(
|
return self.retain_batch(
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
items=[
|
items=[item],
|
||||||
{
|
|
||||||
"content": content,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"context": context,
|
|
||||||
"metadata": metadata,
|
|
||||||
"entities": entities,
|
|
||||||
"tags": tags,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -727,6 +730,7 @@ class Hindsight:
|
||||||
tags=item.get("tags"),
|
tags=item.get("tags"),
|
||||||
observation_scopes=obs_scopes,
|
observation_scopes=obs_scopes,
|
||||||
strategy=item.get("strategy"),
|
strategy=item.get("strategy"),
|
||||||
|
update_mode=item.get("update_mode"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -748,6 +752,7 @@ class Hindsight:
|
||||||
metadata: dict[str, str] | None = None,
|
metadata: dict[str, str] | None = None,
|
||||||
entities: list[dict[str, str]] | None = None,
|
entities: list[dict[str, str]] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
|
update_mode: str | None = None,
|
||||||
) -> RetainResponse:
|
) -> RetainResponse:
|
||||||
"""
|
"""
|
||||||
Store a single memory (async — preferred over :meth:`retain`).
|
Store a single memory (async — preferred over :meth:`retain`).
|
||||||
|
|
@ -761,22 +766,24 @@ class Hindsight:
|
||||||
metadata: Optional user-defined metadata
|
metadata: Optional user-defined metadata
|
||||||
entities: Optional list of entities [{"text": "...", "type": "..."}]
|
entities: Optional list of entities [{"text": "...", "type": "..."}]
|
||||||
tags: Optional list of tags for filtering memories during recall/reflect
|
tags: Optional list of tags for filtering memories during recall/reflect
|
||||||
|
update_mode: How to handle existing documents ('replace' or 'append')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RetainResponse with success status
|
RetainResponse with success status
|
||||||
"""
|
"""
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"content": content,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"context": context,
|
||||||
|
"metadata": metadata,
|
||||||
|
"entities": entities,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
if update_mode is not None:
|
||||||
|
item["update_mode"] = update_mode
|
||||||
return await self.aretain_batch(
|
return await self.aretain_batch(
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
items=[
|
items=[item],
|
||||||
{
|
|
||||||
"content": content,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"context": context,
|
|
||||||
"metadata": metadata,
|
|
||||||
"entities": entities,
|
|
||||||
"tags": tags,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import pprint
|
||||||
import re # noqa: F401
|
import re # noqa: F401
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
from pydantic import BaseModel, ConfigDict, StrictStr, field_validator
|
||||||
from typing import Any, ClassVar, Dict, List, Optional
|
from typing import Any, ClassVar, Dict, List, Optional
|
||||||
from hindsight_client_api.models.entity_input import EntityInput
|
from hindsight_client_api.models.entity_input import EntityInput
|
||||||
from hindsight_client_api.models.observation_scopes import ObservationScopes
|
from hindsight_client_api.models.observation_scopes import ObservationScopes
|
||||||
|
|
@ -38,7 +38,18 @@ class MemoryItem(BaseModel):
|
||||||
tags: Optional[List[StrictStr]] = None
|
tags: Optional[List[StrictStr]] = None
|
||||||
observation_scopes: Optional[ObservationScopes] = None
|
observation_scopes: Optional[ObservationScopes] = None
|
||||||
strategy: Optional[StrictStr] = None
|
strategy: Optional[StrictStr] = None
|
||||||
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes", "strategy"]
|
update_mode: Optional[StrictStr] = None
|
||||||
|
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes", "strategy", "update_mode"]
|
||||||
|
|
||||||
|
@field_validator('update_mode')
|
||||||
|
def update_mode_validate_enum(cls, value):
|
||||||
|
"""Validates the enum"""
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if value not in set(['replace', 'append']):
|
||||||
|
raise ValueError("must be one of enum values ('replace', 'append')")
|
||||||
|
return value
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
populate_by_name=True,
|
populate_by_name=True,
|
||||||
|
|
@ -132,6 +143,11 @@ class MemoryItem(BaseModel):
|
||||||
if self.strategy is None and "strategy" in self.model_fields_set:
|
if self.strategy is None and "strategy" in self.model_fields_set:
|
||||||
_dict['strategy'] = None
|
_dict['strategy'] = None
|
||||||
|
|
||||||
|
# set to None if update_mode (nullable) is None
|
||||||
|
# and model_fields_set contains the field
|
||||||
|
if self.update_mode is None and "update_mode" in self.model_fields_set:
|
||||||
|
_dict['update_mode'] = None
|
||||||
|
|
||||||
return _dict
|
return _dict
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -152,7 +168,8 @@ class MemoryItem(BaseModel):
|
||||||
"entities": [EntityInput.from_dict(_item) for _item in obj["entities"]] if obj.get("entities") is not None else None,
|
"entities": [EntityInput.from_dict(_item) for _item in obj["entities"]] if obj.get("entities") is not None else None,
|
||||||
"tags": obj.get("tags"),
|
"tags": obj.get("tags"),
|
||||||
"observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None,
|
"observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None,
|
||||||
"strategy": obj.get("strategy")
|
"strategy": obj.get("strategy"),
|
||||||
|
"update_mode": obj.get("update_mode")
|
||||||
})
|
})
|
||||||
return _obj
|
return _obj
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ mod tests {
|
||||||
tags: None,
|
tags: None,
|
||||||
observation_scopes: None,
|
observation_scopes: None,
|
||||||
strategy: None,
|
strategy: None,
|
||||||
|
update_mode: None,
|
||||||
},
|
},
|
||||||
types::MemoryItem {
|
types::MemoryItem {
|
||||||
content: "Bob works with Alice on the search team".to_string(),
|
content: "Bob works with Alice on the search team".to_string(),
|
||||||
|
|
@ -84,6 +85,7 @@ mod tests {
|
||||||
tags: None,
|
tags: None,
|
||||||
observation_scopes: None,
|
observation_scopes: None,
|
||||||
strategy: None,
|
strategy: None,
|
||||||
|
update_mode: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
document_tags: None,
|
document_tags: None,
|
||||||
|
|
|
||||||
|
|
@ -1657,6 +1657,12 @@ export type MemoryItem = {
|
||||||
* Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'.
|
* Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'.
|
||||||
*/
|
*/
|
||||||
strategy?: string | null;
|
strategy?: string | null;
|
||||||
|
/**
|
||||||
|
* Update Mode
|
||||||
|
*
|
||||||
|
* How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses.
|
||||||
|
*/
|
||||||
|
update_mode?: "replace" | "append" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export interface MemoryItemInput {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
|
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
|
||||||
strategy?: string;
|
strategy?: string;
|
||||||
|
update_mode?: "replace" | "append";
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HindsightClient {
|
export class HindsightClient {
|
||||||
|
|
@ -137,6 +138,8 @@ export class HindsightClient {
|
||||||
entities?: EntityInput[];
|
entities?: EntityInput[];
|
||||||
/** Optional list of tags for this memory */
|
/** Optional list of tags for this memory */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** How to handle existing documents: 'replace' (default) or 'append' */
|
||||||
|
updateMode?: "replace" | "append";
|
||||||
}
|
}
|
||||||
): Promise<RetainResponse> {
|
): Promise<RetainResponse> {
|
||||||
const item: {
|
const item: {
|
||||||
|
|
@ -147,6 +150,7 @@ export class HindsightClient {
|
||||||
document_id?: string;
|
document_id?: string;
|
||||||
entities?: EntityInput[];
|
entities?: EntityInput[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
update_mode?: "replace" | "append";
|
||||||
} = { content };
|
} = { content };
|
||||||
if (options?.timestamp) {
|
if (options?.timestamp) {
|
||||||
item.timestamp =
|
item.timestamp =
|
||||||
|
|
@ -169,6 +173,9 @@ export class HindsightClient {
|
||||||
if (options?.tags) {
|
if (options?.tags) {
|
||||||
item.tags = options.tags;
|
item.tags = options.tags;
|
||||||
}
|
}
|
||||||
|
if (options?.updateMode) {
|
||||||
|
item.update_mode = options.updateMode;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await sdk.retainMemories({
|
const response = await sdk.retainMemories({
|
||||||
client: this.client,
|
client: this.client,
|
||||||
|
|
@ -192,6 +199,7 @@ export class HindsightClient {
|
||||||
tags: item.tags,
|
tags: item.tags,
|
||||||
observation_scopes: item.observation_scopes,
|
observation_scopes: item.observation_scopes,
|
||||||
strategy: item.strategy,
|
strategy: item.strategy,
|
||||||
|
update_mode: item.update_mode,
|
||||||
timestamp:
|
timestamp:
|
||||||
item.timestamp instanceof Date
|
item.timestamp instanceof Date
|
||||||
? item.timestamp.toISOString()
|
? item.timestamp.toISOString()
|
||||||
|
|
|
||||||
|
|
@ -7232,6 +7232,22 @@
|
||||||
],
|
],
|
||||||
"title": "Strategy",
|
"title": "Strategy",
|
||||||
"description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'."
|
"description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'."
|
||||||
|
},
|
||||||
|
"update_mode": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"replace",
|
||||||
|
"append"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Update Mode",
|
||||||
|
"description": "How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -7232,6 +7232,22 @@
|
||||||
],
|
],
|
||||||
"title": "Strategy",
|
"title": "Strategy",
|
||||||
"description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'."
|
"description": "Named retain strategy for this item. Overrides the bank's default strategy for this item only. Strategies are defined in the bank config under 'retain_strategies'."
|
||||||
|
},
|
||||||
|
"update_mode": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"replace",
|
||||||
|
"append"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Update Mode",
|
||||||
|
"description": "How to handle an existing document with the same document_id. 'replace' (default) deletes old data and reprocesses from scratch. 'append' concatenates new content to the existing document text and reprocesses."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue