Compare commits

...
24 changed files with 709 additions and 16 deletions
+36 -5
View File
@@ -74,7 +74,7 @@ from hindsight_api.config import get_config
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
@@ -97,6 +97,12 @@ class ChunkIncludeOptions(BaseModel):
max_tokens: int = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)")
class SourceFactsIncludeOptions(BaseModel):
"""Options for including source facts for observation-type results."""
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
class IncludeOptions(BaseModel):
"""Options for including additional data in recall results."""
@@ -107,6 +113,10 @@ class IncludeOptions(BaseModel):
chunks: ChunkIncludeOptions | None = Field(
default=None, description="Include raw chunks. Set to {} to enable, null to disable (default: disabled)."
)
source_facts: SourceFactsIncludeOptions | None = Field(
default=None,
description="Include source facts for observation-type results. Set to {} to enable, null to disable (default: disabled).",
)
class RecallRequest(BaseModel):
@@ -189,6 +199,9 @@ class RecallResult(BaseModel):
metadata: dict[str, str] | None = None # User-defined metadata
chunk_id: str | None = None # Chunk this fact was extracted from
tags: list[str] | None = None # Visibility scope tags
source_fact_ids: list[str] | None = (
None # IDs of source facts (observation type only, when source_facts is enabled)
)
class EntityObservationResponse(BaseModel):
@@ -340,6 +353,9 @@ class RecallResponse(BaseModel):
default=None, description="Entity states for entities mentioned in results"
)
chunks: dict[str, ChunkData] | None = Field(default=None, description="Chunks for facts, keyed by chunk_id")
source_facts: dict[str, RecallResult] | None = Field(
default=None, description="Source facts for observation-type results, keyed by fact ID"
)
class EntityInput(BaseModel):
@@ -1959,6 +1975,10 @@ def _register_routes(app: FastAPI):
include_chunks = request.include.chunks is not None
max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192
# Determine source facts inclusion settings
include_source_facts = request.include.source_facts is not None
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
pre_recall = time.time() - handler_start
# Run recall with tracing (record metrics)
with metrics.record_operation(
@@ -1977,14 +1997,16 @@ def _register_routes(app: FastAPI):
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
)
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
recall_results = [
RecallResult(
def _fact_to_result(fact: "MemoryFact") -> RecallResult:
return RecallResult(
id=fact.id,
text=fact.text,
type=fact.fact_type,
@@ -1996,9 +2018,10 @@ def _register_routes(app: FastAPI):
document_id=fact.document_id,
chunk_id=fact.chunk_id,
tags=fact.tags,
source_fact_ids=fact.source_fact_ids,
)
for fact in core_result.results
]
recall_results = [_fact_to_result(fact) for fact in core_result.results]
# Convert chunks from engine to HTTP API format
chunks_response = None
@@ -2026,11 +2049,19 @@ def _register_routes(app: FastAPI):
],
)
# Convert source facts dict to API format
source_facts_response = None
if core_result.source_facts:
source_facts_response = {
fact_id: _fact_to_result(fact) for fact_id, fact in core_result.source_facts.items()
}
response = RecallResponse(
results=recall_results,
trace=core_result.trace,
entities=entities_response,
chunks=chunks_response,
source_facts=source_facts_response,
)
handler_duration = time.time() - handler_start
@@ -2020,6 +2020,8 @@ class MemoryEngine(MemoryEngineInterface):
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
request_context: "RequestContext",
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
@@ -2159,6 +2161,8 @@ class MemoryEngine(MemoryEngineInterface):
tags_match=tags_match,
connection_budget=_connection_budget,
quiet=_quiet,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
)
break # Success - exit retry loop
except Exception as e:
@@ -2283,6 +2287,8 @@ class MemoryEngine(MemoryEngineInterface):
tags_match: TagsMatch = "any",
connection_budget: int | None = None,
quiet: bool = False,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
) -> RecallResultModel:
"""
Search implementation with modular retrieval and reranking.
@@ -2879,6 +2885,74 @@ class MemoryEngine(MemoryEngineInterface):
)
top_results_dicts.append(result_dict)
# Fetch source facts for observation-type results (mirrors chunks pattern)
source_fact_ids_by_obs: dict[str, list[str]] = {} # obs_id -> [source_id, ...]
source_facts_dict: dict[str, MemoryFact] | None = None
if include_source_facts:
observation_ids = [uuid.UUID(sr.id) for sr in top_scored if sr.retrieval.fact_type == "observation"]
if observation_ids:
async with acquire_with_retry(pool) as sf_conn:
# Fetch source_memory_ids for all observation results
obs_rows = await sf_conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[]) AND fact_type = 'observation'
""",
observation_ids,
)
# Collect unique source IDs in order of first appearance
seen_source_ids: set[str] = set()
source_ids_ordered: list[str] = []
for obs_row in obs_rows:
obs_id = str(obs_row["id"])
sids = [str(s) for s in (obs_row["source_memory_ids"] or [])]
source_fact_ids_by_obs[obs_id] = sids
for sid in sids:
if sid not in seen_source_ids:
source_ids_ordered.append(sid)
seen_source_ids.add(sid)
# Fetch source fact content up to token budget
if source_ids_ordered:
import uuid as uuid_module
source_rows = await sf_conn.fetch(
f"""
SELECT id, text, fact_type, context, occurred_start, occurred_end,
mentioned_at, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
[uuid_module.UUID(sid) for sid in source_ids_ordered],
)
source_row_by_id = {str(r["id"]): r for r in source_rows}
encoding = _get_tiktoken_encoding()
source_facts_dict = {}
total_source_tokens = 0
for sid in source_ids_ordered:
if sid not in source_row_by_id:
continue
r = source_row_by_id[sid]
fact_tokens = len(encoding.encode(r["text"]))
if total_source_tokens + fact_tokens > max_source_facts_tokens:
break
source_facts_dict[sid] = MemoryFact(
id=sid,
text=r["text"],
fact_type=r["fact_type"],
context=r["context"],
occurred_start=r["occurred_start"].isoformat() if r["occurred_start"] else None,
occurred_end=r["occurred_end"].isoformat() if r["occurred_end"] else None,
mentioned_at=r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
document_id=r["document_id"],
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
tags=r["tags"] or None,
)
total_source_tokens += fact_tokens
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
if include_entities and top_scored:
@@ -2924,6 +2998,7 @@ class MemoryEngine(MemoryEngineInterface):
document_id=result_dict.get("document_id"),
chunk_id=result_dict.get("chunk_id"),
tags=result_dict.get("tags"),
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
)
)
@@ -2977,7 +3052,13 @@ class MemoryEngine(MemoryEngineInterface):
if not quiet:
logger.info("\n" + "\n".join(log_buffer))
return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict)
return RecallResultModel(
results=memory_facts,
trace=trace_dict,
entities=entities_dict,
chunks=chunks_dict,
source_facts=source_facts_dict,
)
except Exception as e:
log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}")
@@ -159,6 +159,10 @@ class MemoryFact(BaseModel):
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
tags: list[str] | None = Field(None, description="Visibility scope tags associated with this fact")
source_fact_ids: list[str] | None = Field(
None,
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
)
class ChunkInfo(BaseModel):
@@ -226,6 +230,9 @@ class RecallResult(BaseModel):
chunks: dict[str, ChunkInfo] | None = Field(
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
)
source_facts: dict[str, MemoryFact] | None = Field(
None, description="Source facts for observation-type results, keyed by fact ID"
)
class ReflectResult(BaseModel):
+1
View File
@@ -266,6 +266,7 @@ pub fn recall(
max_tokens: chunk_max_tokens,
}),
entities: None,
source_facts: None,
})
} else {
None
+20
View File
@@ -3226,6 +3226,8 @@ components:
$ref: '#/components/schemas/EntityIncludeOptions'
chunks:
$ref: '#/components/schemas/ChunkIncludeOptions'
source_facts:
$ref: '#/components/schemas/SourceFactsIncludeOptions'
title: IncludeOptions
ListDocumentsResponse:
description: Response model for list documents endpoint.
@@ -3724,6 +3726,10 @@ components:
additionalProperties:
$ref: '#/components/schemas/ChunkData'
nullable: true
source_facts:
additionalProperties:
$ref: '#/components/schemas/RecallResult'
nullable: true
required:
- results
title: RecallResponse
@@ -3789,6 +3795,11 @@ components:
type: string
nullable: true
type: array
source_fact_ids:
items:
type: string
nullable: true
type: array
required:
- id
- text
@@ -4148,6 +4159,15 @@ components:
- items_count
- success
title: RetainResponse
SourceFactsIncludeOptions:
description: Options for including source facts for observation-type results.
properties:
max_tokens:
default: 4096
description: Maximum tokens for source facts
title: Max Tokens
type: integer
title: SourceFactsIncludeOptions
TagItem:
description: Single tag with usage count.
properties:
@@ -21,6 +21,7 @@ var _ MappedNullable = &IncludeOptions{}
type IncludeOptions struct {
Entities NullableEntityIncludeOptions `json:"entities,omitempty"`
Chunks NullableChunkIncludeOptions `json:"chunks,omitempty"`
SourceFacts NullableSourceFactsIncludeOptions `json:"source_facts,omitempty"`
}
// NewIncludeOptions instantiates a new IncludeOptions object
@@ -124,6 +125,48 @@ func (o *IncludeOptions) UnsetChunks() {
o.Chunks.Unset()
}
// GetSourceFacts returns the SourceFacts field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *IncludeOptions) GetSourceFacts() SourceFactsIncludeOptions {
if o == nil || IsNil(o.SourceFacts.Get()) {
var ret SourceFactsIncludeOptions
return ret
}
return *o.SourceFacts.Get()
}
// GetSourceFactsOk returns a tuple with the SourceFacts 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 *IncludeOptions) GetSourceFactsOk() (*SourceFactsIncludeOptions, bool) {
if o == nil {
return nil, false
}
return o.SourceFacts.Get(), o.SourceFacts.IsSet()
}
// HasSourceFacts returns a boolean if a field has been set.
func (o *IncludeOptions) HasSourceFacts() bool {
if o != nil && o.SourceFacts.IsSet() {
return true
}
return false
}
// SetSourceFacts gets a reference to the given NullableSourceFactsIncludeOptions and assigns it to the SourceFacts field.
func (o *IncludeOptions) SetSourceFacts(v SourceFactsIncludeOptions) {
o.SourceFacts.Set(&v)
}
// SetSourceFactsNil sets the value for SourceFacts to be an explicit nil
func (o *IncludeOptions) SetSourceFactsNil() {
o.SourceFacts.Set(nil)
}
// UnsetSourceFacts ensures that no value is present for SourceFacts, not even an explicit nil
func (o *IncludeOptions) UnsetSourceFacts() {
o.SourceFacts.Unset()
}
func (o IncludeOptions) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -140,6 +183,9 @@ func (o IncludeOptions) ToMap() (map[string]interface{}, error) {
if o.Chunks.IsSet() {
toSerialize["chunks"] = o.Chunks.Get()
}
if o.SourceFacts.IsSet() {
toSerialize["source_facts"] = o.SourceFacts.Get()
}
return toSerialize, nil
}
@@ -25,6 +25,7 @@ type RecallResponse struct {
Trace map[string]interface{} `json:"trace,omitempty"`
Entities map[string]EntityStateResponse `json:"entities,omitempty"`
Chunks map[string]ChunkData `json:"chunks,omitempty"`
SourceFacts map[string]RecallResult `json:"source_facts,omitempty"`
}
type _RecallResponse RecallResponse
@@ -170,6 +171,39 @@ func (o *RecallResponse) SetChunks(v map[string]ChunkData) {
o.Chunks = v
}
// GetSourceFacts returns the SourceFacts field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *RecallResponse) GetSourceFacts() map[string]RecallResult {
if o == nil {
var ret map[string]RecallResult
return ret
}
return o.SourceFacts
}
// GetSourceFactsOk returns a tuple with the SourceFacts 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 *RecallResponse) GetSourceFactsOk() (map[string]RecallResult, bool) {
if o == nil || IsNil(o.SourceFacts) {
return map[string]RecallResult{}, false
}
return o.SourceFacts, true
}
// HasSourceFacts returns a boolean if a field has been set.
func (o *RecallResponse) HasSourceFacts() bool {
if o != nil && !IsNil(o.SourceFacts) {
return true
}
return false
}
// SetSourceFacts gets a reference to the given map[string]RecallResult and assigns it to the SourceFacts field.
func (o *RecallResponse) SetSourceFacts(v map[string]RecallResult) {
o.SourceFacts = v
}
func (o RecallResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -190,6 +224,9 @@ func (o RecallResponse) ToMap() (map[string]interface{}, error) {
if o.Chunks != nil {
toSerialize["chunks"] = o.Chunks
}
if o.SourceFacts != nil {
toSerialize["source_facts"] = o.SourceFacts
}
return toSerialize, nil
}
@@ -33,6 +33,7 @@ type RecallResult struct {
Metadata map[string]string `json:"metadata,omitempty"`
ChunkId NullableString `json:"chunk_id,omitempty"`
Tags []string `json:"tags,omitempty"`
SourceFactIds []string `json:"source_fact_ids,omitempty"`
}
type _RecallResult RecallResult
@@ -497,6 +498,39 @@ func (o *RecallResult) SetTags(v []string) {
o.Tags = v
}
// GetSourceFactIds returns the SourceFactIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *RecallResult) GetSourceFactIds() []string {
if o == nil {
var ret []string
return ret
}
return o.SourceFactIds
}
// GetSourceFactIdsOk returns a tuple with the SourceFactIds 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 *RecallResult) GetSourceFactIdsOk() ([]string, bool) {
if o == nil || IsNil(o.SourceFactIds) {
return nil, false
}
return o.SourceFactIds, true
}
// HasSourceFactIds returns a boolean if a field has been set.
func (o *RecallResult) HasSourceFactIds() bool {
if o != nil && !IsNil(o.SourceFactIds) {
return true
}
return false
}
// SetSourceFactIds gets a reference to the given []string and assigns it to the SourceFactIds field.
func (o *RecallResult) SetSourceFactIds(v []string) {
o.SourceFactIds = v
}
func (o RecallResult) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -539,6 +573,9 @@ func (o RecallResult) ToMap() (map[string]interface{}, error) {
if o.Tags != nil {
toSerialize["tags"] = o.Tags
}
if o.SourceFactIds != nil {
toSerialize["source_fact_ids"] = o.SourceFactIds
}
return toSerialize, nil
}
@@ -0,0 +1,131 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.12
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
)
// checks if the SourceFactsIncludeOptions type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &SourceFactsIncludeOptions{}
// SourceFactsIncludeOptions Options for including source facts for observation-type results.
type SourceFactsIncludeOptions struct {
// Maximum tokens for source facts
MaxTokens *int32 `json:"max_tokens,omitempty"`
}
// NewSourceFactsIncludeOptions instantiates a new SourceFactsIncludeOptions object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewSourceFactsIncludeOptions() *SourceFactsIncludeOptions {
this := SourceFactsIncludeOptions{}
var maxTokens int32 = 4096
this.MaxTokens = &maxTokens
return &this
}
// NewSourceFactsIncludeOptionsWithDefaults instantiates a new SourceFactsIncludeOptions object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewSourceFactsIncludeOptionsWithDefaults() *SourceFactsIncludeOptions {
this := SourceFactsIncludeOptions{}
var maxTokens int32 = 4096
this.MaxTokens = &maxTokens
return &this
}
// GetMaxTokens returns the MaxTokens field value if set, zero value otherwise.
func (o *SourceFactsIncludeOptions) GetMaxTokens() int32 {
if o == nil || IsNil(o.MaxTokens) {
var ret int32
return ret
}
return *o.MaxTokens
}
// GetMaxTokensOk returns a tuple with the MaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SourceFactsIncludeOptions) GetMaxTokensOk() (*int32, bool) {
if o == nil || IsNil(o.MaxTokens) {
return nil, false
}
return o.MaxTokens, true
}
// HasMaxTokens returns a boolean if a field has been set.
func (o *SourceFactsIncludeOptions) HasMaxTokens() bool {
if o != nil && !IsNil(o.MaxTokens) {
return true
}
return false
}
// SetMaxTokens gets a reference to the given int32 and assigns it to the MaxTokens field.
func (o *SourceFactsIncludeOptions) SetMaxTokens(v int32) {
o.MaxTokens = &v
}
func (o SourceFactsIncludeOptions) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o SourceFactsIncludeOptions) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.MaxTokens) {
toSerialize["max_tokens"] = o.MaxTokens
}
return toSerialize, nil
}
type NullableSourceFactsIncludeOptions struct {
value *SourceFactsIncludeOptions
isSet bool
}
func (v NullableSourceFactsIncludeOptions) Get() *SourceFactsIncludeOptions {
return v.value
}
func (v *NullableSourceFactsIncludeOptions) Set(val *SourceFactsIncludeOptions) {
v.value = val
v.isSet = true
}
func (v NullableSourceFactsIncludeOptions) IsSet() bool {
return v.isSet
}
func (v *NullableSourceFactsIncludeOptions) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableSourceFactsIncludeOptions(val *SourceFactsIncludeOptions) *NullableSourceFactsIncludeOptions {
return &NullableSourceFactsIncludeOptions{value: val, isSet: true}
}
func (v NullableSourceFactsIncludeOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableSourceFactsIncludeOptions) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -77,6 +77,7 @@ hindsight_client_api/models/reflect_tool_call.py
hindsight_client_api/models/reflect_trace.py
hindsight_client_api/models/retain_request.py
hindsight_client_api/models/retain_response.py
hindsight_client_api/models/source_facts_include_options.py
hindsight_client_api/models/tag_item.py
hindsight_client_api/models/token_usage.py
hindsight_client_api/models/tool_calls_include_options.py
@@ -252,6 +252,8 @@ class Hindsight:
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> RecallResponse:
@@ -270,20 +272,30 @@ class Hindsight:
max_entity_tokens: Maximum tokens for entity observations (default: 500)
include_chunks: Include raw text chunks in results (default: False)
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
include_source_facts: Include source facts for observation-type results (default: False)
max_source_facts_tokens: Maximum tokens for source facts (default: 4096)
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
RecallResponse with results, optional entities, optional chunks, and optional trace
RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace
"""
from hindsight_client_api.models import chunk_include_options, entity_include_options, include_options
from hindsight_client_api.models import (
chunk_include_options,
entity_include_options,
include_options,
source_facts_include_options,
)
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens)
if include_entities
else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
source_facts=source_facts_include_options.SourceFactsIncludeOptions(max_tokens=max_source_facts_tokens)
if include_source_facts
else None,
)
request_obj = recall_request.RecallRequest(
@@ -567,6 +579,8 @@ class Hindsight:
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> RecallResponse:
@@ -585,20 +599,30 @@ class Hindsight:
max_entity_tokens: Maximum tokens for entity observations (default: 500)
include_chunks: Include raw text chunks in results (default: False)
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
include_source_facts: Include source facts for observation-type results (default: False)
max_source_facts_tokens: Maximum tokens for source facts (default: 4096)
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
RecallResponse with results, optional entities, optional chunks, and optional trace
RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace
"""
from hindsight_client_api.models import chunk_include_options, entity_include_options, include_options
from hindsight_client_api.models import (
chunk_include_options,
entity_include_options,
include_options,
source_facts_include_options,
)
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens)
if include_entities
else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
source_facts=source_facts_include_options.SourceFactsIncludeOptions(max_tokens=max_source_facts_tokens)
if include_source_facts
else None,
)
request_obj = recall_request.RecallRequest(
@@ -102,6 +102,7 @@ from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
@@ -77,6 +77,7 @@ from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
@@ -21,6 +21,7 @@ from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions
from hindsight_client_api.models.entity_include_options import EntityIncludeOptions
from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions
from typing import Optional, Set
from typing_extensions import Self
@@ -30,7 +31,8 @@ class IncludeOptions(BaseModel):
""" # noqa: E501
entities: Optional[EntityIncludeOptions] = None
chunks: Optional[ChunkIncludeOptions] = None
__properties: ClassVar[List[str]] = ["entities", "chunks"]
source_facts: Optional[SourceFactsIncludeOptions] = None
__properties: ClassVar[List[str]] = ["entities", "chunks", "source_facts"]
model_config = ConfigDict(
populate_by_name=True,
@@ -77,6 +79,9 @@ class IncludeOptions(BaseModel):
# override the default output from pydantic by calling `to_dict()` of chunks
if self.chunks:
_dict['chunks'] = self.chunks.to_dict()
# override the default output from pydantic by calling `to_dict()` of source_facts
if self.source_facts:
_dict['source_facts'] = self.source_facts.to_dict()
# set to None if entities (nullable) is None
# and model_fields_set contains the field
if self.entities is None and "entities" in self.model_fields_set:
@@ -87,6 +92,11 @@ class IncludeOptions(BaseModel):
if self.chunks is None and "chunks" in self.model_fields_set:
_dict['chunks'] = None
# set to None if source_facts (nullable) is None
# and model_fields_set contains the field
if self.source_facts is None and "source_facts" in self.model_fields_set:
_dict['source_facts'] = None
return _dict
@classmethod
@@ -100,7 +110,8 @@ class IncludeOptions(BaseModel):
_obj = cls.model_validate({
"entities": EntityIncludeOptions.from_dict(obj["entities"]) if obj.get("entities") is not None else None,
"chunks": ChunkIncludeOptions.from_dict(obj["chunks"]) if obj.get("chunks") is not None else None
"chunks": ChunkIncludeOptions.from_dict(obj["chunks"]) if obj.get("chunks") is not None else None,
"source_facts": SourceFactsIncludeOptions.from_dict(obj["source_facts"]) if obj.get("source_facts") is not None else None
})
return _obj
@@ -33,7 +33,8 @@ class RecallResponse(BaseModel):
trace: Optional[Dict[str, Any]] = None
entities: Optional[Dict[str, EntityStateResponse]] = None
chunks: Optional[Dict[str, ChunkData]] = None
__properties: ClassVar[List[str]] = ["results", "trace", "entities", "chunks"]
source_facts: Optional[Dict[str, RecallResult]] = None
__properties: ClassVar[List[str]] = ["results", "trace", "entities", "chunks", "source_facts"]
model_config = ConfigDict(
populate_by_name=True,
@@ -95,6 +96,13 @@ class RecallResponse(BaseModel):
if self.chunks[_key_chunks]:
_field_dict[_key_chunks] = self.chunks[_key_chunks].to_dict()
_dict['chunks'] = _field_dict
# override the default output from pydantic by calling `to_dict()` of each value in source_facts (dict)
_field_dict = {}
if self.source_facts:
for _key_source_facts in self.source_facts:
if self.source_facts[_key_source_facts]:
_field_dict[_key_source_facts] = self.source_facts[_key_source_facts].to_dict()
_dict['source_facts'] = _field_dict
# set to None if trace (nullable) is None
# and model_fields_set contains the field
if self.trace is None and "trace" in self.model_fields_set:
@@ -110,6 +118,11 @@ class RecallResponse(BaseModel):
if self.chunks is None and "chunks" in self.model_fields_set:
_dict['chunks'] = None
# set to None if source_facts (nullable) is None
# and model_fields_set contains the field
if self.source_facts is None and "source_facts" in self.model_fields_set:
_dict['source_facts'] = None
return _dict
@classmethod
@@ -135,6 +148,12 @@ class RecallResponse(BaseModel):
for _k, _v in obj["chunks"].items()
)
if obj.get("chunks") is not None
else None,
"source_facts": dict(
(_k, RecallResult.from_dict(_v))
for _k, _v in obj["source_facts"].items()
)
if obj.get("source_facts") is not None
else None
})
return _obj
@@ -38,7 +38,8 @@ class RecallResult(BaseModel):
metadata: Optional[Dict[str, StrictStr]] = None
chunk_id: Optional[StrictStr] = None
tags: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["id", "text", "type", "entities", "context", "occurred_start", "occurred_end", "mentioned_at", "document_id", "metadata", "chunk_id", "tags"]
source_fact_ids: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["id", "text", "type", "entities", "context", "occurred_start", "occurred_end", "mentioned_at", "document_id", "metadata", "chunk_id", "tags", "source_fact_ids"]
model_config = ConfigDict(
populate_by_name=True,
@@ -129,6 +130,11 @@ class RecallResult(BaseModel):
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
# set to None if source_fact_ids (nullable) is None
# and model_fields_set contains the field
if self.source_fact_ids is None and "source_fact_ids" in self.model_fields_set:
_dict['source_fact_ids'] = None
return _dict
@classmethod
@@ -152,7 +158,8 @@ class RecallResult(BaseModel):
"document_id": obj.get("document_id"),
"metadata": obj.get("metadata"),
"chunk_id": obj.get("chunk_id"),
"tags": obj.get("tags")
"tags": obj.get("tags"),
"source_fact_ids": obj.get("source_fact_ids")
})
return _obj
@@ -0,0 +1,87 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.12
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class SourceFactsIncludeOptions(BaseModel):
"""
Options for including source facts for observation-type results.
""" # noqa: E501
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for source facts")
__properties: ClassVar[List[str]] = ["max_tokens"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of SourceFactsIncludeOptions from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of SourceFactsIncludeOptions from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096
})
return _obj
@@ -994,6 +994,10 @@ export type IncludeOptions = {
* Include raw chunks. Set to {} to enable, null to disable (default: disabled).
*/
chunks?: ChunkIncludeOptions | null;
/**
* Include source facts for observation-type results. Set to {} to enable, null to disable (default: disabled).
*/
source_facts?: SourceFactsIncludeOptions | null;
};
/**
@@ -1392,6 +1396,14 @@ export type RecallResponse = {
chunks?: {
[key: string]: ChunkData;
} | null;
/**
* Source Facts
*
* Source facts for observation-type results, keyed by fact ID
*/
source_facts?: {
[key: string]: RecallResult;
} | null;
};
/**
@@ -1450,6 +1462,10 @@ export type RecallResult = {
* Tags
*/
tags?: Array<string> | null;
/**
* Source Fact Ids
*/
source_fact_ids?: Array<string> | null;
};
/**
@@ -1807,6 +1823,20 @@ export type RetainResponse = {
usage?: TokenUsage | null;
};
/**
* SourceFactsIncludeOptions
*
* Options for including source facts for observation-type results.
*/
export type SourceFactsIncludeOptions = {
/**
* Max Tokens
*
* Maximum tokens for source facts
*/
max_tokens?: number;
};
/**
* TagItem
*
@@ -262,6 +262,10 @@ export class HindsightClient {
maxEntityTokens?: number;
includeChunks?: boolean;
maxChunkTokens?: number;
/** Include source facts for observation-type results */
includeSourceFacts?: boolean;
/** Maximum tokens for source facts (default: 4096) */
maxSourceFactsTokens?: number;
/** Optional list of tags to filter memories by */
tags?: string[];
/** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any' */
@@ -281,6 +285,7 @@ export class HindsightClient {
include: {
entities: options?.includeEntities ? { max_tokens: options?.maxEntityTokens ?? 500 } : undefined,
chunks: options?.includeChunks ? { max_tokens: options?.maxChunkTokens ?? 8192 } : undefined,
source_facts: options?.includeSourceFacts ? { max_tokens: options?.maxSourceFactsTokens ?? 4096 } : undefined,
},
tags: options?.tags,
tags_match: options?.tagsMatch,
+1 -1
View File
@@ -125,7 +125,7 @@ export class ControlPlaneClient {
include?: {
entities?: { max_tokens: number } | null;
chunks?: { max_tokens: number } | null;
observations?: { max_results?: number } | null;
source_facts?: { max_tokens?: number } | null;
};
query_timestamp?: string;
tags?: string[];
@@ -48,6 +48,8 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
| `trace` | bool | false | Enable trace output for debugging |
| `include_chunks` | bool | false | Include raw text chunks that generated the memories |
| `max_chunk_tokens` | int | 500 | Token budget for chunks (independent of `max_tokens`) |
| `include_source_facts` | bool | false | Include source facts for observation-type results (see [Source Facts](#source-facts)) |
| `max_source_facts_tokens` | int | 4096 | Token budget for source facts |
| `tags` | list | None | Filter memories by tags (see [Tag Filtering](#filter-by-tags)) |
| `tags_match` | string | "any" | How to match tags: `any`, `all`, `any_strict`, `all_strict` |
@@ -79,6 +81,25 @@ Recall specific memory types:
Observations are consolidated knowledge synthesized from multiple facts. They capture patterns, preferences, and learnings that the memory bank has built up over time. Observations are automatically created in the background after retain operations.
:::
## Source Facts
When recalling `observation`-type memories, you can fetch the underlying facts they were derived from. This is useful when you need to understand or verify the evidence behind a synthesized observation.
Source facts are returned as a top-level `source_facts` dict keyed by fact ID. Each observation result includes a `source_fact_ids` list for cross-referencing. Facts are deduplicated — if two observations share a source fact, it only appears once.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-source-facts" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-source-facts" language="javascript" />
</TabItem>
</Tabs>
:::note Source Facts Token Budget
Source facts are fetched independently of the main `max_tokens` budget, up to `max_source_facts_tokens`. Facts are included in order of first appearance across all observations — once the budget is reached, remaining source facts are omitted.
:::
## Token Budget Management
Hindsight is built for AI agents, not humans. Traditional retrieval systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.
+21
View File
@@ -44,6 +44,27 @@ for (const r of detailedResponse.results) {
// [/docs:recall-with-options]
// [docs:recall-source-facts]
// Recall observations and include their source facts
const obsResponse = await client.recall('my-bank', 'What patterns have I learned about Alice?', {
types: ['observation'],
includeSourceFacts: true,
maxSourceFactsTokens: 4096,
});
for (const obs of obsResponse.results) {
console.log(`Observation: ${obs.text}`);
if (obs.source_fact_ids && obsResponse.source_facts) {
console.log(' Derived from:');
for (const factId of obs.source_fact_ids) {
const fact = obsResponse.source_facts[factId];
if (fact) console.log(` - [${fact.type}] ${fact.text}`);
}
}
}
// [/docs:recall-source-facts]
// [docs:recall-budget-levels]
// Quick lookup
const quickResults = await client.recall('my-bank', "Alice's email", { budget: 'low' });
+21
View File
@@ -94,6 +94,27 @@ observations = client.recall(
# [/docs:recall-with-observations]
# [docs:recall-source-facts]
# Recall observations and include their source facts
response = client.recall(
bank_id="my-bank",
query="What patterns have I learned about Alice?",
types=["observation"],
include_source_facts=True,
max_source_facts_tokens=4096,
)
for obs in response.results:
print(f"Observation: {obs.text}")
if obs.source_fact_ids and response.source_facts:
print(" Derived from:")
for fact_id in obs.source_fact_ids:
fact = response.source_facts.get(fact_id)
if fact:
print(f" - [{fact.type}] {fact.text}")
# [/docs:recall-source-facts]
# [docs:recall-token-budget]
# Fill up to 4K tokens of context with relevant memories
results = client.recall(bank_id="my-bank", query="What do I know about Alice?", max_tokens=4096)
+53
View File
@@ -4801,6 +4801,17 @@
}
],
"description": "Include raw chunks. Set to {} to enable, null to disable (default: disabled)."
},
"source_facts": {
"anyOf": [
{
"$ref": "#/components/schemas/SourceFactsIncludeOptions"
},
{
"type": "null"
}
],
"description": "Include source facts for observation-type results. Set to {} to enable, null to disable (default: disabled)."
}
},
"type": "object",
@@ -5580,6 +5591,21 @@
],
"title": "Chunks",
"description": "Chunks for facts, keyed by chunk_id"
},
"source_facts": {
"anyOf": [
{
"additionalProperties": {
"$ref": "#/components/schemas/RecallResult"
},
"type": "object"
},
{
"type": "null"
}
],
"title": "Source Facts",
"description": "Source facts for observation-type results, keyed by fact ID"
}
},
"type": "object",
@@ -5758,6 +5784,20 @@
}
],
"title": "Tags"
},
"source_fact_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Source Fact Ids"
}
},
"type": "object",
@@ -6431,6 +6471,19 @@
}
}
},
"SourceFactsIncludeOptions": {
"properties": {
"max_tokens": {
"type": "integer",
"title": "Max Tokens",
"description": "Maximum tokens for source facts",
"default": 4096
}
},
"type": "object",
"title": "SourceFactsIncludeOptions",
"description": "Options for including source facts for observation-type results."
},
"TagItem": {
"properties": {
"tag": {