From 37348c859e43da2eab9d67b80e3c24f8e3cbab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 8 Apr 2026 10:33:14 +0200 Subject: [PATCH] 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 --- .../engine/search/think_utils.py | 15 +-- .../tests/test_format_facts_for_prompt.py | 65 ++++++++++ .../python/hindsight_client/__init__.py | 47 ++++++++ .../python/tests/test_prompt_formatting.py | 111 ++++++++++++++++++ hindsight-clients/typescript/src/index.ts | 45 +++++++ .../tests/prompt_formatting.test.ts | 108 +++++++++++++++++ 6 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 hindsight-api-slim/tests/test_format_facts_for_prompt.py create mode 100644 hindsight-clients/python/tests/test_prompt_formatting.py create mode 100644 hindsight-clients/typescript/tests/prompt_formatting.test.ts diff --git a/hindsight-api-slim/hindsight_api/engine/search/think_utils.py b/hindsight-api-slim/hindsight_api/engine/search/think_utils.py index 31648298..d2229354 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/think_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/search/think_utils.py @@ -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) diff --git a/hindsight-api-slim/tests/test_format_facts_for_prompt.py b/hindsight-api-slim/tests/test_format_facts_for_prompt.py new file mode 100644 index 00000000..18ec4a2e --- /dev/null +++ b/hindsight-api-slim/tests/test_format_facts_for_prompt.py @@ -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([]) == "[]" diff --git a/hindsight-clients/python/hindsight_client/__init__.py b/hindsight-clients/python/hindsight_client/__init__.py index cdbb8f3d..9444d440 100644 --- a/hindsight-clients/python/hindsight_client/__init__.py +++ b/hindsight-clients/python/hindsight_client/__init__.py @@ -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 diff --git a/hindsight-clients/python/tests/test_prompt_formatting.py b/hindsight-clients/python/tests/test_prompt_formatting.py new file mode 100644 index 00000000..ad9ffdd1 --- /dev/null +++ b/hindsight-clients/python/tests/test_prompt_formatting.py @@ -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] diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 01941fc9..76d19120 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -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 = { 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, diff --git a/hindsight-clients/typescript/tests/prompt_formatting.test.ts b/hindsight-clients/typescript/tests/prompt_formatting.test.ts new file mode 100644 index 00000000..4ca67008 --- /dev/null +++ b/hindsight-clients/typescript/tests/prompt_formatting.test.ts @@ -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'); + }); +});