hindsight-api 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. hindsight_api/admin/__init__.py +1 -0
  2. hindsight_api/admin/cli.py +311 -0
  3. hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
  4. hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
  5. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  6. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  7. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  8. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  9. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  10. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  11. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  12. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  13. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  14. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  15. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  16. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  17. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  18. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  19. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  20. hindsight_api/api/http.py +1406 -118
  21. hindsight_api/api/mcp.py +11 -196
  22. hindsight_api/config.py +359 -27
  23. hindsight_api/engine/consolidation/__init__.py +5 -0
  24. hindsight_api/engine/consolidation/consolidator.py +859 -0
  25. hindsight_api/engine/consolidation/prompts.py +69 -0
  26. hindsight_api/engine/cross_encoder.py +706 -88
  27. hindsight_api/engine/db_budget.py +284 -0
  28. hindsight_api/engine/db_utils.py +11 -0
  29. hindsight_api/engine/directives/__init__.py +5 -0
  30. hindsight_api/engine/directives/models.py +37 -0
  31. hindsight_api/engine/embeddings.py +553 -29
  32. hindsight_api/engine/entity_resolver.py +8 -5
  33. hindsight_api/engine/interface.py +40 -17
  34. hindsight_api/engine/llm_wrapper.py +744 -68
  35. hindsight_api/engine/memory_engine.py +2505 -1017
  36. hindsight_api/engine/mental_models/__init__.py +14 -0
  37. hindsight_api/engine/mental_models/models.py +53 -0
  38. hindsight_api/engine/query_analyzer.py +4 -3
  39. hindsight_api/engine/reflect/__init__.py +18 -0
  40. hindsight_api/engine/reflect/agent.py +933 -0
  41. hindsight_api/engine/reflect/models.py +109 -0
  42. hindsight_api/engine/reflect/observations.py +186 -0
  43. hindsight_api/engine/reflect/prompts.py +483 -0
  44. hindsight_api/engine/reflect/tools.py +437 -0
  45. hindsight_api/engine/reflect/tools_schema.py +250 -0
  46. hindsight_api/engine/response_models.py +168 -4
  47. hindsight_api/engine/retain/bank_utils.py +79 -201
  48. hindsight_api/engine/retain/fact_extraction.py +424 -195
  49. hindsight_api/engine/retain/fact_storage.py +35 -12
  50. hindsight_api/engine/retain/link_utils.py +29 -24
  51. hindsight_api/engine/retain/orchestrator.py +24 -43
  52. hindsight_api/engine/retain/types.py +11 -2
  53. hindsight_api/engine/search/graph_retrieval.py +43 -14
  54. hindsight_api/engine/search/link_expansion_retrieval.py +391 -0
  55. hindsight_api/engine/search/mpfp_retrieval.py +362 -117
  56. hindsight_api/engine/search/reranking.py +2 -2
  57. hindsight_api/engine/search/retrieval.py +848 -201
  58. hindsight_api/engine/search/tags.py +172 -0
  59. hindsight_api/engine/search/think_utils.py +42 -141
  60. hindsight_api/engine/search/trace.py +12 -1
  61. hindsight_api/engine/search/tracer.py +26 -6
  62. hindsight_api/engine/search/types.py +21 -3
  63. hindsight_api/engine/task_backend.py +113 -106
  64. hindsight_api/engine/utils.py +1 -152
  65. hindsight_api/extensions/__init__.py +10 -1
  66. hindsight_api/extensions/builtin/tenant.py +5 -1
  67. hindsight_api/extensions/context.py +10 -1
  68. hindsight_api/extensions/operation_validator.py +81 -4
  69. hindsight_api/extensions/tenant.py +26 -0
  70. hindsight_api/main.py +69 -6
  71. hindsight_api/mcp_local.py +12 -53
  72. hindsight_api/mcp_tools.py +494 -0
  73. hindsight_api/metrics.py +433 -48
  74. hindsight_api/migrations.py +141 -1
  75. hindsight_api/models.py +3 -3
  76. hindsight_api/pg0.py +53 -0
  77. hindsight_api/server.py +39 -2
  78. hindsight_api/worker/__init__.py +11 -0
  79. hindsight_api/worker/main.py +296 -0
  80. hindsight_api/worker/poller.py +486 -0
  81. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +16 -6
  82. hindsight_api-0.4.0.dist-info/RECORD +112 -0
  83. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +2 -0
  84. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  85. hindsight_api/engine/search/observation_utils.py +0 -125
  86. hindsight_api/engine/search/scoring.py +0 -159
  87. hindsight_api-0.2.1.dist-info/RECORD +0 -75
  88. {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/WHEEL +0 -0
@@ -14,7 +14,9 @@ from typing import Literal
14
14
 
15
15
  from pydantic import BaseModel, ConfigDict, Field, field_validator
16
16
 
17
+ from ...config import get_config
17
18
  from ..llm_wrapper import LLMConfig, OutputTooLongError
19
+ from ..response_models import TokenUsage
18
20
 
19
21
 
20
22
  def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
@@ -109,22 +111,38 @@ class Fact(BaseModel):
109
111
 
110
112
 
111
113
  class CausalRelation(BaseModel):
112
- """Causal relationship between facts."""
114
+ """Causal relationship from this fact to a previous fact (stored format)."""
113
115
 
114
- target_fact_index: int = Field(
115
- description="Index of the related fact in the facts array (0-based). "
116
- "This creates a directed causal link to another fact in the extraction."
116
+ target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
117
+ relation_type: Literal["caused_by"] = Field(
118
+ description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
117
119
  )
118
- relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field(
119
- description="Type of causal relationship: "
120
- "'causes' = this fact directly causes the target fact, "
121
- "'caused_by' = this fact was caused by the target fact, "
122
- "'enables' = this fact enables/allows the target fact, "
123
- "'prevents' = this fact prevents/blocks the target fact"
120
+ strength: float = Field(
121
+ description="Strength of relationship (0.0 to 1.0)",
122
+ ge=0.0,
123
+ le=1.0,
124
+ default=1.0,
125
+ )
126
+
127
+
128
+ class FactCausalRelation(BaseModel):
129
+ """
130
+ Causal relationship from this fact to a PREVIOUS fact (embedded in each fact).
131
+
132
+ Uses index-based references but ONLY allows referencing facts that appear
133
+ BEFORE this fact in the list. This prevents hallucination of invalid indices.
134
+ """
135
+
136
+ target_index: int = Field(
137
+ description="Index of the PREVIOUS fact this relates to (0-based). "
138
+ "MUST be less than this fact's position in the list. "
139
+ "Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
140
+ )
141
+ relation_type: Literal["caused_by"] = Field(
142
+ description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact"
124
143
  )
125
144
  strength: float = Field(
126
- description="Strength of causal relationship (0.0 to 1.0). "
127
- "1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
145
+ description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
128
146
  ge=0.0,
129
147
  le=1.0,
130
148
  default=1.0,
@@ -132,16 +150,67 @@ class CausalRelation(BaseModel):
132
150
 
133
151
 
134
152
  class ExtractedFact(BaseModel):
135
- """A single extracted fact with 5 required dimensions for comprehensive capture."""
153
+ """A single extracted fact."""
136
154
 
137
155
  model_config = ConfigDict(
138
156
  json_schema_mode="validation",
139
157
  json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]},
140
158
  )
141
159
 
142
- # ==========================================================================
143
- # FIVE REQUIRED DIMENSIONS - LLM must think about each one
144
- # ==========================================================================
160
+ what: str = Field(description="Core fact - concise but complete (1-2 sentences)")
161
+ when: str = Field(description="When it happened. 'N/A' if unknown.")
162
+ where: str = Field(description="Location if relevant. 'N/A' if none.")
163
+ who: str = Field(description="People involved with relationships. 'N/A' if general.")
164
+ why: str = Field(description="Context/significance if important. 'N/A' if obvious.")
165
+
166
+ fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
167
+ occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
168
+ occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
169
+ fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
170
+ entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
171
+ causal_relations: list[FactCausalRelation] | None = Field(
172
+ default=None, description="Links to previous facts (target_index < this fact's index)"
173
+ )
174
+
175
+ @field_validator("entities", mode="before")
176
+ @classmethod
177
+ def ensure_entities_list(cls, v):
178
+ """Ensure entities is always a list (convert None to empty list)."""
179
+ if v is None:
180
+ return []
181
+ return v
182
+
183
+ def build_fact_text(self) -> str:
184
+ """Combine all dimensions into a single comprehensive fact string."""
185
+ parts = [self.what]
186
+
187
+ # Add 'who' if not N/A
188
+ if self.who and self.who.upper() != "N/A":
189
+ parts.append(f"Involving: {self.who}")
190
+
191
+ # Add 'why' if not N/A
192
+ if self.why and self.why.upper() != "N/A":
193
+ parts.append(self.why)
194
+
195
+ if len(parts) == 1:
196
+ return parts[0]
197
+
198
+ return " | ".join(parts)
199
+
200
+
201
+ class FactExtractionResponse(BaseModel):
202
+ """Response containing all extracted facts (causal relations are embedded in each fact)."""
203
+
204
+ facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
205
+
206
+
207
+ class ExtractedFactVerbose(BaseModel):
208
+ """A single extracted fact with verbose field descriptions for detailed extraction."""
209
+
210
+ model_config = ConfigDict(
211
+ json_schema_mode="validation",
212
+ json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]},
213
+ )
145
214
 
