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:
parent
cece2c903c
commit
37348c859e
6 changed files with 384 additions and 7 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
65
hindsight-api-slim/tests/test_format_facts_for_prompt.py
Normal file
65
hindsight-api-slim/tests/test_format_facts_for_prompt.py
Normal 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([]) == "[]"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
111
hindsight-clients/python/tests/test_prompt_formatting.py
Normal file
111
hindsight-clients/python/tests/test_prompt_formatting.py
Normal 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]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
108
hindsight-clients/typescript/tests/prompt_formatting.test.ts
Normal file
108
hindsight-clients/typescript/tests/prompt_formatting.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue