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.
- hindsight_api/admin/__init__.py +1 -0
- hindsight_api/admin/cli.py +311 -0
- hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
- hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
- hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
- hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
- hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
- hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
- hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
- hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
- hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
- hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
- hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
- hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
- hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
- hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
- hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
- hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
- hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
- hindsight_api/api/http.py +1406 -118
- hindsight_api/api/mcp.py +11 -196
- hindsight_api/config.py +359 -27
- hindsight_api/engine/consolidation/__init__.py +5 -0
- hindsight_api/engine/consolidation/consolidator.py +859 -0
- hindsight_api/engine/consolidation/prompts.py +69 -0
- hindsight_api/engine/cross_encoder.py +706 -88
- hindsight_api/engine/db_budget.py +284 -0
- hindsight_api/engine/db_utils.py +11 -0
- hindsight_api/engine/directives/__init__.py +5 -0
- hindsight_api/engine/directives/models.py +37 -0
- hindsight_api/engine/embeddings.py +553 -29
- hindsight_api/engine/entity_resolver.py +8 -5
- hindsight_api/engine/interface.py +40 -17
- hindsight_api/engine/llm_wrapper.py +744 -68
- hindsight_api/engine/memory_engine.py +2505 -1017
- hindsight_api/engine/mental_models/__init__.py +14 -0
- hindsight_api/engine/mental_models/models.py +53 -0
- hindsight_api/engine/query_analyzer.py +4 -3
- hindsight_api/engine/reflect/__init__.py +18 -0
- hindsight_api/engine/reflect/agent.py +933 -0
- hindsight_api/engine/reflect/models.py +109 -0
- hindsight_api/engine/reflect/observations.py +186 -0
- hindsight_api/engine/reflect/prompts.py +483 -0
- hindsight_api/engine/reflect/tools.py +437 -0
- hindsight_api/engine/reflect/tools_schema.py +250 -0
- hindsight_api/engine/response_models.py +168 -4
- hindsight_api/engine/retain/bank_utils.py +79 -201
- hindsight_api/engine/retain/fact_extraction.py +424 -195
- hindsight_api/engine/retain/fact_storage.py +35 -12
- hindsight_api/engine/retain/link_utils.py +29 -24
- hindsight_api/engine/retain/orchestrator.py +24 -43
- hindsight_api/engine/retain/types.py +11 -2
- hindsight_api/engine/search/graph_retrieval.py +43 -14
- hindsight_api/engine/search/link_expansion_retrieval.py +391 -0
- hindsight_api/engine/search/mpfp_retrieval.py +362 -117
- hindsight_api/engine/search/reranking.py +2 -2
- hindsight_api/engine/search/retrieval.py +848 -201
- hindsight_api/engine/search/tags.py +172 -0
- hindsight_api/engine/search/think_utils.py +42 -141
- hindsight_api/engine/search/trace.py +12 -1
- hindsight_api/engine/search/tracer.py +26 -6
- hindsight_api/engine/search/types.py +21 -3
- hindsight_api/engine/task_backend.py +113 -106
- hindsight_api/engine/utils.py +1 -152
- hindsight_api/extensions/__init__.py +10 -1
- hindsight_api/extensions/builtin/tenant.py +5 -1
- hindsight_api/extensions/context.py +10 -1
- hindsight_api/extensions/operation_validator.py +81 -4
- hindsight_api/extensions/tenant.py +26 -0
- hindsight_api/main.py +69 -6
- hindsight_api/mcp_local.py +12 -53
- hindsight_api/mcp_tools.py +494 -0
- hindsight_api/metrics.py +433 -48
- hindsight_api/migrations.py +141 -1
- hindsight_api/models.py +3 -3
- hindsight_api/pg0.py +53 -0
- hindsight_api/server.py +39 -2
- hindsight_api/worker/__init__.py +11 -0
- hindsight_api/worker/main.py +296 -0
- hindsight_api/worker/poller.py +486 -0
- {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +16 -6
- hindsight_api-0.4.0.dist-info/RECORD +112 -0
- {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +2 -0
- hindsight_api/engine/retain/observation_regeneration.py +0 -254
- hindsight_api/engine/search/observation_utils.py +0 -125
- hindsight_api/engine/search/scoring.py +0 -159
- hindsight_api-0.2.1.dist-info/RECORD +0 -75
- {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
|
|
114
|
+
"""Causal relationship from this fact to a previous fact (stored format)."""
|
|
113
115
|
|
|
114
|
-
target_fact_index: int = Field(
|
|
115
|
-
|
|
116
|
-
"
|
|
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
|
-
|
|
119
|
-
description="
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
parts = [self.what]
|
|
293
|
+
class FactExtractionResponseVerbose(BaseModel):
|
|
294
|
+
"""Response for verbose fact extraction."""
|
|
240
295
|
|
|
241
|
-
|
|
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
|
-
|
|
250
|
-
|
|
299
|
+
class ExtractedFactNoCausal(BaseModel):
|
|
300
|
+
"""A single extracted fact WITHOUT causal relations (for when causal extraction is disabled)."""
|
|
251
301
|
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
+
ENTITIES - EXTRACT EVERYTHING
|
|
477
659
|
══════════════════════════════════════════════════════════════════════════
|
|
478
660
|
|
|
479
|
-
|
|
480
|
-
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
676
|
+
CAUSAL RELATIONSHIPS
|
|
510
677
|
══════════════════════════════════════════════════════════════════════════
|
|
511
678
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
1
|
|
518
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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=
|
|
787
|
+
response_format=response_schema,
|
|
609
788
|
scope="memory_extract_facts",
|
|
610
789
|
temperature=0.1,
|
|
611
|
-
max_completion_tokens=
|
|
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
|
-
|
|
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(
|
|
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
|
|
749
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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):
|