146
215
  what: str = Field(
147
216
  description="WHAT happened - COMPLETE, DETAILED description with ALL specifics. "
@@ -184,16 +253,11 @@ class ExtractedFact(BaseModel):
184
253
  "NOT: 'User liked it' or 'To help user'"
185
254
  )
186
255
 
187
- # ==========================================================================
188
- # CLASSIFICATION
189
- # ==========================================================================
190
-
191
256
  fact_kind: str = Field(
192
257
  default="conversation",
193
258
  description="'event' = specific datable occurrence (set occurred dates), 'conversation' = general info (no occurred dates)",
194
259
  )
195
260
 
196
- # Temporal fields - optional
197
261
  occurred_start: str | None = Field(
198
262
  default=None,
199
263
  description="WHEN the event happened (ISO timestamp). Only for fact_kind='event'. Leave null for conversations.",
@@ -203,59 +267,76 @@ class ExtractedFact(BaseModel):
203
267
  description="WHEN the event ended (ISO timestamp). Only for events with duration. Leave null for conversations.",
204
268
  )
205
269
 
206
- # Classification (CRITICAL - required)
207
- # Note: LLM uses "assistant" but we convert to "bank" for storage
208
270
  fact_type: Literal["world", "assistant"] = Field(
209
271
  description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant."
210
272
  )
211
273
 
212
- # Entities - extracted from fact content
213
274
  entities: list[Entity] | None = Field(
214
275
  default=None,
215
276
  description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.",
216
277
  )
