Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790d8618ef | ||
|
|
24726dd066 | ||
|
|
cb025ca2f1 | ||
|
|
7057c83ab1 | ||
|
|
ca77c1d73b | ||
|
|
a36b537185 | ||
|
|
99621a4b10 | ||
|
|
5f551f1745 | ||
|
|
8c95ac0ebb | ||
|
|
a6c734422e |
+35
@@ -0,0 +1,35 @@
|
||||
"""Add observation_scopes column to memory_units table
|
||||
|
||||
Revision ID: z1u2v3w4x5y6
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-02-25
|
||||
|
||||
Adds observation_scopes JSONB column to memory_units to control how observations
|
||||
are scoped during consolidation. Accepts "per_tag", "combined", or an explicit
|
||||
list of tag-set lists for custom multi-pass consolidation.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "z1u2v3w4x5y6"
|
||||
down_revision: str | Sequence[str] | None = "a1b2c3d4e5f6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS observation_scopes JSONB")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS observation_scopes")
|
||||
@@ -395,6 +395,16 @@ class MemoryItem(BaseModel):
|
||||
default=None,
|
||||
description="Optional tags for visibility scoping. Memories with tags can be filtered during recall.",
|
||||
)
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
|
||||
default=None,
|
||||
title="ObservationScopes",
|
||||
description=(
|
||||
"How to scope observations during consolidation. "
|
||||
"'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. "
|
||||
"'combined' (default) runs a single pass with all tags together. "
|
||||
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("timestamp", mode="before")
|
||||
@classmethod
|
||||
@@ -3784,6 +3794,8 @@ def _register_routes(app: FastAPI):
|
||||
content_dict["entities"] = [{"text": e.text, "type": e.type or "CONCEPT"} for e in item.entities]
|
||||
if item.tags:
|
||||
content_dict["tags"] = item.tags
|
||||
if item.observation_scopes is not None:
|
||||
content_dict["observation_scopes"] = item.observation_scopes
|
||||
contents.append(content_dict)
|
||||
|
||||
if request.async_:
|
||||
|
||||
@@ -17,6 +17,7 @@ import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from itertools import combinations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -193,7 +194,8 @@ async def run_consolidation_job(
|
||||
t0 = time.time()
|
||||
memories = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
|
||||
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at,
|
||||
observation_scopes
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidated_at IS NULL
|
||||
@@ -239,15 +241,84 @@ async def run_consolidation_job(
|
||||
consolidated_tags.update(memory_tags)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
results = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memories=llm_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
)
|
||||
# Determine observation_scopes for this batch. All memories in a batch share
|
||||
# the same tags (enforced by tag_groups), so we only check the first memory.
|
||||
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
|
||||
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
|
||||
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
|
||||
|
||||
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
|
||||
if _obs_parsed == "per_tag":
|
||||
_memory_tags = llm_batch[0].get("tags") or []
|
||||
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
|
||||
elif _obs_parsed == "all_combinations":
|
||||
_memory_tags = llm_batch[0].get("tags") or []
|
||||
obs_tags_list = (
|
||||
[
|
||||
list(combo)
|
||||
for r in range(1, len(_memory_tags) + 1)
|
||||
for combo in combinations(_memory_tags, r)
|
||||
]
|
||||
if _memory_tags
|
||||
else None
|
||||
)
|
||||
elif _obs_parsed == "combined" or _obs_parsed is None:
|
||||
obs_tags_list = None # single combined pass (default behaviour)
|
||||
else:
|
||||
# explicit list[list[str]]
|
||||
obs_tags_list = _obs_parsed
|
||||
|
||||
if obs_tags_list:
|
||||
# Multi-pass: run one observation consolidation pass per tag set
|
||||
results = []
|
||||
for obs_tags in obs_tags_list:
|
||||
pass_results = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memories=llm_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
obs_tags_override=obs_tags,
|
||||
)
|
||||
# Merge results: prefer non-skipped actions
|
||||
if not results:
|
||||
results = pass_results
|
||||
else:
|
||||
for i, (existing, new) in enumerate(zip(results, pass_results)):
|
||||
if existing.get("action") == "skipped" and new.get("action") != "skipped":
|
||||
results[i] = new
|
||||
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
|
||||
# Both did something — combine into "multiple"
|
||||
existing_created = existing.get(
|
||||
"created", 1 if existing.get("action") == "created" else 0
|
||||
)
|
||||
existing_updated = existing.get(
|
||||
"updated", 1 if existing.get("action") == "updated" else 0
|
||||
)
|
||||
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
|
||||
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
|
||||
total = existing_created + existing_updated + new_created + new_updated
|
||||
results[i] = {
|
||||
"action": "multiple",
|
||||
"created": existing_created + new_created,
|
||||
"updated": existing_updated + new_updated,
|
||||
"merged": 0,
|
||||
"total_actions": total,
|
||||
}
|
||||
else:
|
||||
# Normal single pass using the memory's own tags
|
||||
results = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memories=llm_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
)
|
||||
|
||||
await conn.executemany(
|
||||
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
|
||||
[(m["id"],) for m in llm_batch],
|
||||
@@ -441,6 +512,7 @@ async def _process_memory_batch(
|
||||
request_context: "RequestContext",
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
config: Any = None,
|
||||
obs_tags_override: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Process a batch of memories in a single LLM call.
|
||||
@@ -455,18 +527,26 @@ async def _process_memory_batch(
|
||||
Per-fact security: action execution validates each learning_id against the
|
||||
observations that were recalled specifically for that fact, so cross-tag
|
||||
updates cannot occur.
|
||||
|
||||
Args:
|
||||
obs_tags_override: When set, use these tags for observation recall and
|
||||
create/update instead of the memory's own tags. This enables multi-pass
|
||||
consolidation where a single memory can contribute to observations
|
||||
scoped at different tag levels (e.g., user-level vs session-level).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# 1. Parallel recalls — one per fact
|
||||
# When obs_tags_override is set, use it as the observation scope for all facts.
|
||||
t0 = time.time()
|
||||
observation_scope_tags = obs_tags_override if obs_tags_override is not None else None
|
||||
recall_tasks = [
|
||||
_find_related_observations(
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
query=m["text"],
|
||||
request_context=request_context,
|
||||
tags=m.get("tags") or [],
|
||||
tags=observation_scope_tags if observation_scope_tags is not None else (m.get("tags") or []),
|
||||
)
|
||||
for m in memories
|
||||
]
|
||||
@@ -510,8 +590,13 @@ async def _process_memory_batch(
|
||||
per_memory_created: set[str] = set()
|
||||
per_memory_updated: set[str] = set()
|
||||
|
||||
# All memories in the batch share the same tag set (enforced by batching)
|
||||
fact_tags = memories[0].get("tags") or [] if memories else []
|
||||
# Determine effective tag scope for observations.
|
||||
# When obs_tags_override is set, use it; otherwise use the memory's own tags.
|
||||
if obs_tags_override is not None:
|
||||
fact_tags = obs_tags_override
|
||||
else:
|
||||
# All memories in the batch share the same tag set (enforced by batching)
|
||||
fact_tags = memories[0].get("tags") or [] if memories else []
|
||||
|
||||
mem_by_id = {str(m["id"]): m for m in memories}
|
||||
|
||||
|
||||
@@ -3646,7 +3646,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Only include if the target is visible
|
||||
if to_id in unit_id_set or to_observations:
|
||||
target = to_observations[0] if to_observations and to_id not in unit_id_set else to_id
|
||||
if target in unit_id_set:
|
||||
if target in unit_id_set and obs_id != target:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": obs_id,
|
||||
@@ -3660,15 +3660,16 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# If to_id is a source memory, copy links to its observations
|
||||
if to_observations and from_id in unit_id_set:
|
||||
for obs_id in to_observations:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": from_id,
|
||||
"to_unit_id": obs_id,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
if from_id != obs_id:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": from_id,
|
||||
"to_unit_id": obs_id,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
|
||||
# Keep only direct links between visible nodes
|
||||
direct_links = [
|
||||
@@ -3737,9 +3738,63 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# Build edges (combine direct links and copied links from sources)
|
||||
# Build observation-inferred links from inherited entities and shared source memories.
|
||||
# Observations never have direct memory_links rows, so all their links must be derived.
|
||||
observation_units = [unit for unit in units if unit["fact_type"] == "observation"]
|
||||
observation_ids = {unit["id"] for unit in observation_units}
|
||||
|
||||
# Entity links: pair observations that share at least one inherited entity
|
||||
entity_to_observations: dict[str, list] = {}
|
||||
for obs_id in observation_ids:
|
||||
for entity_name in entity_map.get(obs_id, []):
|
||||
entity_to_observations.setdefault(entity_name, []).append(obs_id)
|
||||
|
||||
# Semantic links: pair observations that share at least one source memory
|
||||
source_to_obs_for_semantic: dict = {}
|
||||
for unit in observation_units:
|
||||
if unit["source_memory_ids"]:
|
||||
for src_id in unit["source_memory_ids"]:
|
||||
source_to_obs_for_semantic.setdefault(src_id, []).append(unit["id"])
|
||||
|
||||
observation_inferred_links = []
|
||||
seen_inferred: set[tuple] = set()
|
||||
|
||||
for entity_name, obs_ids in entity_to_observations.items():
|
||||
for i, obs_a in enumerate(obs_ids):
|
||||
for obs_b in obs_ids[i + 1 :]:
|
||||
pair = (min(str(obs_a), str(obs_b)), max(str(obs_a), str(obs_b)), "entity", entity_name)
|
||||
if pair not in seen_inferred:
|
||||
seen_inferred.add(pair)
|
||||
observation_inferred_links.append(
|
||||
{
|
||||
"from_unit_id": obs_a,
|
||||
"to_unit_id": obs_b,
|
||||
"link_type": "entity",
|
||||
"weight": 1.0,
|
||||
"entity_name": entity_name,
|
||||
}
|
||||
)
|
||||
|
||||
for src_id, obs_ids in source_to_obs_for_semantic.items():
|
||||
for i, obs_a in enumerate(obs_ids):
|
||||
for obs_b in obs_ids[i + 1 :]:
|
||||
pair = (min(str(obs_a), str(obs_b)), max(str(obs_a), str(obs_b)), "semantic", "")
|
||||
if pair not in seen_inferred:
|
||||
seen_inferred.add(pair)
|
||||
observation_inferred_links.append(
|
||||
{
|
||||
"from_unit_id": obs_a,
|
||||
"to_unit_id": obs_b,
|
||||
"link_type": "semantic",
|
||||
"weight": 1.0,
|
||||
"entity_name": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Build edges (combine direct links, copied links from sources, and observation-inferred links)
|
||||
edges = []
|
||||
all_links = direct_links + copied_links
|
||||
seen_edges: set[tuple] = set()
|
||||
all_links = direct_links + copied_links + observation_inferred_links
|
||||
for row in all_links:
|
||||
from_id = str(row["from_unit_id"])
|
||||
to_id = str(row["to_unit_id"])
|
||||
@@ -3761,6 +3816,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
color = "#999999"
|
||||
line_style = "solid"
|
||||
|
||||
edge_key = (from_id, to_id, link_type, entity_name or "")
|
||||
if edge_key in seen_edges:
|
||||
continue
|
||||
seen_edges.add(edge_key)
|
||||
|
||||
edges.append(
|
||||
{
|
||||
"data": {
|
||||
@@ -3958,7 +4018,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids,
|
||||
observation_scopes
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
@@ -3981,6 +4042,19 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
entities = [r["canonical_name"] for r in entities_rows]
|
||||
|
||||
# For observations with no direct entities, inherit from source memories
|
||||
if not entities and row["fact_type"] == "observation" and row["source_memory_ids"]:
|
||||
source_entities_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT e.canonical_name
|
||||
FROM {fq_table("unit_entities")} ue
|
||||
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
|
||||
WHERE ue.unit_id = ANY($1::uuid[])
|
||||
""",
|
||||
row["source_memory_ids"],
|
||||
)
|
||||
entities = [r["canonical_name"] for r in source_entities_rows]
|
||||
|
||||
result = {
|
||||
"id": str(row["id"]),
|
||||
"text": row["text"],
|
||||
@@ -3994,6 +4068,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"document_id": row["document_id"] if row["document_id"] else None,
|
||||
"chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None,
|
||||
"tags": row["tags"] if row["tags"] else [],
|
||||
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
|
||||
}
|
||||
|
||||
# For observations, include source_memory_ids and fetch source_memories
|
||||
|
||||
@@ -1701,6 +1701,7 @@ async def extract_facts_from_contents_batch_api(
|
||||
mentioned_at=content.event_date,
|
||||
metadata=content.metadata,
|
||||
tags=content.tags,
|
||||
observation_scopes=content.observation_scopes,
|
||||
)
|
||||
|
||||
extracted_facts.append(extracted_fact)
|
||||
@@ -1831,6 +1832,7 @@ async def extract_facts_from_contents(
|
||||
mentioned_at=content.event_date,
|
||||
metadata=content.metadata,
|
||||
tags=content.tags,
|
||||
observation_scopes=content.observation_scopes,
|
||||
)
|
||||
|
||||
extracted_facts.append(extracted_fact)
|
||||
|
||||
@@ -47,6 +47,7 @@ async def insert_facts_batch(
|
||||
chunk_ids = []
|
||||
document_ids = []
|
||||
tags_list = []
|
||||
observation_scopes_list = []
|
||||
|
||||
for fact in facts:
|
||||
fact_texts.append(_sanitize_text(fact.fact_text))
|
||||
@@ -68,6 +69,10 @@ async def insert_facts_batch(
|
||||
document_ids.append(fact.document_id if fact.document_id else document_id)
|
||||
# Convert tags to JSON string for proper batch insertion (PostgreSQL unnest doesn't handle 2D arrays well)
|
||||
tags_list.append(json.dumps(fact.tags if fact.tags else []))
|
||||
# observation_scopes: stored as JSONB (string or 2D array), None if not provided
|
||||
observation_scopes_list.append(
|
||||
json.dumps(fact.observation_scopes) if fact.observation_scopes is not None else None
|
||||
)
|
||||
|
||||
# Batch insert all facts
|
||||
# Note: tags are passed as JSON strings and converted back to varchar[] via jsonb_array_elements_text + array_agg
|
||||
@@ -79,12 +84,14 @@ async def insert_facts_batch(
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags, search_vector)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
@@ -93,6 +100,7 @@ async def insert_facts_batch(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
observation_scopes_json,
|
||||
tokenize(COALESCE(text, '') || ' ' || COALESCE(context, ''), 'llmlingua2')::bm25_catalog.bm25vector
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
@@ -104,12 +112,14 @@ async def insert_facts_batch(
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json,
|
||||
observation_scopes_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
|
||||
observation_scopes)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
@@ -117,7 +127,8 @@ async def insert_facts_batch(
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
)
|
||||
),
|
||||
observation_scopes_json
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
@@ -138,6 +149,7 @@ async def insert_facts_batch(
|
||||
chunk_ids,
|
||||
document_ids,
|
||||
tags_list,
|
||||
observation_scopes_list,
|
||||
)
|
||||
|
||||
unit_ids = [str(row["id"]) for row in results]
|
||||
|
||||
@@ -142,6 +142,7 @@ async def retain_batch(
|
||||
metadata=item.get("metadata", {}),
|
||||
entities=item.get("entities", []),
|
||||
tags=merged_tags,
|
||||
observation_scopes=item.get("observation_scopes"),
|
||||
)
|
||||
contents.append(content)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from content input to fact storage.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TypedDict
|
||||
from typing import Literal, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ class RetainContentDict(TypedDict, total=False):
|
||||
document_id: Document ID for this content item (optional)
|
||||
entities: User-provided entities to merge with extracted entities (optional)
|
||||
tags: Visibility scope tags for this content item (optional)
|
||||
observation_scopes: How to scope observations for consolidation (optional).
|
||||
"per_tag" runs one pass per individual tag; "combined" (default) runs a
|
||||
single pass with all tags; a list[list[str]] specifies exact passes.
|
||||
"""
|
||||
|
||||
content: str # Required
|
||||
@@ -31,6 +34,9 @@ class RetainContentDict(TypedDict, total=False):
|
||||
document_id: str
|
||||
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
|
||||
tags: list[str] # Visibility scope tags
|
||||
observation_scopes: (
|
||||
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
|
||||
) # Observation scopes for consolidation
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
@@ -52,6 +58,9 @@ class RetainContent:
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
|
||||
tags: list[str] = field(default_factory=list) # Visibility scope tags
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
|
||||
None # Observation scopes
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -117,6 +126,9 @@ class ExtractedFact:
|
||||
mentioned_at: datetime | None = None
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
tags: list[str] = field(default_factory=list) # Visibility scope tags
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
|
||||
None # Observation scopes
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -165,6 +177,9 @@ class ProcessedFact:
|
||||
# Visibility scope tags
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
# Observation scopes for consolidation
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None
|
||||
|
||||
@property
|
||||
def is_duplicate(self) -> bool:
|
||||
"""Check if this fact was marked as a duplicate."""
|
||||
@@ -209,6 +224,7 @@ class ProcessedFact:
|
||||
chunk_id=chunk_id,
|
||||
content_index=extracted_fact.content_index,
|
||||
tags=extracted_fact.tags,
|
||||
observation_scopes=extracted_fact.observation_scopes,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2084,3 +2084,236 @@ async def test_consolidation_with_observations_mission(memory: "MemoryEngine", r
|
||||
else:
|
||||
os.environ["HINDSIGHT_API_OBSERVATIONS_MISSION"] = original
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_scopes_explicit_multi_pass(memory: MemoryEngine, request_context):
|
||||
"""Test that observation_scopes with an explicit list triggers separate consolidation passes.
|
||||
|
||||
A single memory stored with observation_scopes=[["user:alice"], ["teacher:ben"]]
|
||||
must produce:
|
||||
- At least one observation with tags containing ONLY "user:alice" (not "teacher:ben")
|
||||
- At least one observation with tags containing ONLY "teacher:ben" (not "user:alice")
|
||||
|
||||
The two tag scopes must remain isolated — no observation should carry both tags,
|
||||
which would indicate the scopes were incorrectly merged.
|
||||
"""
|
||||
bank_id = f"test-obs-scopes-explicit-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Retain a memory with two explicit observation scopes
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Alice, a student, worked hard in the lesson with teacher Ben.",
|
||||
"observation_scopes": [["user:alice"], ["teacher:ben"]],
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
async with memory._pool.acquire() as conn:
|
||||
observations = await conn.fetch(
|
||||
"""
|
||||
SELECT id, text, tags
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = 'observation'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# Must have at least 2 observations (one per tag scope)
|
||||
assert len(observations) >= 2, (
|
||||
f"Expected at least 2 observations (one per tag scope), got {len(observations)}: "
|
||||
+ str([dict(o) for o in observations])
|
||||
)
|
||||
|
||||
tag_sets = [set(obs["tags"] or []) for obs in observations]
|
||||
|
||||
# There must be at least one observation scoped to user:alice only
|
||||
alice_only = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" not in ts]
|
||||
assert alice_only, (
|
||||
f"Expected an observation scoped to 'user:alice' only, got tag sets: {tag_sets}"
|
||||
)
|
||||
|
||||
# There must be at least one observation scoped to teacher:ben only
|
||||
ben_only = [ts for ts in tag_sets if "teacher:ben" in ts and "user:alice" not in ts]
|
||||
assert ben_only, (
|
||||
f"Expected an observation scoped to 'teacher:ben' only, got tag sets: {tag_sets}"
|
||||
)
|
||||
|
||||
# No observation should carry both tags (scopes must not be merged)
|
||||
both = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" in ts]
|
||||
assert not both, (
|
||||
f"Found observation(s) with both tags — scopes were incorrectly merged: {both}"
|
||||
)
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_scopes_per_tag(memory: MemoryEngine, request_context):
|
||||
"""Test that observation_scopes='per_tag' derives one pass per individual tag.
|
||||
|
||||
A memory with tags=["user:alice", "teacher:ben"] and observation_scopes="per_tag"
|
||||
must produce isolated observations — one scoped to "user:alice" and one to "teacher:ben".
|
||||
"""
|
||||
bank_id = f"test-obs-scopes-pertag-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Alice, a student, worked hard in the lesson with teacher Ben.",
|
||||
"tags": ["user:alice", "teacher:ben"],
|
||||
"observation_scopes": "per_tag",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
async with memory._pool.acquire() as conn:
|
||||
observations = await conn.fetch(
|
||||
"""
|
||||
SELECT id, text, tags
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = 'observation'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
try:
|
||||
assert len(observations) >= 2, (
|
||||
f"Expected at least 2 observations (one per tag), got {len(observations)}: "
|
||||
+ str([dict(o) for o in observations])
|
||||
)
|
||||
|
||||
tag_sets = [set(obs["tags"] or []) for obs in observations]
|
||||
|
||||
alice_only = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" not in ts]
|
||||
assert alice_only, f"Expected an observation scoped to 'user:alice' only, got: {tag_sets}"
|
||||
|
||||
ben_only = [ts for ts in tag_sets if "teacher:ben" in ts and "user:alice" not in ts]
|
||||
assert ben_only, f"Expected an observation scoped to 'teacher:ben' only, got: {tag_sets}"
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_scopes_combined(memory: MemoryEngine, request_context):
|
||||
"""Test that observation_scopes='combined' produces a single observation with all tags.
|
||||
|
||||
A memory with tags=["user:alice", "teacher:ben"] and observation_scopes="combined"
|
||||
must produce at least one observation that carries both tags together, and no
|
||||
observation scoped to only one of them.
|
||||
"""
|
||||
bank_id = f"test-obs-scopes-combined-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Alice, a student, worked hard in the lesson with teacher Ben.",
|
||||
"tags": ["user:alice", "teacher:ben"],
|
||||
"observation_scopes": "combined",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
async with memory._pool.acquire() as conn:
|
||||
observations = await conn.fetch(
|
||||
"""
|
||||
SELECT id, text, tags
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = 'observation'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
try:
|
||||
assert len(observations) >= 1, (
|
||||
"Expected at least 1 observation, got 0"
|
||||
)
|
||||
|
||||
tag_sets = [set(obs["tags"] or []) for obs in observations]
|
||||
|
||||
# All observations must carry both tags (combined scope)
|
||||
combined = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" in ts]
|
||||
assert combined, f"Expected at least one observation with both tags, got: {tag_sets}"
|
||||
|
||||
# No observation should be scoped to only one tag
|
||||
alice_only = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" not in ts]
|
||||
assert not alice_only, f"Expected no alice-only observation in combined mode, got: {tag_sets}"
|
||||
|
||||
ben_only = [ts for ts in tag_sets if "teacher:ben" in ts and "user:alice" not in ts]
|
||||
assert not ben_only, f"Expected no ben-only observation in combined mode, got: {tag_sets}"
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_scopes_all_combinations(memory: MemoryEngine, request_context):
|
||||
"""Test that observation_scopes='all_combinations' generates passes for every tag subset.
|
||||
|
||||
A memory with tags=["user:alice", "teacher:ben"] and observation_scopes="all_combinations"
|
||||
must produce observations covering all subsets: ["user:alice"], ["teacher:ben"], and
|
||||
["user:alice", "teacher:ben"].
|
||||
"""
|
||||
bank_id = f"test-obs-scopes-allcombos-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Alice, a student, worked hard in the lesson with teacher Ben.",
|
||||
"tags": ["user:alice", "teacher:ben"],
|
||||
"observation_scopes": "all_combinations",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
async with memory._pool.acquire() as conn:
|
||||
observations = await conn.fetch(
|
||||
"""
|
||||
SELECT id, text, tags
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = 'observation'
|
||||
ORDER BY created_at
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# With 2 tags there are 3 subsets: {alice}, {ben}, {alice, ben}
|
||||
assert len(observations) >= 3, (
|
||||
f"Expected at least 3 observations (one per subset), got {len(observations)}: "
|
||||
+ str([dict(o) for o in observations])
|
||||
)
|
||||
|
||||
tag_sets = [set(obs["tags"] or []) for obs in observations]
|
||||
|
||||
alice_only = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" not in ts]
|
||||
assert alice_only, f"Expected an observation scoped to 'user:alice' only, got: {tag_sets}"
|
||||
|
||||
ben_only = [ts for ts in tag_sets if "teacher:ben" in ts and "user:alice" not in ts]
|
||||
assert ben_only, f"Expected an observation scoped to 'teacher:ben' only, got: {tag_sets}"
|
||||
|
||||
combined = [ts for ts in tag_sets if "user:alice" in ts and "teacher:ben" in ts]
|
||||
assert combined, f"Expected an observation scoped to both tags, got: {tag_sets}"
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -15,6 +15,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(
|
||||
strict=False,
|
||||
reason="Gemini sometimes consistently translates Chinese content to English despite instructions",
|
||||
)
|
||||
async def test_retain_chinese_content(memory, request_context):
|
||||
"""
|
||||
Test that retain correctly extracts facts from Chinese content
|
||||
@@ -24,70 +28,87 @@ async def test_retain_chinese_content(memory, request_context):
|
||||
1. Facts are extracted from Chinese text
|
||||
2. The extracted facts contain Chinese characters
|
||||
3. Entity names are preserved in Chinese
|
||||
|
||||
Note: LLM fact extraction is non-deterministic and may sometimes translate
|
||||
content to English despite instructions. We retry up to 3 times.
|
||||
"""
|
||||
bank_id = f"test_chinese_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
try:
|
||||
# Chinese content about a person and their activities
|
||||
chinese_content = """
|
||||
张伟是一位资深软件工程师,在腾讯工作了五年。他专门研究分布式系统,
|
||||
并领导了公司微服务架构的开发。他以编写干净、文档完善的代码而闻名。
|
||||
for attempt in range(max_retries):
|
||||
bank_id = f"test_chinese_retain_{datetime.now(timezone.utc).timestamp()}_{attempt}"
|
||||
|
||||
李明上个月加入团队担任初级开发人员。他正在学习React和Node.js。
|
||||
李明很有热情,在代码审查中提出很好的问题。他最近完成了他的第一个功能,
|
||||
这是一个用户认证流程。
|
||||
try:
|
||||
# Chinese content about a person and their activities
|
||||
chinese_content = """
|
||||
张伟是一位资深软件工程师,在腾讯工作了五年。他专门研究分布式系统,
|
||||
并领导了公司微服务架构的开发。他以编写干净、文档完善的代码而闻名。
|
||||
|
||||
团队使用Kubernetes进行容器编排,并部署到阿里云。他们遵循敏捷方法论,
|
||||
采用两周冲刺周期。合并前必须进行代码审查。
|
||||
"""
|
||||
李明上个月加入团队担任初级开发人员。他正在学习React和Node.js。
|
||||
李明很有热情,在代码审查中提出很好的问题。他最近完成了他的第一个功能,
|
||||
这是一个用户认证流程。
|
||||
|
||||
# Retain the Chinese content
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=chinese_content,
|
||||
context="团队概述", # Chinese context
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
团队使用Kubernetes进行容器编排,并部署到阿里云。他们遵循敏捷方法论,
|
||||
采用两周冲刺周期。合并前必须进行代码审查。
|
||||
"""
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Chinese content")
|
||||
assert len(unit_ids) > 0, "Should have extracted and stored facts from Chinese content"
|
||||
# Retain the Chinese content
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=chinese_content,
|
||||
context="团队概述", # Chinese context
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Recall the facts with a Chinese query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="告诉我关于张伟的信息", # "Tell me about Zhang Wei"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Chinese content (attempt {attempt + 1})")
|
||||
assert len(unit_ids) > 0, "Should have extracted and stored facts from Chinese content"
|
||||
|
||||
logger.info(f"Recalled {len(result.results)} facts")
|
||||
assert len(result.results) > 0, "Should recall facts about Zhang Wei"
|
||||
# Recall the facts with a Chinese query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="告诉我关于张伟的信息", # "Tell me about Zhang Wei"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify that the facts contain Chinese characters
|
||||
# At least one fact should mention 张伟 (Zhang Wei) or related Chinese content
|
||||
chinese_facts_found = 0
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text[:100]}...")
|
||||
# Check for common Chinese characters or the name
|
||||
if any(
|
||||
char in fact.text
|
||||
for char in ["张", "伟", "腾讯", "软件", "工程师", "分布式", "系统", "代码"]
|
||||
):
|
||||
chinese_facts_found += 1
|
||||
logger.info(f"Recalled {len(result.results)} facts")
|
||||
assert len(result.results) > 0, "Should recall facts about Zhang Wei"
|
||||
|
||||
logger.info(f"Found {chinese_facts_found} facts with Chinese content")
|
||||
assert chinese_facts_found > 0, (
|
||||
f"Expected facts to contain Chinese characters, but none found. "
|
||||
f"Facts: {[f.text for f in result.results]}"
|
||||
)
|
||||
# Verify that the facts contain Chinese characters
|
||||
# At least one fact should mention 张伟 (Zhang Wei) or related Chinese content
|
||||
chinese_facts_found = 0
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text[:100]}...")
|
||||
# Check for common Chinese characters or the name
|
||||
if any(
|
||||
char in fact.text
|
||||
for char in ["张", "伟", "腾讯", "软件", "工程师", "分布式", "系统", "代码"]
|
||||
):
|
||||
chinese_facts_found += 1
|
||||
|
||||
logger.info("Chinese retain test passed - facts preserved in Chinese")
|
||||
logger.info(f"Found {chinese_facts_found} facts with Chinese content")
|
||||
assert chinese_facts_found > 0, (
|
||||
f"Expected facts to contain Chinese characters, but none found. "
|
||||
f"Facts: {[f.text for f in result.results]}"
|
||||
)
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
logger.info("Chinese retain test passed - facts preserved in Chinese")
|
||||
return # Test passed
|
||||
|
||||
except AssertionError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"Attempt {attempt + 1} failed: {e}. Retrying...")
|
||||
else:
|
||||
raise e
|
||||
finally:
|
||||
try:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -233,6 +233,35 @@ class TestFilterResultsByTags:
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].tags == ["a", "b", "c"] # Has a, b, AND c
|
||||
|
||||
def test_all_strict_superset_observation_matches_incoming_memory_tags(self):
|
||||
"""
|
||||
Consolidation scenario: an incoming memory with tags ['user:bob', 'session:id1']
|
||||
uses all_strict matching to find existing observations.
|
||||
|
||||
An observation tagged ['user:bob', 'session:id1', 'place:online'] IS matched
|
||||
because it contains all of the incoming memory's tags (superset).
|
||||
This is NOT exact matching — an observation with extra tags is still a valid match.
|
||||
"""
|
||||
# Incoming memory tags (e.g. from a new retain call)
|
||||
incoming_tags = ["user:bob", "session:id1"]
|
||||
|
||||
# Candidate observations with different tag sets
|
||||
exact_match = MockResult(["user:bob", "session:id1"])
|
||||
superset_match = MockResult(["session:id1", "user:bob", "place:online"])
|
||||
different_user = MockResult(["user:alice", "session:id1"])
|
||||
missing_session = MockResult(["user:bob"])
|
||||
|
||||
results = [exact_match, superset_match, different_user, missing_session]
|
||||
filtered = filter_results_by_tags(results, incoming_tags, match="all_strict")
|
||||
|
||||
# Both exact_match and superset_match have all incoming tags → both match
|
||||
assert len(filtered) == 2
|
||||
assert exact_match in filtered
|
||||
assert superset_match in filtered
|
||||
# different_user and missing_session are excluded because they lack at least one tag
|
||||
assert different_user not in filtered
|
||||
assert missing_session not in filtered
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests for tags in retain/recall/reflect
|
||||
|
||||
@@ -387,6 +387,7 @@ pub fn retain(
|
||||
document_id: Some(doc_id.clone()),
|
||||
entities: None,
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
};
|
||||
|
||||
let request = RetainRequest {
|
||||
|
||||
@@ -3500,6 +3500,8 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
observation_scopes:
|
||||
$ref: '#/components/schemas/ObservationScopes'
|
||||
required:
|
||||
- content
|
||||
title: MemoryItem
|
||||
@@ -4457,6 +4459,25 @@ components:
|
||||
- api_version
|
||||
- features
|
||||
title: VersionResponse
|
||||
ObservationScopes:
|
||||
anyOf:
|
||||
- enum:
|
||||
- per_tag
|
||||
- combined
|
||||
- all_combinations
|
||||
type: string
|
||||
- items:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: array
|
||||
description: "How to scope observations during consolidation. 'per_tag' runs\
|
||||
\ one consolidation pass per individual tag, creating separate observations\
|
||||
\ for each tag. 'combined' (default) runs a single pass with all tags together.\
|
||||
\ A list of tag lists runs one pass per inner list, giving full control over\
|
||||
\ which combinations to use."
|
||||
nullable: true
|
||||
title: ObservationScopes
|
||||
ValidationError_loc_inner:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -29,6 +29,7 @@ type MemoryItem struct {
|
||||
DocumentId NullableString `json:"document_id,omitempty"`
|
||||
Entities []EntityInput `json:"entities,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
|
||||
}
|
||||
|
||||
type _MemoryItem MemoryItem
|
||||
@@ -300,6 +301,48 @@ func (o *MemoryItem) SetTags(v []string) {
|
||||
o.Tags = v
|
||||
}
|
||||
|
||||
// GetObservationScopes returns the ObservationScopes field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *MemoryItem) GetObservationScopes() ObservationScopes {
|
||||
if o == nil || IsNil(o.ObservationScopes.Get()) {
|
||||
var ret ObservationScopes
|
||||
return ret
|
||||
}
|
||||
return *o.ObservationScopes.Get()
|
||||
}
|
||||
|
||||
// GetObservationScopesOk returns a tuple with the ObservationScopes 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) GetObservationScopesOk() (*ObservationScopes, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.ObservationScopes.Get(), o.ObservationScopes.IsSet()
|
||||
}
|
||||
|
||||
// HasObservationScopes returns a boolean if a field has been set.
|
||||
func (o *MemoryItem) HasObservationScopes() bool {
|
||||
if o != nil && o.ObservationScopes.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetObservationScopes gets a reference to the given NullableObservationScopes and assigns it to the ObservationScopes field.
|
||||
func (o *MemoryItem) SetObservationScopes(v ObservationScopes) {
|
||||
o.ObservationScopes.Set(&v)
|
||||
}
|
||||
// SetObservationScopesNil sets the value for ObservationScopes to be an explicit nil
|
||||
func (o *MemoryItem) SetObservationScopesNil() {
|
||||
o.ObservationScopes.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetObservationScopes ensures that no value is present for ObservationScopes, not even an explicit nil
|
||||
func (o *MemoryItem) UnsetObservationScopes() {
|
||||
o.ObservationScopes.Unset()
|
||||
}
|
||||
|
||||
func (o MemoryItem) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -329,6 +372,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) {
|
||||
if o.Tags != nil {
|
||||
toSerialize["tags"] = o.Tags
|
||||
}
|
||||
if o.ObservationScopes.IsSet() {
|
||||
toSerialize["observation_scopes"] = o.ObservationScopes.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.14
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
|
||||
// ObservationScopes How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
|
||||
type ObservationScopes struct {
|
||||
ArrayOfArrayOfString *[][]string
|
||||
String *string
|
||||
}
|
||||
|
||||
// Unmarshal JSON data into any of the pointers in the struct
|
||||
func (dst *ObservationScopes) UnmarshalJSON(data []byte) error {
|
||||
var err error
|
||||
// this object is nullable so check if the payload is null or empty string
|
||||
if string(data) == "" || string(data) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// try to unmarshal JSON data into ArrayOfArrayOfString
|
||||
err = json.Unmarshal(data, &dst.ArrayOfArrayOfString);
|
||||
if err == nil {
|
||||
jsonArrayOfArrayOfString, _ := json.Marshal(dst.ArrayOfArrayOfString)
|
||||
if string(jsonArrayOfArrayOfString) == "{}" { // empty struct
|
||||
dst.ArrayOfArrayOfString = nil
|
||||
} else {
|
||||
return nil // data stored in dst.ArrayOfArrayOfString, return on the first match
|
||||
}
|
||||
} else {
|
||||
dst.ArrayOfArrayOfString = nil
|
||||
}
|
||||
|
||||
// try to unmarshal JSON data into String
|
||||
err = json.Unmarshal(data, &dst.String);
|
||||
if err == nil {
|
||||
jsonString, _ := json.Marshal(dst.String)
|
||||
if string(jsonString) == "{}" { // empty struct
|
||||
dst.String = nil
|
||||
} else {
|
||||
return nil // data stored in dst.String, return on the first match
|
||||
}
|
||||
} else {
|
||||
dst.String = nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("data failed to match schemas in anyOf(ObservationScopes)")
|
||||
}
|
||||
|
||||
// Marshal data from the first non-nil pointers in the struct to JSON
|
||||
func (src *ObservationScopes) MarshalJSON() ([]byte, error) {
|
||||
if src.ArrayOfArrayOfString != nil {
|
||||
return json.Marshal(&src.ArrayOfArrayOfString)
|
||||
}
|
||||
|
||||
if src.String != nil {
|
||||
return json.Marshal(&src.String)
|
||||
}
|
||||
|
||||
return nil, nil // no data in anyOf schemas
|
||||
}
|
||||
|
||||
|
||||
type NullableObservationScopes struct {
|
||||
value *ObservationScopes
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableObservationScopes) Get() *ObservationScopes {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableObservationScopes) Set(val *ObservationScopes) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableObservationScopes) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableObservationScopes) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableObservationScopes(val *ObservationScopes) *NullableObservationScopes {
|
||||
return &NullableObservationScopes{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableObservationScopes) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableObservationScopes) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ hindsight_client_api/models/memory_item.py
|
||||
hindsight_client_api/models/mental_model_list_response.py
|
||||
hindsight_client_api/models/mental_model_response.py
|
||||
hindsight_client_api/models/mental_model_trigger.py
|
||||
hindsight_client_api/models/observation_scopes.py
|
||||
hindsight_client_api/models/operation_response.py
|
||||
hindsight_client_api/models/operation_status_response.py
|
||||
hindsight_client_api/models/operations_list_response.py
|
||||
|
||||
@@ -85,6 +85,7 @@ from hindsight_client_api.models.memory_item import MemoryItem
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
|
||||
from hindsight_client_api.models.observation_scopes import ObservationScopes
|
||||
from hindsight_client_api.models.operation_response import OperationResponse
|
||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
||||
|
||||
@@ -60,6 +60,7 @@ from hindsight_client_api.models.memory_item import MemoryItem
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
|
||||
from hindsight_client_api.models.observation_scopes import ObservationScopes
|
||||
from hindsight_client_api.models.operation_response import OperationResponse
|
||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
||||
|
||||
@@ -21,6 +21,7 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.entity_input import EntityInput
|
||||
from hindsight_client_api.models.observation_scopes import ObservationScopes
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -35,7 +36,8 @@ class MemoryItem(BaseModel):
|
||||
document_id: Optional[StrictStr] = None
|
||||
entities: Optional[List[EntityInput]] = None
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags"]
|
||||
observation_scopes: Optional[ObservationScopes] = None
|
||||
__properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id", "entities", "tags", "observation_scopes"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -83,6 +85,9 @@ class MemoryItem(BaseModel):
|
||||
if _item_entities:
|
||||
_items.append(_item_entities.to_dict())
|
||||
_dict['entities'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of observation_scopes
|
||||
if self.observation_scopes:
|
||||
_dict['observation_scopes'] = self.observation_scopes.to_dict()
|
||||
# set to None if timestamp (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.timestamp is None and "timestamp" in self.model_fields_set:
|
||||
@@ -113,6 +118,11 @@ class MemoryItem(BaseModel):
|
||||
if self.tags is None and "tags" in self.model_fields_set:
|
||||
_dict['tags'] = None
|
||||
|
||||
# set to None if observation_scopes (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.observation_scopes is None and "observation_scopes" in self.model_fields_set:
|
||||
_dict['observation_scopes'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -131,7 +141,8 @@ class MemoryItem(BaseModel):
|
||||
"metadata": obj.get("metadata"),
|
||||
"document_id": obj.get("document_id"),
|
||||
"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
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.14
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
from inspect import getfullargspec
|
||||
import json
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator
|
||||
from typing import List, Optional
|
||||
from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict
|
||||
from typing_extensions import Literal, Self
|
||||
from pydantic import Field
|
||||
|
||||
OBSERVATIONSCOPES_ANY_OF_SCHEMAS = ["List[List[str]]", "str"]
|
||||
|
||||
class ObservationScopes(BaseModel):
|
||||
"""
|
||||
How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
|
||||
"""
|
||||
|
||||
# data type: str
|
||||
anyof_schema_1_validator: Optional[StrictStr] = None
|
||||
# data type: List[List[str]]
|
||||
anyof_schema_2_validator: Optional[List[List[StrictStr]]] = None
|
||||
if TYPE_CHECKING:
|
||||
actual_instance: Optional[Union[List[List[str]], str]] = None
|
||||
else:
|
||||
actual_instance: Any = None
|
||||
any_of_schemas: Set[str] = { "List[List[str]]", "str" }
|
||||
|
||||
model_config = {
|
||||
"validate_assignment": True,
|
||||
"protected_namespaces": (),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
if args:
|
||||
if len(args) > 1:
|
||||
raise ValueError("If a position argument is used, only 1 is allowed to set `actual_instance`")
|
||||
if kwargs:
|
||||
raise ValueError("If a position argument is used, keyword arguments cannot be used.")
|
||||
super().__init__(actual_instance=args[0])
|
||||
else:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@field_validator('actual_instance')
|
||||
def actual_instance_must_validate_anyof(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
instance = ObservationScopes.model_construct()
|
||||
error_messages = []
|
||||
# validate data type: str
|
||||
try:
|
||||
instance.anyof_schema_1_validator = v
|
||||
return v
|
||||
except (ValidationError, ValueError) as e:
|
||||
error_messages.append(str(e))
|
||||
# validate data type: List[List[str]]
|
||||
try:
|
||||
instance.anyof_schema_2_validator = v
|
||||
return v
|
||||
except (ValidationError, ValueError) as e:
|
||||
error_messages.append(str(e))
|
||||
if error_messages:
|
||||
# no match
|
||||
raise ValueError("No match found when setting the actual_instance in ObservationScopes with anyOf schemas: List[List[str]], str. Details: " + ", ".join(error_messages))
|
||||
else:
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Dict[str, Any]) -> Self:
|
||||
return cls.from_json(json.dumps(obj))
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Self:
|
||||
"""Returns the object represented by the json string"""
|
||||
instance = cls.model_construct()
|
||||
if json_str is None:
|
||||
return instance
|
||||
|
||||
error_messages = []
|
||||
# deserialize data into str
|
||||
try:
|
||||
# validation
|
||||
instance.anyof_schema_1_validator = json.loads(json_str)
|
||||
# assign value to actual_instance
|
||||
instance.actual_instance = instance.anyof_schema_1_validator
|
||||
return instance
|
||||
except (ValidationError, ValueError) as e:
|
||||
error_messages.append(str(e))
|
||||
# deserialize data into List[List[str]]
|
||||
try:
|
||||
# validation
|
||||
instance.anyof_schema_2_validator = json.loads(json_str)
|
||||
# assign value to actual_instance
|
||||
instance.actual_instance = instance.anyof_schema_2_validator
|
||||
return instance
|
||||
except (ValidationError, ValueError) as e:
|
||||
error_messages.append(str(e))
|
||||
|
||||
if error_messages:
|
||||
# no match
|
||||
raise ValueError("No match found when deserializing the JSON string into ObservationScopes with anyOf schemas: List[List[str]], str. Details: " + ", ".join(error_messages))
|
||||
else:
|
||||
return instance
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the actual instance"""
|
||||
if self.actual_instance is None:
|
||||
return "null"
|
||||
|
||||
if hasattr(self.actual_instance, "to_json") and callable(self.actual_instance.to_json):
|
||||
return self.actual_instance.to_json()
|
||||
else:
|
||||
return json.dumps(self.actual_instance)
|
||||
|
||||
def to_dict(self) -> Optional[Union[Dict[str, Any], List[List[str]], str]]:
|
||||
"""Returns the dict representation of the actual instance"""
|
||||
if self.actual_instance is None:
|
||||
return None
|
||||
|
||||
if hasattr(self.actual_instance, "to_dict") and callable(self.actual_instance.to_dict):
|
||||
return self.actual_instance.to_dict()
|
||||
else:
|
||||
return self.actual_instance
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the actual instance"""
|
||||
return pprint.pformat(self.model_dump())
|
||||
|
||||
|
||||
@@ -47,43 +47,42 @@ fn convert_anyof_to_nullable(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(obj) => {
|
||||
// Check if this object has anyOf with null and process it
|
||||
let should_convert = obj.get("anyOf")
|
||||
let has_null_in_anyof = obj.get("anyOf")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|array| {
|
||||
if array.len() == 2 {
|
||||
let has_null = array.iter().any(|v| {
|
||||
v.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s == "null")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
has_null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
array.iter().any(|v| {
|
||||
v.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s == "null")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if should_convert {
|
||||
if has_null_in_anyof {
|
||||
// Clone the anyOf array to avoid borrow issues
|
||||
if let Some(any_of) = obj.get("anyOf").cloned() {
|
||||
if let Some(array) = any_of.as_array() {
|
||||
// Find the non-null schema
|
||||
if let Some(non_null_schema) = array.iter().find(|v| {
|
||||
let non_null_schemas: Vec<_> = array.iter().filter(|v| {
|
||||
v.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s != "null")
|
||||
.unwrap_or(true)
|
||||
}).cloned() {
|
||||
// Replace anyOf with the non-null schema + nullable: true
|
||||
obj.remove("anyOf");
|
||||
if let Some(non_null_obj) = non_null_schema.as_object() {
|
||||
}).cloned().collect();
|
||||
|
||||
obj.remove("anyOf");
|
||||
if non_null_schemas.len() == 1 {
|
||||
// Single non-null type: inline it with nullable: true
|
||||
if let Some(non_null_obj) = non_null_schemas[0].as_object() {
|
||||
for (k, v) in non_null_obj.iter() {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
obj.insert("nullable".to_string(), serde_json::json!(true));
|
||||
} else {
|
||||
// Multiple non-null types: keep anyOf with nulls removed
|
||||
obj.insert("anyOf".to_string(), serde_json::json!(non_null_schemas));
|
||||
}
|
||||
obj.insert("nullable".to_string(), serde_json::json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ mod tests {
|
||||
timestamp: None,
|
||||
entities: None,
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
},
|
||||
types::MemoryItem {
|
||||
content: "Bob works with Alice on the search team".to_string(),
|
||||
@@ -80,6 +81,7 @@ mod tests {
|
||||
timestamp: None,
|
||||
entities: None,
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
},
|
||||
],
|
||||
document_tags: None,
|
||||
|
||||
@@ -1195,6 +1195,17 @@ export type MemoryItem = {
|
||||
* Optional tags for visibility scoping. Memories with tags can be filtered during recall.
|
||||
*/
|
||||
tags?: Array<string> | null;
|
||||
/**
|
||||
* ObservationScopes
|
||||
*
|
||||
* How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
|
||||
*/
|
||||
observation_scopes?:
|
||||
| "per_tag"
|
||||
| "combined"
|
||||
| "all_combinations"
|
||||
| Array<Array<string>>
|
||||
| null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface MemoryItemInput {
|
||||
document_id?: string;
|
||||
entities?: EntityInput[];
|
||||
tags?: string[];
|
||||
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
|
||||
}
|
||||
|
||||
export class HindsightClient {
|
||||
@@ -188,6 +189,7 @@ export class HindsightClient {
|
||||
document_id: item.document_id,
|
||||
entities: item.entities,
|
||||
tags: item.tags,
|
||||
observation_scopes: item.observation_scopes,
|
||||
timestamp:
|
||||
item.timestamp instanceof Date
|
||||
? item.timestamp.toISOString()
|
||||
|
||||
@@ -10,9 +10,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items, document_id, document_tags } = body;
|
||||
const { items, document_id, document_tags, observation_scopes } = body;
|
||||
|
||||
const response = await hindsightClient.retainBatch(bankId, items, {
|
||||
// Map observation_scopes into each item if provided at request level
|
||||
const mappedItems = observation_scopes
|
||||
? items?.map((item: any) => ({
|
||||
...item,
|
||||
observation_scopes: item.observation_scopes ?? observation_scopes,
|
||||
}))
|
||||
: items;
|
||||
|
||||
const response = await hindsightClient.retainBatch(bankId, mappedItems, {
|
||||
documentId: document_id,
|
||||
documentTags: document_tags,
|
||||
});
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { client } from "@/lib/api";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AddMemoryView() {
|
||||
const { currentBank } = useBank();
|
||||
const [content, setContent] = useState("");
|
||||
const [context, setContext] = useState("");
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [documentId, setDocumentId] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [async, setAsync] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const clearForm = () => {
|
||||
setContent("");
|
||||
setContext("");
|
||||
setEventDate("");
|
||||
setDocumentId("");
|
||||
setTags("");
|
||||
setAsync(false);
|
||||
};
|
||||
|
||||
const submitMemory = async () => {
|
||||
if (!currentBank || !content) {
|
||||
toast.error("Validation error", {
|
||||
description: "Please enter content",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Parse tags from comma-separated string
|
||||
const parsedTags = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
const item: any = { content };
|
||||
if (context) item.context = context;
|
||||
// datetime-local gives "2024-01-15T10:30", add seconds for proper ISO format
|
||||
if (eventDate) item.timestamp = eventDate + ":00";
|
||||
if (parsedTags.length > 0) item.tags = parsedTags;
|
||||
|
||||
const data: any = await client.retain({
|
||||
bank_id: currentBank,
|
||||
items: [item],
|
||||
document_id: documentId,
|
||||
async,
|
||||
...(parsedTags.length > 0 && { document_tags: parsedTags }),
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
toast.success("Memory retained", {
|
||||
description: data.message || "Memory has been successfully added to the bank",
|
||||
});
|
||||
|
||||
// Clear form on success
|
||||
setContent("");
|
||||
setContext("");
|
||||
setTags("");
|
||||
} catch (error) {
|
||||
// Error toast is shown automatically by the API client interceptor
|
||||
// No need to handle it here!
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Retain memories to the selected memory bank. You can add one or multiple memories at once.
|
||||
</p>
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<div className="bg-card p-5 rounded-lg mb-5 border-2 border-primary">
|
||||
<h3 className="mt-0 text-card-foreground">Memory Entry</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="font-bold block mb-1 text-card-foreground">Content *</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Enter the memory content..."
|
||||
className="min-h-[100px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="font-bold block mb-1 text-card-foreground">Context</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder="Optional context about this memory..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="font-bold block mb-1 text-card-foreground">Event Date</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="font-bold block mb-1 text-card-foreground">Document ID</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={documentId}
|
||||
onChange={(e) => setDocumentId(e.target.value)}
|
||||
placeholder="Optional document identifier (automatically upserts if document exists)..."
|
||||
/>
|
||||
<small className="text-muted-foreground text-xs mt-1 block">
|
||||
Note: If a document with this ID already exists, it will be automatically replaced
|
||||
with the new content.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="font-bold block mb-1 text-card-foreground flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Tags
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="user_alice, session_123, project_x"
|
||||
/>
|
||||
<small className="text-muted-foreground text-xs mt-1 block">
|
||||
Comma-separated tags for filtering during recall/reflect. Tags cannot contain commas.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="async"
|
||||
checked={async}
|
||||
onCheckedChange={(checked) => setAsync(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="async" className="font-bold text-card-foreground cursor-pointer">
|
||||
Async (process in background)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<Button onClick={submitMemory} disabled={loading}>
|
||||
{loading ? "Retaining..." : "Retain Memory"}
|
||||
</Button>
|
||||
<Button onClick={clearForm} variant="secondary">
|
||||
Clear Form
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<div className="text-5xl mb-2.5">⏳</div>
|
||||
<div className="text-lg">Retaining memory...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,16 +31,24 @@ import {
|
||||
Moon,
|
||||
Sun,
|
||||
Github,
|
||||
Tag,
|
||||
Upload,
|
||||
X,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/lib/theme-context";
|
||||
import Image from "next/image";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BankSelectorInner() {
|
||||
@@ -62,11 +70,30 @@ function BankSelectorInner() {
|
||||
const [docEventDate, setDocEventDate] = React.useState("");
|
||||
const [docDocumentId, setDocDocumentId] = React.useState("");
|
||||
const [docTags, setDocTags] = React.useState("");
|
||||
const [docObservationScopes, setDocObservationScopes] = React.useState<
|
||||
"per_tag" | "combined" | "all_combinations" | "custom"
|
||||
>("combined");
|
||||
const [docObservationScopesCustom, setDocObservationScopesCustom] = React.useState("");
|
||||
const [docMetadata, setDocMetadata] = React.useState("");
|
||||
const [docEntities, setDocEntities] = React.useState("");
|
||||
const [docAdvancedTab, setDocAdvancedTab] = React.useState<"document" | "tags" | "source">(
|
||||
"document"
|
||||
);
|
||||
const [docAsync, setDocAsync] = React.useState(false);
|
||||
const [isCreatingDoc, setIsCreatingDoc] = React.useState(false);
|
||||
|
||||
// File upload state
|
||||
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
|
||||
const [filesMetadata, setFilesMetadata] = React.useState<
|
||||
{
|
||||
context: string;
|
||||
timestamp: string;
|
||||
document_id: string;
|
||||
tags: string;
|
||||
metadata: string;
|
||||
expanded: boolean;
|
||||
}[]
|
||||
>([]);
|
||||
const [uploadProgress, setUploadProgress] = React.useState<string>("");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -110,10 +137,71 @@ function BankSelectorInner() {
|
||||
}
|
||||
};
|
||||
|
||||
const parseMetadata = (s: string): Record<string, string> | undefined => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of s.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
const val = line.slice(idx + 1).trim();
|
||||
if (key) result[key] = val;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const parseEntities = (s: string) => {
|
||||
const items = s
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (items.length === 0) return undefined;
|
||||
return items.map((t) => ({ text: t }));
|
||||
};
|
||||
|
||||
const scopeLabel = (tags: string[]) => tags.join(", ");
|
||||
|
||||
const scopeQuestion = (tags: string[]): string => {
|
||||
if (tags.length === 1) return `What happened with ${tags[0]}?`;
|
||||
const allButLast = tags.slice(0, -1).join(", ");
|
||||
return `What happened with ${allButLast} and ${tags[tags.length - 1]}?`;
|
||||
};
|
||||
|
||||
const computeScopes = (
|
||||
tags: string[],
|
||||
mode: "per_tag" | "combined" | "all_combinations"
|
||||
): string[][] => {
|
||||
if (tags.length === 0) return [];
|
||||
if (mode === "per_tag") return tags.map((t) => [t]);
|
||||
if (mode === "combined") return [tags];
|
||||
// all_combinations: every non-empty subset
|
||||
const result: string[][] = [];
|
||||
for (let size = 1; size <= tags.length; size++) {
|
||||
const combine = (start: number, combo: string[]) => {
|
||||
if (combo.length === size) {
|
||||
result.push([...combo]);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < tags.length; i++) combine(i + 1, [...combo, tags[i]]);
|
||||
};
|
||||
combine(0, []);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const emptyFileMeta = () => ({
|
||||
context: "",
|
||||
timestamp: "",
|
||||
document_id: "",
|
||||
tags: "",
|
||||
metadata: "",
|
||||
expanded: false,
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
setSelectedFiles((prev) => [...prev, ...files]);
|
||||
// Reset input to allow selecting same file again
|
||||
setFilesMetadata((prev) => [...prev, ...files.map(emptyFileMeta)]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
@@ -121,6 +209,21 @@ function BankSelectorInner() {
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setFilesMetadata((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateFileMeta = (
|
||||
index: number,
|
||||
field: "context" | "timestamp" | "document_id" | "tags" | "metadata",
|
||||
value: string
|
||||
) => {
|
||||
setFilesMetadata((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m)));
|
||||
};
|
||||
|
||||
const toggleFileExpanded = (index: number) => {
|
||||
setFilesMetadata((prev) =>
|
||||
prev.map((m, i) => (i === index ? { ...m, expanded: !m.expanded } : m))
|
||||
);
|
||||
};
|
||||
|
||||
const handleUploadFiles = async () => {
|
||||
@@ -130,31 +233,39 @@ function BankSelectorInner() {
|
||||
setUploadProgress("");
|
||||
|
||||
try {
|
||||
const parsedTags = docTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
setUploadProgress(`Uploading ${selectedFiles.length} file(s)...`);
|
||||
|
||||
// Use the new file upload API (always async, converter configured server-side)
|
||||
const perFileMeta = filesMetadata.map((meta) => ({
|
||||
...(meta.context && { context: meta.context }),
|
||||
...(meta.timestamp && { timestamp: meta.timestamp + ":00" }),
|
||||
...(meta.document_id && { document_id: meta.document_id }),
|
||||
...(meta.tags && {
|
||||
tags: meta.tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
...(meta.metadata && { metadata: parseMetadata(meta.metadata) }),
|
||||
}));
|
||||
|
||||
await client.uploadFiles({
|
||||
bank_id: currentBank,
|
||||
files: selectedFiles,
|
||||
document_tags: parsedTags.length > 0 ? parsedTags : undefined,
|
||||
async: true,
|
||||
files_metadata: perFileMeta,
|
||||
});
|
||||
|
||||
// Reset form and close dialog
|
||||
setDocDialogOpen(false);
|
||||
setSelectedFiles([]);
|
||||
setFilesMetadata([]);
|
||||
setDocTags("");
|
||||
setDocAsync(false);
|
||||
setUploadProgress("");
|
||||
|
||||
// Navigate to documents view
|
||||
router.push(`/banks/${currentBank}?view=documents`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error toast is shown automatically by the API client interceptor
|
||||
} finally {
|
||||
setIsCreatingDoc(false);
|
||||
@@ -168,31 +279,53 @@ function BankSelectorInner() {
|
||||
setIsCreatingDoc(true);
|
||||
|
||||
try {
|
||||
// Parse tags from comma-separated string
|
||||
const parsedTags = docTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
.filter(Boolean);
|
||||
|
||||
const item: any = { content: docContent };
|
||||
const item: {
|
||||
content: string;
|
||||
context?: string;
|
||||
timestamp?: string;
|
||||
document_id?: string;
|
||||
tags?: string[];
|
||||
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
|
||||
metadata?: Record<string, string>;
|
||||
entities?: Array<{ text: string }>;
|
||||
} = { content: docContent };
|
||||
if (docContext) item.context = docContext;
|
||||
// datetime-local gives "2024-01-15T10:30", add seconds for proper ISO format
|
||||
if (docEventDate) item.timestamp = docEventDate + ":00";
|
||||
if (docDocumentId) item.document_id = docDocumentId;
|
||||
if (parsedTags.length > 0) item.tags = parsedTags;
|
||||
if (docObservationScopes === "per_tag") {
|
||||
item.observation_scopes = "per_tag";
|
||||
} else if (docObservationScopes === "combined") {
|
||||
item.observation_scopes = "combined";
|
||||
} else if (docObservationScopes === "all_combinations") {
|
||||
item.observation_scopes = "all_combinations";
|
||||
} else if (docObservationScopes === "custom") {
|
||||
const customScopes = docObservationScopesCustom
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
.filter((scope) => scope.length > 0);
|
||||
if (customScopes.length > 0) item.observation_scopes = customScopes;
|
||||
}
|
||||
const parsedMeta = parseMetadata(docMetadata);
|
||||
if (parsedMeta) item.metadata = parsedMeta;
|
||||
const parsedEntities = parseEntities(docEntities);
|
||||
if (parsedEntities) item.entities = parsedEntities;
|
||||
|
||||
const params: any = {
|
||||
await client.retain({
|
||||
bank_id: currentBank,
|
||||
items: [item],
|
||||
};
|
||||
|
||||
if (docDocumentId) params.document_id = docDocumentId;
|
||||
if (parsedTags.length > 0) params.document_tags = parsedTags;
|
||||
|
||||
if (docAsync) {
|
||||
await client.retain({ ...params, async: true });
|
||||
} else {
|
||||
await client.retain(params);
|
||||
}
|
||||
async: docAsync,
|
||||
});
|
||||
|
||||
// Reset form and close dialog
|
||||
setDocDialogOpen(false);
|
||||
@@ -201,11 +334,16 @@ function BankSelectorInner() {
|
||||
setDocEventDate("");
|
||||
setDocDocumentId("");
|
||||
setDocTags("");
|
||||
setDocObservationScopes("combined");
|
||||
setDocObservationScopesCustom("");
|
||||
setDocMetadata("");
|
||||
setDocEntities("");
|
||||
setDocAdvancedTab("document");
|
||||
setDocAsync(false);
|
||||
|
||||
// Navigate to documents view to see the new document
|
||||
router.push(`/banks/${currentBank}?view=documents`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error toast is shown automatically by the API client interceptor
|
||||
} finally {
|
||||
setIsCreatingDoc(false);
|
||||
@@ -375,7 +513,7 @@ function BankSelectorInner() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={docDialogOpen} onOpenChange={setDocDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogContent className="sm:max-w-[750px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Document</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -384,28 +522,29 @@ function BankSelectorInner() {
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={docTab} onValueChange={(v) => setDocTab(v as "text" | "upload")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="text" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Text
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="upload"
|
||||
className="flex items-center gap-2"
|
||||
disabled={fileUploadEnabled === false}
|
||||
>
|
||||
{fileUploadEnabled === false ? (
|
||||
<Lock className="h-4 w-4" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Upload Files
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="space-y-4 overflow-y-auto flex-1 px-1 -mx-1">
|
||||
{/* Content — tab-switched input only */}
|
||||
<Tabs value={docTab} onValueChange={(v) => setDocTab(v as "text" | "upload")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="text" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Text
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="upload"
|
||||
className="flex items-center gap-2"
|
||||
disabled={fileUploadEnabled === false}
|
||||
>
|
||||
{fileUploadEnabled === false ? (
|
||||
<Lock className="h-4 w-4" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Upload Files
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="text" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<TabsContent value="text" className="mt-3">
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">Content *</label>
|
||||
<Textarea
|
||||
value={docContent}
|
||||
@@ -414,92 +553,27 @@ function BankSelectorInner() {
|
||||
className="min-h-[150px] resize-y"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">Context</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docContext}
|
||||
onChange={(e) => setDocContext(e.target.value)}
|
||||
placeholder="Optional context about this document..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Event Date
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={docEventDate}
|
||||
onChange={(e) => setDocEventDate(e.target.value)}
|
||||
className="text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Document ID
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docDocumentId}
|
||||
onChange={(e) => setDocDocumentId(e.target.value)}
|
||||
placeholder="Optional document identifier..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Tags
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docTags}
|
||||
onChange={(e) => setDocTags(e.target.value)}
|
||||
placeholder="user_alice, session_123, project_x"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated tags for filtering during recall/reflect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="async-doc"
|
||||
checked={docAsync}
|
||||
onCheckedChange={(checked) => setDocAsync(checked as boolean)}
|
||||
/>
|
||||
<label htmlFor="async-doc" className="text-sm cursor-pointer text-foreground">
|
||||
Process in background (async)
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4 mt-4">
|
||||
{fileUploadEnabled === false ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center space-y-3">
|
||||
<Lock className="h-12 w-12 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">File Upload API Disabled</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
File upload is not enabled on this server.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
To enable, set{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
HINDSIGHT_API_ENABLE_FILE_UPLOAD_API=true
|
||||
</code>
|
||||
</p>
|
||||
<TabsContent value="upload" className="mt-3">
|
||||
{fileUploadEnabled === false ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center space-y-3">
|
||||
<Lock className="h-12 w-12 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">File Upload API Disabled</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
File upload is not enabled on this server.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
To enable, set{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
HINDSIGHT_API_ENABLE_FILE_UPLOAD_API=true
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -517,63 +591,340 @@ function BankSelectorInner() {
|
||||
Click to select files or drag and drop
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="font-bold block text-sm text-foreground">
|
||||
Selected Files ({selectedFiles.length})
|
||||
</label>
|
||||
<div className="max-h-[150px] overflow-y-auto space-y-1">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center justify-between p-2 bg-muted rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeFile(index)}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{selectedFiles.map((file, index) => {
|
||||
const meta = filesMetadata[index];
|
||||
const hasData =
|
||||
meta &&
|
||||
(meta.context ||
|
||||
meta.timestamp ||
|
||||
meta.document_id ||
|
||||
meta.tags ||
|
||||
meta.metadata);
|
||||
return (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="bg-muted rounded-md overflow-hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{/* File row header */}
|
||||
<div className="flex items-center gap-1 px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 min-w-0 flex-1 text-left hover:opacity-75 transition-opacity"
|
||||
onClick={() => toggleFileExpanded(index)}
|
||||
title="Edit metadata for this file"
|
||||
>
|
||||
{meta?.expanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<FileText
|
||||
className={`h-4 w-4 shrink-0 ${hasData ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Per-file metadata form */}
|
||||
{meta?.expanded && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-border/50">
|
||||
<div className="mt-2">
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Context
|
||||
</label>
|
||||
<Input
|
||||
value={meta.context}
|
||||
onChange={(e) =>
|
||||
updateFileMeta(index, "context", e.target.value)
|
||||
}
|
||||
placeholder="Optional context..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Event Date
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={meta.timestamp}
|
||||
onChange={(e) =>
|
||||
updateFileMeta(index, "timestamp", e.target.value)
|
||||
}
|
||||
className="h-8 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Document ID
|
||||
</label>
|
||||
<Input
|
||||
value={meta.document_id}
|
||||
onChange={(e) =>
|
||||
updateFileMeta(index, "document_id", e.target.value)
|
||||
}
|
||||
placeholder="Optional ID..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Tags
|
||||
</label>
|
||||
<Input
|
||||
value={meta.tags}
|
||||
onChange={(e) =>
|
||||
updateFileMeta(index, "tags", e.target.value)
|
||||
}
|
||||
placeholder="tag1, tag2..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Metadata
|
||||
</label>
|
||||
<Textarea
|
||||
value={meta.metadata}
|
||||
onChange={(e) =>
|
||||
updateFileMeta(index, "metadata", e.target.value)
|
||||
}
|
||||
placeholder={"source: slack\nchannel: engineering"}
|
||||
className="min-h-[52px] resize-y font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
Tags (applied to all files)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docTags}
|
||||
onChange={(e) => setDocTags(e.target.value)}
|
||||
placeholder="user_alice, session_123, project_x"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated tags for filtering during recall/reflect
|
||||
</p>
|
||||
{uploadProgress && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{uploadProgress}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Context — text tab only */}
|
||||
{docTab === "text" && (
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">Context</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docContext}
|
||||
onChange={(e) => setDocContext(e.target.value)}
|
||||
placeholder="Optional context about this document..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced section — text only */}
|
||||
{docTab === "text" && (
|
||||
<div>
|
||||
<Tabs
|
||||
value={docAdvancedTab}
|
||||
onValueChange={(v) => setDocAdvancedTab(v as "document" | "tags" | "source")}
|
||||
>
|
||||
<TabsList className="w-full border-b border-border bg-transparent h-8 p-0 gap-0 justify-start rounded-none">
|
||||
<TabsTrigger
|
||||
value="document"
|
||||
className="rounded-none h-full px-4 text-xs font-medium bg-transparent shadow-none text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-primary -mb-px"
|
||||
>
|
||||
Document
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tags"
|
||||
className="rounded-none h-full px-4 text-xs font-medium bg-transparent shadow-none text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-primary -mb-px"
|
||||
>
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="source"
|
||||
className="rounded-none h-full px-4 text-xs font-medium bg-transparent shadow-none text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent data-[state=active]:border-b-2 data-[state=active]:border-primary -mb-px"
|
||||
>
|
||||
Source
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="pt-3 space-y-3">
|
||||
<TabsContent value="document" className="mt-0 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Event Date
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={docEventDate}
|
||||
onChange={(e) => setDocEventDate(e.target.value)}
|
||||
className="text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Document ID
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docDocumentId}
|
||||
onChange={(e) => setDocDocumentId(e.target.value)}
|
||||
placeholder="Optional document identifier..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="async-doc"
|
||||
checked={docAsync}
|
||||
onCheckedChange={(checked) => setDocAsync(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="async-doc"
|
||||
className="text-sm cursor-pointer text-foreground"
|
||||
>
|
||||
Process in background (async)
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="mt-0 space-y-3">
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Tags
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docTags}
|
||||
onChange={(e) => setDocTags(e.target.value)}
|
||||
placeholder="user_alice, session_123, project_x"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated — used to filter memories during recall/reflect
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Observation Scopes
|
||||
</label>
|
||||
<Select
|
||||
value={docObservationScopes}
|
||||
onValueChange={(v) =>
|
||||
setDocObservationScopes(
|
||||
v as "per_tag" | "combined" | "all_combinations" | "custom"
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="per_tag">Per tag</SelectItem>
|
||||
<SelectItem value="combined">Combined</SelectItem>
|
||||
<SelectItem value="all_combinations">All combinations</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{docObservationScopes !== "custom" &&
|
||||
(() => {
|
||||
const tags = docTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const scopes = computeScopes(tags, docObservationScopes);
|
||||
const MAX = 6;
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1.5 italic">
|
||||
Add tags above to preview observation scopes
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{scopes.slice(0, MAX).map((scope, i) => (
|
||||
<li key={i} className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono text-foreground">
|
||||
{scopeLabel(scope)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scopeQuestion(scope)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{scopes.length > MAX && (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
+{scopes.length - MAX} more scopes
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
{docObservationScopes === "custom" && (
|
||||
<Textarea
|
||||
value={docObservationScopesCustom}
|
||||
onChange={(e) => setDocObservationScopesCustom(e.target.value)}
|
||||
placeholder={"user:alice\nuser:alice, place:online"}
|
||||
className="min-h-[72px] resize-y font-mono text-sm mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="source" className="mt-0 space-y-3">
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Metadata
|
||||
</label>
|
||||
<Textarea
|
||||
value={docMetadata}
|
||||
onChange={(e) => setDocMetadata(e.target.value)}
|
||||
placeholder={"source: slack\nchannel: engineering"}
|
||||
className="min-h-[72px] resize-y font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
One <code className="bg-muted px-0.5 rounded">key: value</code> per line
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-bold block mb-1 text-sm text-foreground">
|
||||
Entities
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={docEntities}
|
||||
onChange={(e) => setDocEntities(e.target.value)}
|
||||
placeholder="Alice, Google, ML model"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Comma-separated hints merged with auto-extracted entities
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{uploadProgress && (
|
||||
<p className="text-sm text-muted-foreground">{uploadProgress}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -585,8 +936,14 @@ function BankSelectorInner() {
|
||||
setDocEventDate("");
|
||||
setDocDocumentId("");
|
||||
setDocTags("");
|
||||
setDocObservationScopes("combined");
|
||||
setDocObservationScopesCustom("");
|
||||
setDocMetadata("");
|
||||
setDocEntities("");
|
||||
setDocAdvancedTab("document");
|
||||
setDocAsync(false);
|
||||
setSelectedFiles([]);
|
||||
setFilesMetadata([]);
|
||||
setUploadProgress("");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -681,21 +681,22 @@ export function DataView({ factType }: DataViewProps) {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead
|
||||
className={factType === "observation" ? "w-[40%]" : "w-[45%]"}
|
||||
className={factType === "observation" ? "w-[35%]" : "w-[38%]"}
|
||||
>
|
||||
{factType === "observation" ? "Observation" : "Memory"}
|
||||
</TableHead>
|
||||
<TableHead className="w-[20%]">Entities</TableHead>
|
||||
<TableHead className="w-[15%]">Entities</TableHead>
|
||||
<TableHead className="w-[15%]">Tags</TableHead>
|
||||
{factType === "observation" && (
|
||||
<TableHead className="w-[10%]">Sources</TableHead>
|
||||
)}
|
||||
<TableHead
|
||||
className={factType === "observation" ? "w-[15%]" : "w-[17%]"}
|
||||
className={factType === "observation" ? "w-[12%]" : "w-[16%]"}
|
||||
>
|
||||
Occurred
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={factType === "observation" ? "w-[15%]" : "w-[18%]"}
|
||||
className={factType === "observation" ? "w-[13%]" : "w-[16%]"}
|
||||
>
|
||||
Mentioned
|
||||
</TableHead>
|
||||
@@ -758,6 +759,29 @@ export function DataView({ factType }: DataViewProps) {
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{row.tags && row.tags.length > 0 ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(row.tags as string[])
|
||||
.slice(0, 2)
|
||||
.map((tag: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-700 border border-amber-500/20 font-medium font-mono"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{row.tags.length > 2 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{row.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{factType === "observation" && (
|
||||
<TableCell className="text-xs py-2 text-foreground">
|
||||
{row.proof_count ?? 1}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from "lucide-react";
|
||||
import { Loader2, Calendar, Users, FileText, Layers, Tag } from "lucide-react";
|
||||
import { TagList } from "@/components/ui/tag-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -31,6 +31,7 @@ interface MemoryDetail {
|
||||
document_id: string | null;
|
||||
chunk_id: string | null;
|
||||
tags: string[];
|
||||
observation_scopes: string | string[][] | null;
|
||||
source_memories?: SourceMemory[];
|
||||
}
|
||||
|
||||
@@ -215,6 +216,27 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
{/* 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">
|
||||
@@ -393,6 +415,27 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* ID */}
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
|
||||
|
||||
@@ -165,15 +165,17 @@ export class ControlPlaneClient {
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
context?: string;
|
||||
metadata?: Record<string, string>;
|
||||
document_id?: string;
|
||||
metadata?: Record<string, string>;
|
||||
entities?: Array<{ text: string; type?: string }>;
|
||||
tags?: string[];
|
||||
observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
|
||||
}>;
|
||||
document_id?: string;
|
||||
async?: boolean;
|
||||
}) {
|
||||
const endpoint = params.async ? "/api/memories/retain_async" : "/api/memories/retain";
|
||||
return this.fetchApi(endpoint, {
|
||||
return this.fetchApi<{ message?: string }>(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
@@ -376,6 +378,7 @@ export class ControlPlaneClient {
|
||||
document_id: string | null;
|
||||
chunk_id: string | null;
|
||||
tags: string[];
|
||||
observation_scopes: string | string[][] | null;
|
||||
}>(`/api/memories/${memoryId}?bank_id=${bankId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,66 @@ Use consistent naming patterns to keep tag filtering predictable. Common convent
|
||||
|
||||
See [Recall API](./recall#tags) for filtering by tags during retrieval.
|
||||
|
||||
### observation_scopes
|
||||
|
||||
Controls which [observations](../observations) this memory contributes to during consolidation. Each scope runs an independent pass, creating or updating observations tagged with only that scope's tags.
|
||||
|
||||
:::info Scope isolation
|
||||
During consolidation, Hindsight uses `all_strict` matching to find existing observations to update — only observations whose tags exactly match the current scope are considered. This keeps scopes isolated: a memory consolidated under `["student:alice"]` will never bleed into an observation tagged `["student:alice", "teacher:bob"]`.
|
||||
:::
|
||||
|
||||
The examples below use a lesson transcript retained with `tags: ["student:alice", "teacher:bob", "session-id:s1"]`.
|
||||
|
||||
#### combined *(default)*
|
||||
|
||||
One consolidation pass using all tags together. The resulting observation is tagged with the full set.
|
||||
|
||||
- Observations created: `["student:alice", "teacher:bob", "session-id:s1"]`
|
||||
- ✗ *"What does Alice struggle with across all her sessions?"* — no match, because no observation was ever built for `student:alice` alone
|
||||
- ✗ *"How does Bob teach?"* — no match for `teacher:bob` alone
|
||||
- ✓ *"What happened in session s1 with Alice and Bob?"* — exact match
|
||||
|
||||
**Use when** the memory is meaningful only as a whole and you never need to query any single tag in isolation.
|
||||
|
||||
#### per_tag
|
||||
|
||||
One consolidation pass per individual tag. Each tag gets its own isolated observation that grows with every new memory sharing that tag.
|
||||
|
||||
- Observations created: `["student:alice"]` · `["teacher:bob"]` · `["session-id:s1"]`
|
||||
- ✓ *"What does Alice struggle with across all her sessions?"*
|
||||
- ✓ *"How does Bob teach?"*
|
||||
- ✓ *"What happened in session s1?"*
|
||||
- ✗ *"How does Alice perform specifically with Bob?"* — no observation for the `["student:alice", "teacher:bob"]` combination
|
||||
- ✗ *"How does Bob teach in online sessions?"* — no observation for `["teacher:bob", "session-id:s1"]`
|
||||
|
||||
**Use when** content involves multiple tags that each represent an independent subject — the most common choice for multi-party content like conversations, lessons, or support sessions.
|
||||
|
||||
#### all_combinations
|
||||
|
||||
One pass per subset of tags — singles, pairs, triples, and so on. For 3 tags that is 7 passes.
|
||||
|
||||
- Observations created: all `"per_tag"` scopes above, plus `["student:alice", "teacher:bob"]` · `["student:alice", "session-id:s1"]` · `["teacher:bob", "session-id:s1"]` · `["student:alice", "teacher:bob", "session-id:s1"]`
|
||||
- ✓ All questions from `"per_tag"` above
|
||||
- ✓ *"How does Alice perform specifically with Bob?"* — matched by `["student:alice", "teacher:bob"]`
|
||||
|
||||
**Use when** you need observations at every granularity — per tag, per pair, per group.
|
||||
|
||||
#### custom
|
||||
|
||||
Pass an explicit list of tag sets. Each inner list is one scope.
|
||||
|
||||
```json
|
||||
[["student:alice"], ["teacher:bob"], ["teacher:bob", "session-id:s1"]]
|
||||
```
|
||||
|
||||
- Observations created: exactly those three scopes — nothing more
|
||||
- ✓ *"What does Alice struggle with?"*
|
||||
- ✓ *"How does Bob teach?"*
|
||||
- ✓ *"How does Bob teach in session s1 specifically?"*
|
||||
- ✗ *"What happened in session s1 regardless of teacher?"* — `["session-id:s1"]` alone was not included
|
||||
|
||||
**Use when** you know exactly which combinations are meaningful and want to avoid unnecessary passes.
|
||||
|
||||
### Response
|
||||
|
||||
The synchronous retain response includes:
|
||||
|
||||
@@ -131,6 +131,14 @@ This ensures responses stay accurate even as the underlying data changes.
|
||||
|
||||
---
|
||||
|
||||
## Observation Scopes
|
||||
|
||||
By default, observations are scoped to all of a memory's tags combined. The `observation_scopes` retain parameter lets you control this — building separate observations per tag, per combination, or with a custom list of scopes. This is key when a single memory carries multiple tags and you want each tag to accumulate its own observations independently.
|
||||
|
||||
See [`observation_scopes` in the Retain API](./api/retain#observation_scopes) for the full explanation and options.
|
||||
|
||||
---
|
||||
|
||||
## Observations Mission
|
||||
|
||||
You can define exactly what this bank should synthesise by setting an **observations mission** (`observations_mission`). This replaces the built-in durable-knowledge rules with your own instructions, letting you control what shape observations take.
|
||||
|
||||
@@ -5309,6 +5309,32 @@
|
||||
],
|
||||
"title": "Tags",
|
||||
"description": "Optional tags for visibility scoping. Memories with tags can be filtered during recall."
|
||||
},
|
||||
"observation_scopes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"per_tag",
|
||||
"combined",
|
||||
"all_combinations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "ObservationScopes",
|
||||
"description": "How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
+12
-16
@@ -106,8 +106,10 @@ echo "Generating new client with openapi-generator..."
|
||||
cd "$PYTHON_CLIENT_DIR"
|
||||
|
||||
# Run openapi-generator via Docker (pinned version for reproducibility)
|
||||
# Use --platform linux/amd64 to ensure identical output on both macOS (arm64) and Linux CI (amd64)
|
||||
# Use --user to match current user's UID/GID so generated files are writable
|
||||
docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$OPENAPI_SPEC:/local/openapi.json" \
|
||||
-v "$PYTHON_CLIENT_DIR:/local/out" \
|
||||
@@ -344,21 +346,10 @@ GO_CLIENT_DIR="$CLIENTS_DIR/go"
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "⚠ Go not found, skipping Go client generation"
|
||||
echo " Install Go 1.25+ from https://go.dev/dl/"
|
||||
elif ! command -v java &> /dev/null; then
|
||||
echo "⚠ Java not found, skipping Go client generation"
|
||||
echo " Install Java 11+ from https://adoptium.net/"
|
||||
else
|
||||
echo "Regenerating Go client (via OpenAPI Generator)..."
|
||||
echo "Regenerating Go client (via OpenAPI Generator Docker)..."
|
||||
cd "$GO_CLIENT_DIR"
|
||||
|
||||
# Download OpenAPI Generator if not present
|
||||
OPENAPI_GEN_VERSION="7.10.0"
|
||||
OPENAPI_GEN_JAR="openapi-generator-cli.jar"
|
||||
if [ ! -f "$OPENAPI_GEN_JAR" ]; then
|
||||
echo "Downloading OpenAPI Generator ${OPENAPI_GEN_VERSION}..."
|
||||
curl -L "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${OPENAPI_GEN_VERSION}/openapi-generator-cli-${OPENAPI_GEN_VERSION}.jar" -o "$OPENAPI_GEN_JAR"
|
||||
fi
|
||||
|
||||
# Save maintained files to temp
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
echo "Preserving maintained files..."
|
||||
@@ -374,12 +365,17 @@ else
|
||||
rm -rf docs/ .openapi-generator/
|
||||
rm -f go.mod go.sum
|
||||
|
||||
# Generate new client
|
||||
# Generate new client via Docker (--platform linux/amd64 ensures identical output on macOS and Linux CI)
|
||||
echo "Generating client from OpenAPI spec..."
|
||||
java -jar "$OPENAPI_GEN_JAR" generate \
|
||||
-i "$OPENAPI_SPEC" \
|
||||
docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$OPENAPI_SPEC:/local/openapi.json" \
|
||||
-v "$GO_CLIENT_DIR:/local/out" \
|
||||
"openapitools/openapi-generator-cli:${OPENAPI_GENERATOR_VERSION}" generate \
|
||||
-i /local/openapi.json \
|
||||
-g go \
|
||||
-o . \
|
||||
-o /local/out \
|
||||
--package-name hindsight \
|
||||
--git-user-id vectorize-io \
|
||||
--git-repo-id hindsight/hindsight-clients/go \
|
||||
|
||||
Reference in New Issue
Block a user