diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index f2b9cd96..c751c983 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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 diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 6e592413..269c5ec8 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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)}") diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py index f85e1141..b46b842b 100644 --- a/hindsight-api/hindsight_api/engine/response_models.py +++ b/hindsight-api/hindsight_api/engine/response_models.py @@ -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): diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index a116446c..ea461570 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -266,6 +266,7 @@ pub fn recall( max_tokens: chunk_max_tokens, }), entities: None, + source_facts: None, }) } else { None diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 69f13973..fed2587a 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -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: diff --git a/hindsight-clients/go/model_include_options.go b/hindsight-clients/go/model_include_options.go index aedbd75b..c32d6d69 100644 --- a/hindsight-clients/go/model_include_options.go +++ b/hindsight-clients/go/model_include_options.go @@ -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 } diff --git a/hindsight-clients/go/model_recall_response.go b/hindsight-clients/go/model_recall_response.go index cee56169..2f038566 100644 --- a/hindsight-clients/go/model_recall_response.go +++ b/hindsight-clients/go/model_recall_response.go @@ -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 } diff --git a/hindsight-clients/go/model_recall_result.go b/hindsight-clients/go/model_recall_result.go index af7da4d9..a14fae33 100644 --- a/hindsight-clients/go/model_recall_result.go +++ b/hindsight-clients/go/model_recall_result.go @@ -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 } diff --git a/hindsight-clients/go/model_source_facts_include_options.go b/hindsight-clients/go/model_source_facts_include_options.go new file mode 100644 index 00000000..cf5207b8 --- /dev/null +++ b/hindsight-clients/go/model_source_facts_include_options.go @@ -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) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 726609cd..286e294b 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -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 diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index d52c21e3..960bf0d1 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.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( diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 4e8fd284..d0845b4c 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 9609d0e8..a36df415 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/include_options.py b/hindsight-clients/python/hindsight_client_api/models/include_options.py index bf44dd87..25e564ee 100644 --- a/hindsight-clients/python/hindsight_client_api/models/include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/include_options.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_response.py b/hindsight-clients/python/hindsight_client_api/models/recall_response.py index 5146b5bd..ce30aff4 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_response.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_result.py b/hindsight-clients/python/hindsight_client_api/models/recall_result.py index 536aeb0e..23b3fa9e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_result.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_result.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py new file mode 100644 index 00000000..fb178e7b --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py @@ -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 + + diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 8bcf22ba..ff4d45fa 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -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 | null; + /** + * Source Fact Ids + */ + source_fact_ids?: Array | 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 * diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index b6f93ef4..ed2cdbd0 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -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, diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index a490e35e..29fbf309 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -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[]; diff --git a/hindsight-docs/docs/developer/api/recall.mdx b/hindsight-docs/docs/developer/api/recall.mdx index a087bd03..750023b7 100644 --- a/hindsight-docs/docs/developer/api/recall.mdx +++ b/hindsight-docs/docs/developer/api/recall.mdx @@ -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. + + + + + + + + + + +:::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. diff --git a/hindsight-docs/examples/api/recall.mjs b/hindsight-docs/examples/api/recall.mjs index 592c820e..e85ab238 100644 --- a/hindsight-docs/examples/api/recall.mjs +++ b/hindsight-docs/examples/api/recall.mjs @@ -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' }); diff --git a/hindsight-docs/examples/api/recall.py b/hindsight-docs/examples/api/recall.py index 1b3d41c5..db700965 100644 --- a/hindsight-docs/examples/api/recall.py +++ b/hindsight-docs/examples/api/recall.py @@ -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) diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index e6fe5c47..f083d989 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -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": {