217
- causal_relations: list[CausalRelation] | None = Field(
218
- default=None, description="Causal links to other facts. Can be null."
278
+
279
+ causal_relations: list[FactCausalRelation] | None = Field(
280
+ default=None,
281
+ description="Causal links to PREVIOUS facts only. target_index MUST be less than this fact's position. "
282
+ "Example: fact #3 can only reference facts 0, 1, or 2. Max 2 relations per fact.",
219
283
  )
220
284
 
221
285
  @field_validator("entities", mode="before")
222
286
  @classmethod
223
287
  def ensure_entities_list(cls, v):
224
- """Ensure entities is always a list (convert None to empty list)."""
225
288
  if v is None:
226
289
  return []
227
290
  return v
228
291
 
229
- @field_validator("causal_relations", mode="before")
230
- @classmethod
231
- def ensure_causal_relations_list(cls, v):
232
- """Ensure causal_relations is always a list (convert None to empty list)."""
233
- if v is None:
234
- return []
235
- return v
236
292
 
237
- def build_fact_text(self) -> str:
238
- """Combine all dimensions into a single comprehensive fact string."""
239
- parts = [self.what]
293
+ class FactExtractionResponseVerbose(BaseModel):
294
+ """Response for verbose fact extraction."""
240
295
 
241
- # Add 'who' if not N/A
242
- if self.who and self.who.upper() != "N/A":
243
- parts.append(f"Involving: {self.who}")
296
+ facts: list[ExtractedFactVerbose] = Field(description="List of extracted factual statements")
244
297
 
245
- # Add 'why' if not N/A
246
- if self.why and self.why.upper() != "N/A":
247
- parts.append(self.why)
248
298
 
249
- if len(parts) == 1:
250
- return parts[0]
299
+ class ExtractedFactNoCausal(BaseModel):
300
+ """A single extracted fact WITHOUT causal relations (for when causal extraction is disabled)."""
251
301
 
252
- return " | ".join(parts)
302
+ model_config = ConfigDict(
303
+ json_schema_mode="validation",
304
+ json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]},
305
+ )
253
306
 
307
+ # Same fields as ExtractedFact but without causal_relations
308
+ what: str = Field(description="WHAT happened - COMPLETE, DETAILED description with ALL specifics.")
309
+ when: str = Field(description="WHEN it happened - include temporal information if mentioned.")
310
+ where: str = Field(description="WHERE it happened - SPECIFIC locations if applicable.")
311
+ who: str = Field(description="WHO is involved - ALL people/entities with relationships.")
312
+ why: str = Field(description="WHY it matters - emotional, contextual, and motivational details.")
254
313
 
255
- class FactExtractionResponse(BaseModel):
256
- """Response containing all extracted facts."""
314
+ fact_kind: str = Field(
315
+ default="conversation",
316
+ description="'event' = specific datable occurrence, 'conversation' = general info",
317
+ )
318
+ occurred_start: str | None = Field(default=None, description="WHEN the event happened (ISO timestamp).")
319
+ occurred_end: str | None = Field(default=None, description="WHEN the event ended (ISO timestamp).")
320
+ fact_type: Literal["world", "assistant"] = Field(
321
+ description="'world' = about the user/others. 'assistant' = experience with assistant."
322
+ )
323
+ entities: list[Entity] | None = Field(
324
+ default=None,
325
+ description="Named entities, objects, and concepts from the fact.",
326
+ )
327
+
328
+ @field_validator("entities", mode="before")
329
+ @classmethod
330
+ def ensure_entities_list(cls, v):
331
+ if v is None:
332
+ return []
333
+ return v
257
334
 
258
- facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
335
+
336
+ class FactExtractionResponseNoCausal(BaseModel):
337
+ """Response for fact extraction without causal relations."""
338
+
339
+ facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
259
340
 
260
341
 
261
342
  def chunk_text(text: str, max_chars: int) -> list[str]:
@@ -347,39 +428,140 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
347
428
  return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
348
429
 
349
430
 
350
- async def _extract_facts_from_chunk(
351
- chunk: str,
352
- chunk_index: int,
353
- total_chunks: int,
354
- event_date: datetime,
355
- context: str,
356
- llm_config: "LLMConfig",
357
- agent_name: str = None,
358
- extract_opinions: bool = False,
359
- ) -> list[dict[str, str]]:
360
- """
361
- Extract facts from a single chunk (internal helper for parallel processing).
362
-
363
- Note: event_date parameter is kept for backward compatibility but not used in prompt.
364
- The LLM extracts temporal information from the context string instead.
365
- """
366
- memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
431
+ # =============================================================================
432
+ # FACT EXTRACTION PROMPTS
433
+ # =============================================================================
367
434
 
368
- # Determine which fact types to extract based on the flag
369
- # Note: We use "assistant" in the prompt but convert to "bank" for storage
370
- if extract_opinions:
371
- # Opinion extraction uses a separate prompt (not this one)
372
- fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
373
- else:
374
- fact_types_instruction = (
375
- "Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
376
- )
435
+ # Base prompt template (shared by concise and custom modes)
436
+ # Uses {extraction_guidelines} placeholder for mode-specific instructions
437
+ _BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
377
438
 
