feat: include occurred_end and mentioned_at in think-prompt fact serialization (#929)

Extend format_facts_for_prompt() to include occurred_end and mentioned_at
temporal fields (when non-null), matching the MemoryFact model. Also add
RecallResponse.to_prompt_string() to Python and TypeScript client SDKs so
users can serialize recall results (with chunks and entity summaries) into
LLM-ready prompt strings.

Closes #924
This commit is contained in:
Nicolò Boschi 2026-04-08 10:33:14 +02:00 committed by GitHub
parent cece2c903c
commit 37348c859e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 384 additions and 7 deletions

View file

@ -62,13 +62,14 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
if fact.context:
fact_obj["context"] = fact.context
# Add occurred_start if available (when the fact occurred)
if fact.occurred_start:
occurred_start = fact.occurred_start
if isinstance(occurred_start, str):
fact_obj["occurred_start"] = occurred_start
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
# Add temporal fields if available
for field_name in ("occurred_start", "occurred_end", "mentioned_at"):
value = getattr(fact, field_name, None)
if value:
if isinstance(value, str):
fact_obj[field_name] = value
elif isinstance(value, datetime):
fact_obj[field_name] = value.strftime("%Y-%m-%d %H:%M:%S")
formatted.append(fact_obj)

View file

@ -0,0 +1,65 @@
"""
Tests for format_facts_for_prompt in think_utils.
"""
import json
from hindsight_api.engine.response_models import MemoryFact
from hindsight_api.engine.search.think_utils import format_facts_for_prompt
def test_format_facts_includes_temporal_fields():
"""All temporal fields (occurred_start, occurred_end, mentioned_at) should appear in the JSON."""
facts = [
MemoryFact(
id="fact-1",
text="Team offsite in February",
fact_type="experience",
occurred_start="2024-02-01T00:00:00Z",
occurred_end="2024-02-28T23:59:59Z",
mentioned_at="2024-03-05T10:00:00Z",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert len(result) == 1
assert result[0]["text"] == "Team offsite in February"
assert result[0]["occurred_start"] == "2024-02-01T00:00:00Z"
assert result[0]["occurred_end"] == "2024-02-28T23:59:59Z"
assert result[0]["mentioned_at"] == "2024-03-05T10:00:00Z"
def test_format_facts_omits_null_temporal_fields():
"""Null temporal fields should not appear in the JSON."""
facts = [
MemoryFact(
id="fact-2",
text="The sky is blue",
fact_type="world",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert len(result) == 1
assert "occurred_start" not in result[0]
assert "occurred_end" not in result[0]
assert "mentioned_at" not in result[0]
def test_format_facts_partial_temporal_fields():
"""Only non-null temporal fields should appear."""
facts = [
MemoryFact(
id="fact-3",
text="Meeting happened",
fact_type="experience",
occurred_start="2024-06-01T09:00:00Z",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert result[0]["occurred_start"] == "2024-06-01T09:00:00Z"
assert "occurred_end" not in result[0]
assert "mentioned_at" not in result[0]
def test_format_facts_empty_list():
"""Empty list should return '[]'."""
assert format_facts_for_prompt([]) == "[]"

View file

@ -91,6 +91,53 @@ def _recall_response_getitem(self, index):
return self.results[index]
def _recall_response_to_prompt_string(self) -> str:
"""Serialize the recall response to a string suitable for LLM prompts.
Builds a prompt containing:
- Facts: each result as a JSON object with ``text``, ``context``, and
temporal fields (``occurred_start``, ``occurred_end``, ``mentioned_at``).
If the result has a ``chunk_id`` matching a chunk in the response, the
chunk text is included as ``source_chunk``.
- Entities: entity summaries from observations, formatted as sections.
This mirrors the format used internally by Hindsight's reflect operation.
"""
import json
chunks_map = self.chunks or {}
sections: list[str] = []
# Facts
formatted_facts: list[dict] = []
for result in self.results or []:
fact_obj: dict = {"text": result.text}
if result.context:
fact_obj["context"] = result.context
for field in ("occurred_start", "occurred_end", "mentioned_at"):
value = getattr(result, field, None)
if value:
fact_obj[field] = value
if result.chunk_id and result.chunk_id in chunks_map:
fact_obj["source_chunk"] = chunks_map[result.chunk_id].text
formatted_facts.append(fact_obj)
sections.append("FACTS:\n" + json.dumps(formatted_facts, indent=2))
# Entities
if self.entities:
entity_parts: list[str] = []
for name, state in self.entities.items():
if state.observations:
obs_text = state.observations[0].text
entity_parts.append(f"## {name}\n{obs_text}")
if entity_parts:
sections.append("ENTITIES:\n" + "\n\n".join(entity_parts))
return "\n\n".join(sections)
_RecallResponse.to_prompt_string = _recall_response_to_prompt_string
_RecallResult.__repr__ = _recall_result_repr
_RecallResponse.__repr__ = _recall_response_repr
_RecallResponse.__iter__ = _recall_response_iter

View file

@ -0,0 +1,111 @@
"""
Tests for RecallResponse.to_prompt_string.
"""
import json
from hindsight_client import RecallResponse
def test_to_prompt_string_facts_only():
response = RecallResponse.from_dict({
"results": [
{
"id": "1",
"text": "Alice works at Google",
"type": "world",
"context": "work",
"occurred_start": "2024-01-15T10:00:00Z",
"occurred_end": "2024-06-15T10:00:00Z",
"mentioned_at": "2024-03-01T09:00:00Z",
},
{"id": "2", "text": "The sky is blue", "type": "world"},
]
})
prompt = response.to_prompt_string()
assert prompt.startswith("FACTS:\n")
facts = json.loads(prompt[len("FACTS:\n"):])
assert len(facts) == 2
assert facts[0] == {
"text": "Alice works at Google",
"context": "work",
"occurred_start": "2024-01-15T10:00:00Z",
"occurred_end": "2024-06-15T10:00:00Z",
"mentioned_at": "2024-03-01T09:00:00Z",
}
assert facts[1] == {"text": "The sky is blue"}
def test_to_prompt_string_with_chunks():
response = RecallResponse.from_dict({
"results": [
{"id": "1", "text": "Alice works at Google", "type": "world", "chunk_id": "chunk_1"},
],
"chunks": {
"chunk_1": {"id": "chunk_1", "text": "Alice works at Google on the AI team since 2020.", "chunk_index": 0},
},
})
prompt = response.to_prompt_string()
facts = json.loads(prompt[len("FACTS:\n"):])
assert facts[0]["source_chunk"] == "Alice works at Google on the AI team since 2020."
def test_to_prompt_string_with_entities():
response = RecallResponse.from_dict({
"results": [
{"id": "1", "text": "Alice works at Google", "type": "world"},
],
"entities": {
"Alice": {
"entity_id": "e1",
"canonical_name": "Alice",
"observations": [{"text": "Alice is a senior engineer at Google working on AI."}],
},
},
})
prompt = response.to_prompt_string()
assert "ENTITIES:" in prompt
assert "## Alice" in prompt
assert "Alice is a senior engineer at Google working on AI." in prompt
def test_to_prompt_string_with_chunks_and_entities():
response = RecallResponse.from_dict({
"results": [
{"id": "1", "text": "Alice works at Google", "type": "world", "chunk_id": "c1"},
],
"chunks": {
"c1": {"id": "c1", "text": "Full conversation about Alice at Google.", "chunk_index": 0},
},
"entities": {
"Alice": {
"entity_id": "e1",
"canonical_name": "Alice",
"observations": [{"text": "Alice is a senior engineer."}],
},
},
})
prompt = response.to_prompt_string()
# Facts with chunk
facts = json.loads(prompt.split("ENTITIES:")[0].strip()[len("FACTS:\n"):])
assert facts[0]["source_chunk"] == "Full conversation about Alice at Google."
# Entities
assert "## Alice\nAlice is a senior engineer." in prompt
def test_to_prompt_string_empty_results():
response = RecallResponse.from_dict({"results": []})
prompt = response.to_prompt_string()
assert prompt == "FACTS:\n[]"
def test_to_prompt_string_chunk_id_not_in_chunks():
"""chunk_id that doesn't match any chunk should be ignored."""
response = RecallResponse.from_dict({
"results": [
{"id": "1", "text": "Some fact", "type": "world", "chunk_id": "missing_chunk"},
],
})
prompt = response.to_prompt_string()
facts = json.loads(prompt[len("FACTS:\n"):])
assert "source_chunk" not in facts[0]

View file

@ -742,6 +742,51 @@ export class HindsightClient {
}
}
/**
* Serialize a RecallResponse to a string suitable for LLM prompts.
*
* Builds a prompt containing:
* - Facts: each result as a JSON object with text, context, temporal fields,
* and source_chunk (if the result's chunk_id matches a chunk in the response).
* - Entities: entity summaries from observations, formatted as sections.
*
* Mirrors the format used internally by Hindsight's reflect operation.
*/
export function recallResponseToPromptString(response: RecallResponse): string {
const chunksMap = response.chunks ?? {};
const sections: string[] = [];
// Facts
const formattedFacts = (response.results ?? []).map((result) => {
const obj: Record<string, string> = { text: result.text };
if (result.context) obj.context = result.context;
if (result.occurred_start) obj.occurred_start = result.occurred_start;
if (result.occurred_end) obj.occurred_end = result.occurred_end;
if (result.mentioned_at) obj.mentioned_at = result.mentioned_at;
if (result.chunk_id && chunksMap[result.chunk_id]) {
obj.source_chunk = chunksMap[result.chunk_id].text;
}
return obj;
});
sections.push('FACTS:\n' + JSON.stringify(formattedFacts, null, 2));
// Entities
const entities = response.entities;
if (entities) {
const entityParts: string[] = [];
for (const [name, state] of Object.entries(entities)) {
if (state.observations?.length) {
entityParts.push(`## ${name}\n${state.observations[0].text}`);
}
}
if (entityParts.length) {
sections.push('ENTITIES:\n' + entityParts.join('\n\n'));
}
}
return sections.join('\n\n');
}
// Re-export types for convenience
export type {
RetainRequest,

View file

@ -0,0 +1,108 @@
/**
* Tests for recallResponseToPromptString.
*/
import { recallResponseToPromptString } from '../src';
describe('recallResponseToPromptString', () => {
test('facts only', () => {
const response = {
results: [
{
id: '1',
text: 'Alice works at Google',
context: 'work',
occurred_start: '2024-01-15T10:00:00Z',
occurred_end: '2024-06-15T10:00:00Z',
mentioned_at: '2024-03-01T09:00:00Z',
},
{ id: '2', text: 'The sky is blue' },
],
};
const prompt = recallResponseToPromptString(response);
expect(prompt.startsWith('FACTS:\n')).toBe(true);
const facts = JSON.parse(prompt.slice('FACTS:\n'.length));
expect(facts).toEqual([
{
text: 'Alice works at Google',
context: 'work',
occurred_start: '2024-01-15T10:00:00Z',
occurred_end: '2024-06-15T10:00:00Z',
mentioned_at: '2024-03-01T09:00:00Z',
},
{ text: 'The sky is blue' },
]);
});
test('with chunks', () => {
const response = {
results: [
{ id: '1', text: 'Alice works at Google', chunk_id: 'chunk_1' },
],
chunks: {
chunk_1: { id: 'chunk_1', text: 'Alice works at Google on the AI team since 2020.', chunk_index: 0 },
},
};
const prompt = recallResponseToPromptString(response);
const facts = JSON.parse(prompt.slice('FACTS:\n'.length));
expect(facts[0].source_chunk).toBe('Alice works at Google on the AI team since 2020.');
});
test('with entities', () => {
const response = {
results: [
{ id: '1', text: 'Alice works at Google' },
],
entities: {
Alice: {
entity_id: 'e1',
canonical_name: 'Alice',
observations: [{ text: 'Alice is a senior engineer at Google working on AI.' }],
},
},
};
const prompt = recallResponseToPromptString(response);
expect(prompt).toContain('ENTITIES:');
expect(prompt).toContain('## Alice');
expect(prompt).toContain('Alice is a senior engineer at Google working on AI.');
});
test('with chunks and entities', () => {
const response = {
results: [
{ id: '1', text: 'Alice works at Google', chunk_id: 'c1' },
],
chunks: {
c1: { id: 'c1', text: 'Full conversation about Alice at Google.', chunk_index: 0 },
},
entities: {
Alice: {
entity_id: 'e1',
canonical_name: 'Alice',
observations: [{ text: 'Alice is a senior engineer.' }],
},
},
};
const prompt = recallResponseToPromptString(response);
const factsSection = prompt.split('ENTITIES:')[0].trim();
const facts = JSON.parse(factsSection.slice('FACTS:\n'.length));
expect(facts[0].source_chunk).toBe('Full conversation about Alice at Google.');
expect(prompt).toContain('## Alice\nAlice is a senior engineer.');
});
test('empty results', () => {
const response = { results: [] };
expect(recallResponseToPromptString(response)).toBe('FACTS:\n[]');
});
test('chunk_id not in chunks is ignored', () => {
const response = {
results: [
{ id: '1', text: 'Some fact', chunk_id: 'missing_chunk' },
],
};
const prompt = recallResponseToPromptString(response);
const facts = JSON.parse(prompt.slice('FACTS:\n'.length));
expect(facts[0]).not.toHaveProperty('source_chunk');
});
});