378
- prompt = f"""Extract facts from text into structured format with FOUR required dimensions - BE EXTREMELY DETAILED.
439
+ LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
379
440
 
380
441
  {fact_types_instruction}
381
442
 
443
+ {extraction_guidelines}
444
+
445
+ ══════════════════════════════════════════════════════════════════════════
446
+ FACT FORMAT - BE CONCISE
447
+ ══════════════════════════════════════════════════════════════════════════
448
+
449
+ 1. **what**: Core fact - concise but complete (1-2 sentences max)
450
+ 2. **when**: Temporal info if mentioned. "N/A" if none. Use day name when known.
451
+ 3. **where**: Location if relevant. "N/A" if none.
452
+ 4. **who**: People involved with relationships. "N/A" if just general info.
453
+ 5. **why**: Context/significance ONLY if important. "N/A" if obvious.
454
+
455
+ CONCISENESS: Capture the essence, not every word. One good sentence beats three mediocre ones.
456
+
457
+ ══════════════════════════════════════════════════════════════════════════
458
+ COREFERENCE RESOLUTION
459
+ ══════════════════════════════════════════════════════════════════════════
460
+
461
+ Link generic references to names when both appear:
462
+ - "my roommate" + "Emily" → use "Emily (user's roommate)"
463
+ - "the manager" + "Sarah" → use "Sarah (the manager)"
464
+
465
+ ══════════════════════════════════════════════════════════════════════════
466
+ CLASSIFICATION
467
+ ══════════════════════════════════════════════════════════════════════════
468
+
469
+ fact_kind:
470
+ - "event": Specific datable occurrence (set occurred_start/end)
471
+ - "conversation": Ongoing state, preference, trait (no dates)
472
+
473
+ fact_type:
474
+ - "world": About user's life, other people, external events
475
+ - "assistant": Interactions with assistant (requests, recommendations)
476
+
477
+ ══════════════════════════════════════════════════════════════════════════
478
+ TEMPORAL HANDLING
479
+ ══════════════════════════════════════════════════════════════════════════
480
+
481
+ Use "Event Date" from input as reference for relative dates.
482
+ - "yesterday" relative to Event Date, not today
483
+ - For events: set occurred_start AND occurred_end (same for point events)
484
+ - For conversation facts: NO occurred dates
485
+
486
+ ══════════════════════════════════════════════════════════════════════════
487
+ ENTITIES
488
+ ══════════════════════════════════════════════════════════════════════════
489
+
490
+ Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
491
+ Always include "user" when fact is about the user.{examples}"""
492
+
493
+ # Concise mode guidelines
494
+ _CONCISE_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
495
+ SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
496
+ ══════════════════════════════════════════════════════════════════════════
497
+
498
+ ONLY extract facts that are:
499
+ ✅ Personal info: names, relationships, roles, background
500
+ ✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
501
+ ✅ Significant events: milestones, decisions, achievements, changes
502
+ ✅ Plans/goals: future intentions, deadlines, commitments
503
+ ✅ Expertise: skills, knowledge, certifications, experience
504
+ ✅ Important context: projects, problems, constraints
505
+ ✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
506
+ ✅ Observations: descriptions of people, places, things with specific details
507
+
508
+ DO NOT extract:
509
+ ❌ Generic greetings: "how are you", "hello", pleasantries without substance
510
+ ❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
511
+ ❌ Process chatter: "let me check", "one moment", "I'll look into it"
512
+ ❌ Repeated info: if already stated, don't extract again
382
513
 
514
+ CONSOLIDATE related statements into ONE fact when possible."""
515
+
516
+ # Concise mode examples
517
+ _CONCISE_EXAMPLES = """
518
+
519
+ ══════════════════════════════════════════════════════════════════════════
520
+ EXAMPLES
521
+ ══════════════════════════════════════════════════════════════════════════
522
+
523
+ Example 1 - Selective extraction (Event Date: June 10, 2024):
524
+ Input: "Hey! How's it going? Good morning! So I'm planning my wedding - want a small outdoor ceremony. Just got back from Emily's wedding, she married Sarah at a rooftop garden. It was nice weather. I grabbed a coffee on the way."
525
+
526
+ Output: ONLY 2 facts (skip greetings, weather, coffee):
527
+ 1. what="User planning wedding, wants small outdoor ceremony", who="user", why="N/A", entities=["user", "wedding"]
528
+ 2. what="Emily married Sarah at rooftop garden", who="Emily (user's friend), Sarah", occurred_start="2024-06-09", entities=["Emily", "Sarah", "wedding"]
529
+
530
+ Example 2 - Professional context:
531
+ Input: "Alice has 5 years of Kubernetes experience and holds CKA certification. She's been leading the infrastructure team since March. By the way, she prefers dark roast coffee."
532
+
533
+ Output: ONLY 2 facts (skip coffee preference - too trivial):
534
+ 1. what="Alice has 5 years Kubernetes experience, CKA certified", who="Alice", entities=["Alice", "Kubernetes", "CKA"]
535
+ 2. what="Alice leads infrastructure team since March", who="Alice", entities=["Alice", "infrastructure"]
536
+
537
+ ══════════════════════════════════════════════════════════════════════════
538
+ QUALITY OVER QUANTITY
539
+ ══════════════════════════════════════════════════════════════════════════
540
+
541
+ Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
542
+
543
+ # Assembled concise prompt (backward compatible - exact same output as before)
544
+ CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
545
+ fact_types_instruction="{fact_types_instruction}",
546
+ extraction_guidelines=_CONCISE_GUIDELINES,
547
+ examples=_CONCISE_EXAMPLES,
548
+ )
549
+
550
+ # Custom prompt uses same base but without examples
551
+ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
552
+ fact_types_instruction="{fact_types_instruction}",
553
+ extraction_guidelines="{custom_instructions}",
554
+ examples="", # No examples for custom mode
555
+ )
556
+
557
+
558
+ # Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
559
+ VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
560
+
561
+ LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions,
562
+ and other output MUST be in the SAME language as the input. Do not translate to English if the input is in another language.
563
+
564
+ {fact_types_instruction}
383
565
 
384
566
  ══════════════════════════════════════════════════════════════════════════
385
567
  FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
@@ -473,113 +655,109 @@ FACT TYPE
473
655
  Include: what the user asked, what problem they wanted solved, what context they provided
474
656
 
475
657
  ══════════════════════════════════════════════════════════════════════════
476
- USER PREFERENCES (CRITICAL)
658
+ ENTITIES - EXTRACT EVERYTHING
477
659
  ══════════════════════════════════════════════════════════════════════════
478
660
 
479
- ALWAYS extract user preferences as separate facts! Watch for these keywords:
480
- - "enjoy", "like", "love", "prefer", "hate", "dislike", "favorite", "ideal", "dream", "want"
661
+ Extract ALL of the following from the fact:
662
+ - People names (Emily, Alice, Dr. Smith)
663
+ - Organizations (Google, MIT, local coffee shop)
664
+ - Places (San Francisco, Brooklyn, Paris)
665
+ - Significant objects mentioned (coffee maker, new car, wedding dress)
666
+ - Abstract concepts/themes (friendship, career growth, loss, celebration)
481
667
 
482
- Example: "I love Italian food and prefer outdoor dining"
483
- Fact 1: what="User loves Italian food", who="user", why="This is a food preference", entities=["user"]
484
- → Fact 2: what="User prefers outdoor dining", who="user", why="This is a dining preference", entities=["user"]
668
+ ALWAYS include "user" when fact is about the user.
669
+ Extract anything that could help link related facts together."""
485
670
 
486
- ══════════════════════════════════════════════════════════════════════════
487
- ENTITIES - INCLUDE PEOPLE, PLACES, OBJECTS, AND CONCEPTS (CRITICAL)
488
- ══════════════════════════════════════════════════════════════════════════
489
671
 
490
- Extract entities that help link related facts together. Include:
491
- 1. "user" - when the fact is about the user
492
- 2. People names - Emily, Dr. Smith, etc.
493
- 3. Organizations/Places - IKEA, Goodwill, New York, etc.
494
- 4. Specific objects - coffee maker, toaster, car, laptop, kitchen, etc.
495
- 5. Abstract concepts - themes, values, emotions, or ideas that capture the essence of the fact:
496
- - "friendship" for facts about friends helping each other, bonding, loyalty
497
- - "career growth" for facts about promotions, learning new skills, job changes
498
- - "loss" or "grief" for facts about death, endings, saying goodbye
499
- - "celebration" for facts about parties, achievements, milestones
500
- - "trust" or "betrayal" for facts involving those themes
501
-
502
- ✅ CORRECT: entities=["user", "coffee maker", "Goodwill", "kitchen"] for "User donated their coffee maker to Goodwill"
503
- ✅ CORRECT: entities=["user", "Emily", "friendship"] for "Emily helped user move to a new apartment"
504
- ✅ CORRECT: entities=["user", "promotion", "career growth"] for "User got promoted to senior engineer"
505
- ✅ CORRECT: entities=["user", "grandmother", "loss", "grief"] for "User's grandmother passed away last week"
506
- ❌ WRONG: entities=["user", "Emily"] only - missing the "friendship" concept that links to other friendship facts!
672
+ # Causal relationships section - appended when causal extraction is enabled
673
+ CAUSAL_RELATIONSHIPS_SECTION = """
507
674
 
508
675
  ══════════════════════════════════════════════════════════════════════════
509
- EXAMPLES
676
+ CAUSAL RELATIONSHIPS
510
677
  ══════════════════════════════════════════════════════════════════════════
511
678
 
512
- Example 1 - World Facts (Event Date: Tuesday, June 10, 2024):
513
- Input: "I'm planning my wedding and want a small outdoor ceremony. I just got back from my college roommate Emily's wedding - she married Sarah at a rooftop garden, it was so romantic!"
514
-
515
- Output facts:
516
-
517
- 1. User's wedding preference
518
- - what: "User wants a small outdoor ceremony for their wedding"
519
- - who: "user"
520
- - why: "User prefers intimate outdoor settings"
521
- - fact_type: "world", fact_kind: "conversation"
522
- - entities: ["user", "wedding", "outdoor ceremony"]
523
-
524
- 2. User planning wedding
525
- - what: "User is planning their own wedding"
526
- - who: "user"
527
- - why: "Inspired by Emily's ceremony"
528
- - fact_type: "world", fact_kind: "conversation"
529
- - entities: ["user", "wedding"]
530
-
531
- 3. Emily's wedding (THE EVENT - note occurred_start AND occurred_end both set)
532
- - what: "Emily got married to Sarah at a rooftop garden ceremony in the city"
533
- - who: "Emily (user's college roommate), Sarah (Emily's partner)"
534
- - why: "User found it romantic and beautiful"
535
- - fact_type: "world", fact_kind: "event"
536
- - occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back" - relative to Event Date June 10, 2024)
537
- - occurred_end: "2024-06-09T23:59:59Z" (same day - point event)
538
- - entities: ["user", "Emily", "Sarah", "wedding", "rooftop garden"]
539
-
540
- Example 2 - Assistant Facts (Context: March 5, 2024):
541
- Input: "User: My API is really slow when we have 1000+ concurrent users. What can I do?
542
- Assistant: I'd recommend implementing Redis for caching frequently-accessed data, which should reduce your database load by 70-80%."
543
-
544
- Output fact:
545
- - what: "Assistant recommended implementing Redis for caching frequently-accessed data to improve API performance"
546
- - when: "March 5, 2024 during conversation"
547
- - who: "user, assistant"
548
- - why: "User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load"
549
- - fact_type: "assistant", fact_kind: "conversation"
550
- - entities: ["user", "API", "Redis"]
551
-
552
- Example 3 - Kitchen Items with Concept Inference (Event Date: Thursday, May 30, 2024):
553
- Input: "I finally donated my old coffee maker to Goodwill. I upgraded to that new espresso machine last month and the old one was just taking up counter space."
554
-
555
- Output fact:
556
- - what: "User donated their old coffee maker to Goodwill after upgrading to a new espresso machine"
557
- - when: "Thursday, May 30, 2024"
558
- - who: "user"
559
- - why: "The old coffee maker was taking up counter space after the upgrade"
560
- - fact_type: "world", fact_kind: "event"
561
- - occurred_start: "2024-05-30T00:00:00Z" (uses Event Date year)
562
- - occurred_end: "2024-05-30T23:59:59Z" (same day - point event)
563
- - entities: ["user", "coffee maker", "Goodwill", "espresso machine", "kitchen"]
564
-
565
- Note: "kitchen" is inferred as a concept because coffee makers and espresso machines are kitchen appliances.
566
- This links the fact to other kitchen-related facts (toaster, faucet, kitchen mat, etc.) via the shared "kitchen" entity.
567
-
568
- Note how the "why" field captures the FULL STORY: what the user asked AND what outcome was expected!
679
+ Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
680
+ Type: "caused_by" (this fact was caused by the target fact)
681
+
682
+ Example: "Lost job → couldn't pay rent → moved apartment"
683
+ - Fact 0: Lost job, causal_relations: null
684
+ - Fact 1: Couldn't pay rent, causal_relations: [{target_index: 0, relation_type: "caused_by"}]
685
+ - Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
569
686
 
570
- ══════════════════════════════════════════════════════════════════════════
571
- WHAT TO EXTRACT vs SKIP
572
- ══════════════════════════════════════════════════════════════════════════
573
687
 
574
- EXTRACT: User preferences (ALWAYS as separate facts!), feelings, plans, events, relationships, achievements
575
- ❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements"""
688
+ async def _extract_facts_from_chunk(
689
+ chunk: str,
690
+ chunk_index: int,
691
+ total_chunks: int,
692
+ event_date: datetime,
693
+ context: str,
694
+ llm_config: "LLMConfig",
695
+ agent_name: str = None,
696
+ extract_opinions: bool = False,
697
+ ) -> tuple[list[dict[str, str]], TokenUsage]:
698
+ """
699
+ Extract facts from a single chunk (internal helper for parallel processing).
576
700
 
701
+ Note: event_date parameter is kept for backward compatibility but not used in prompt.
702
+ The LLM extracts temporal information from the context string instead.
703
+ """
577
704
  import logging
578
705
 
579
706
  from openai import BadRequestError
580
707
 
581
708
  logger = logging.getLogger(__name__)
582
709
 
710
+ memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
711
+
712
+ # Determine which fact types to extract based on the flag
713
+ # Note: We use "assistant" in the prompt but convert to "bank" for storage
714
+ if extract_opinions:
715
+ # Opinion extraction uses a separate prompt (not this one)
716
+ fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
717
+ else:
718
+ fact_types_instruction = (
719
+ "Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
720
+ )
721
+
722
+ # Check config for extraction mode and causal link extraction
723
+ config = get_config()
724
+ extraction_mode = config.retain_extraction_mode
725
+ extract_causal_links = config.retain_extract_causal_links
726
+
727
+ # Select base prompt based on extraction mode
728
+ if extraction_mode == "custom":
729
+ # Custom mode: inject user-provided guidelines
730
+ if not config.retain_custom_instructions:
731
+ logger.warning(
732
+ "extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
733
+ "Falling back to 'concise' mode."
734
+ )
735
+ base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
736
+ prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
737
+ else:
738
+ base_prompt = CUSTOM_FACT_EXTRACTION_PROMPT
739
+ prompt = base_prompt.format(
740
+ fact_types_instruction=fact_types_instruction,
741
+ custom_instructions=config.retain_custom_instructions,
742
+ )
743
+ elif extraction_mode == "verbose":
744
+ base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
745
+ prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
746
+ else:
747
+ base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
748
+ prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
749
+
750
+ # Build the full prompt with or without causal relationships section
751
+ # Select appropriate response schema based on extraction mode and causal links
752
+ if extract_causal_links:
753
+ prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
754
+ if extraction_mode == "verbose":
755
+ response_schema = FactExtractionResponseVerbose
756
+ else:
757
+ response_schema = FactExtractionResponse
758
+ else:
759
+ response_schema = FactExtractionResponseNoCausal
760
+
583
761
  # Retry logic for JSON validation errors
584
762
  max_retries = 2
585
763
  last_error = None
@@ -601,16 +779,19 @@ Context: {sanitized_context}
601
779
  Text:
602
780
  {sanitized_chunk}"""
603
781
 
782
+ usage = TokenUsage() # Track cumulative usage across retries
604
783
  for attempt in range(max_retries):
605
784
  try:
606
- extraction_response_json = await llm_config.call(
785
+ extraction_response_json, call_usage = await llm_config.call(
607
786
  messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
608
- response_format=FactExtractionResponse,
787
+ response_format=response_schema,
609
788
  scope="memory_extract_facts",
610
789
  temperature=0.1,
611
- max_completion_tokens=65000,
790
+ max_completion_tokens=config.retain_max_completion_tokens,
612
791
  skip_validation=True, # Get raw JSON, we'll validate leniently
792
+ return_usage=True,
613
793
  )
794
+ usage = usage + call_usage # Aggregate usage across retries
614
795
 
615
796
  # Lenient parsing of facts from raw JSON
616
797
  chunk_facts = []
@@ -628,9 +809,10 @@ Text:
628
809
  f"LLM returned non-dict JSON after {max_retries} attempts: {type(extraction_response_json).__name__}. "
629
810
  f"Raw: {str(extraction_response_json)[:500]}"
630
811
  )
631
- return []
812
+ return [], usage
632
813
 
633
814
  raw_facts = extraction_response_json.get("facts", [])
815
+
634
816
  if not raw_facts:
635
817
  logger.debug(
636
818
  f"LLM response missing 'facts' field or returned empty list. "
@@ -670,7 +852,8 @@ Text:
670
852
 
671
853
  # Critical field: fact_type
672
854
  # LLM uses "assistant" but we convert to "experience" for storage
673
- fact_type = llm_fact.get("fact_type")
855
+ original_fact_type = llm_fact.get("fact_type")
856
+ fact_type = original_fact_type
674
857
 
675
858
  # Convert "assistant" → "experience" for storage
676
859
  if fact_type == "assistant":
@@ -687,7 +870,10 @@ Text:
687
870
  else:
688
871
  # Default to 'world' if we can't determine
689
872
  fact_type = "world"
690
- logger.warning(f"Fact {i}: defaulting to fact_type='world'")
873
+ logger.warning(
874
+ f"Fact {i}: defaulting to fact_type='world' "
875
+ f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
876
+ )
691
877
 
692
878
  # Get fact_kind for temporal handling (but don't store it)
693
879
  fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -745,17 +931,40 @@ Text:
745
931
  if validated_entities:
746
932
  fact_data["entities"] = validated_entities
747
933
 
748
- # Add causal relations if present (validate as CausalRelation objects)
749
- # Filter out invalid relations (missing required fields)
750
- causal_relations = get_value("causal_relations")
751
- if causal_relations:
934
+ # Add per-fact causal relations (only if enabled in config)
935
+ if extract_causal_links:
752
936
  validated_relations = []
753
- for rel in causal_relations:
754
- if isinstance(rel, dict) and "target_fact_index" in rel and "relation_type" in rel:
937
+ causal_relations_raw = get_value("causal_relations")
938
+ if causal_relations_raw:
939
+ for rel in causal_relations_raw:
940
+ if not isinstance(rel, dict):
941
+ continue
942
+ # New schema uses target_index
943
+ target_idx = rel.get("target_index")
944
+ relation_type = rel.get("relation_type")
945
+ strength = rel.get("strength", 1.0)
946
+
947
+ if target_idx is None or relation_type is None:
948
+ continue
949
+
950
+ # Validate: target_index must be < current fact index
951
+ if target_idx < 0 or target_idx >= i:
952
+ logger.debug(
953
+ f"Invalid target_index {target_idx} for fact {i} (must be 0 to {i - 1}). Skipping."
954
+ )
955
+ continue
956
+
755
957
  try:
756
- validated_relations.append(CausalRelation.model_validate(rel))
958
+ validated_relations.append(
959
+ CausalRelation(
960
+ target_fact_index=target_idx,
961
+ relation_type=relation_type,
962
+ strength=strength,
963
+ )
964
+ )
757
965
  except Exception as e:
758
- logger.warning(f"Invalid causal relation {rel}: {e}")
966
+ logger.debug(f"Invalid causal relation {rel}: {e}")
967
+
759
968
  if validated_relations:
760
969
  fact_data["causal_relations"] = validated_relations
761
970
 
@@ -778,7 +987,7 @@ Text:
778
987
  )
779
988
  continue
780
989
 
781
- return chunk_facts
990
+ return chunk_facts, usage
782
991
 
783
992
  except BadRequestError as e:
784
993
  last_error = e
@@ -805,7 +1014,7 @@ async def _extract_facts_with_auto_split(
805
1014
  llm_config: LLMConfig,
806
1015
  agent_name: str = None,
807
1016
  extract_opinions: bool = False,
808
- ) -> list[dict[str, str]]:
1017
+ ) -> tuple[list[dict[str, str]], TokenUsage]:
809
1018
  """
810
1019
  Extract facts from a chunk with automatic splitting if output exceeds token limits.
811
1020
 
@@ -823,7 +1032,7 @@ async def _extract_facts_with_auto_split(
823
1032
  extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
824
1033
 
825
1034
  Returns:
826
- List of fact dictionaries extracted from the chunk (possibly from sub-chunks)
1035
+ Tuple of (facts list, token usage) extracted from the chunk (possibly from sub-chunks)
827
1036
  """
828
1037
  import logging
829
1038
 
@@ -902,12 +1111,14 @@ async def _extract_facts_with_auto_split(
902
1111
 
903
1112
  # Combine results from both halves
904
1113
  all_facts = []
905
- for sub_result in sub_results:
906
- all_facts.extend(sub_result)
1114
+ total_usage = TokenUsage()
1115
+ for sub_facts, sub_usage in sub_results:
1116
+ all_facts.extend(sub_facts)
1117
+ total_usage = total_usage + sub_usage
907
1118
 
908
1119
  logger.info(f"Successfully extracted {len(all_facts)} facts from split chunk {chunk_index + 1}")
909
1120
 
910
- return all_facts
1121
+ return all_facts, total_usage
911
1122
 
912
1123
 
913
1124
  async def extract_facts_from_text(
@@ -917,7 +1128,7 @@ async def extract_facts_from_text(
917
1128
  agent_name: str,
918
1129
  context: str = "",
919
1130
  extract_opinions: bool = False,
920
- ) -> tuple[list[Fact], list[tuple[str, int]]]:
1131
+ ) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
921
1132
  """
922
1133
  Extract semantic facts from conversational or narrative text using LLM.
923
1134
 
@@ -936,11 +1147,22 @@ async def extract_facts_from_text(
936
1147
  extract_opinions: If True, extract ONLY opinions. If False, extract world and bank facts (no opinions)
937
1148
 
938
1149
  Returns:
939
- Tuple of (facts, chunks) where:
1150
+ Tuple of (facts, chunks, usage) where:
940
1151
  - facts: List of Fact model instances
941
1152
  - chunks: List of tuples (chunk_text, fact_count) for each chunk
1153
+ - usage: Aggregated token usage across all LLM calls
942
1154
  """
943
- chunks = chunk_text(text, max_chars=3000)
1155
+ config = get_config()
1156
+ chunks = chunk_text(text, max_chars=config.retain_chunk_size)
1157
+
1158
+ # Log chunk count before starting LLM requests
1159
+ total_chars = sum(len(c) for c in chunks)
1160
+ if len(chunks) > 1:
1161
+ logger.debug(
1162
+ f"[FACT_EXTRACTION] Text chunked into {len(chunks)} chunks ({total_chars:,} chars total, "
1163
+ f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
1164
+ )
1165
+
944
1166
  tasks = [
945
1167
  _extract_facts_with_auto_split(
946
1168
  chunk=chunk,
@@ -957,10 +1179,12 @@ async def extract_facts_from_text(
957
1179
  chunk_results = await asyncio.gather(*tasks)
958
1180
  all_facts = []
959
1181
  chunk_metadata = [] # [(chunk_text, fact_count), ...]
960
- for chunk, chunk_facts in zip(chunks, chunk_results):
1182
+ total_usage = TokenUsage()
1183
+ for chunk, (chunk_facts, chunk_usage) in zip(chunks, chunk_results):
961
1184
  all_facts.extend(chunk_facts)
962
1185
  chunk_metadata.append((chunk, len(chunk_facts)))
963
- return all_facts, chunk_metadata
1186
+ total_usage = total_usage + chunk_usage
1187
+ return all_facts, chunk_metadata, total_usage
964
1188
 
965
1189
 
966
1190
  # ============================================================================
@@ -981,7 +1205,7 @@ SECONDS_PER_FACT = 10
981
1205
 
982
1206
  async def extract_facts_from_contents(
983
1207
  contents: list[RetainContent], llm_config, agent_name: str, extract_opinions: bool = False
984
- ) -> tuple[list[ExtractedFactType], list[ChunkMetadata]]:
1208
+ ) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
985
1209
  """
986
1210
  Extract facts from multiple content items in parallel.
987
1211
 
@@ -998,10 +1222,10 @@ async def extract_facts_from_contents(
998
1222
  extract_opinions: If True, extract only opinions; otherwise world/bank facts
999
1223
 
1000
1224
  Returns:
1001
- Tuple of (extracted_facts, chunks_metadata)
1225
+ Tuple of (extracted_facts, chunks_metadata, usage)
1002
1226
  """
1003
1227
  if not contents:
1004
- return [], []
1228
+ return [], [], TokenUsage()
1005
1229
 
1006
1230
  # Step 1: Create parallel fact extraction tasks
1007
1231
  fact_extraction_tasks = []
@@ -1024,11 +1248,15 @@ async def extract_facts_from_contents(
1024
1248
  # Step 3: Flatten and convert to typed objects
1025
1249
  extracted_facts: list[ExtractedFactType] = []
1026
1250
  chunks_metadata: list[ChunkMetadata] = []
1251
+ total_usage = TokenUsage()
1027
1252
 
1028
1253
  global_chunk_idx = 0
1029
1254
  global_fact_idx = 0
1030
1255
 
1031
- for content_index, (content, (facts_from_llm, chunks_from_llm)) in enumerate(zip(contents, all_fact_results)):
1256
+ for content_index, (content, (facts_from_llm, chunks_from_llm, content_usage)) in enumerate(
1257
+ zip(contents, all_fact_results)
1258
+ ):
1259
+ total_usage = total_usage + content_usage
1032
1260
  chunk_start_idx = global_chunk_idx
1033
1261
 
1034
1262
  # Convert chunk tuples to ChunkMetadata objects
@@ -1073,6 +1301,7 @@ async def extract_facts_from_contents(
1073
1301
  # mentioned_at: always the event_date (when the conversation/document occurred)
1074
1302
  mentioned_at=content.event_date,
1075
1303
  metadata=content.metadata,
1304
+ tags=content.tags,
1076
1305
  )
1077
1306
 
1078
1307
  extracted_facts.append(extracted_fact)
@@ -1082,7 +1311,7 @@ async def extract_facts_from_contents(
1082
1311
  # Step 4: Add time offsets to preserve ordering within each content
1083
1312
  _add_temporal_offsets(extracted_facts, contents)
1084
1313
 
1085
- return extracted_facts, chunks_metadata
1314
+ return extracted_facts, chunks_metadata, total_usage
1086
1315
 
1087
1316
 
1088
1317
  def _parse_datetime(date_str: str):