Skip to content

Commit 49e233c

Browse files
authored
fix: duplicated causal relationships and token optimization (#126)
* fix: duplicated causal relationships and token optimization * doc * doc
1 parent e6709d5 commit 49e233c

6 files changed

Lines changed: 347 additions & 198 deletions

File tree

hindsight-api/hindsight_api/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464

6565
# Retain settings
6666
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
67+
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
6768

6869
# Optimization flags
6970
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
@@ -103,6 +104,7 @@
103104

104105
# Retain settings
105106
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
107+
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
106108

107109
# Database migrations
108110
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -182,6 +184,7 @@ class HindsightConfig:
182184

183185
# Retain settings
184186
retain_max_completion_tokens: int
187+
retain_chunk_size: int
185188

186189
# Optimization flags
187190
skip_llm_verification: bool
@@ -239,6 +242,7 @@ def from_env(cls) -> "HindsightConfig":
239242
retain_max_completion_tokens=int(
240243
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
241244
),
245+
retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))),
242246
# Database migrations
243247
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
244248
)

hindsight-api/hindsight_api/engine/retain/fact_extraction.py

Lines changed: 91 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -111,52 +111,44 @@ class Fact(BaseModel):
111111

112112

113113
class CausalRelation(BaseModel):
114-
"""Causal relationship between facts (legacy - embedded in each fact)."""
115-
116-
target_fact_index: int = Field(
117-
description="Index of the related fact in the facts array (0-based). "
118-
"This creates a directed causal link to another fact in the extraction."
119-
)
120-
relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field(
121-
description="Type of causal relationship: "
122-
"'causes' = this fact directly causes the target fact, "
123-
"'caused_by' = this fact was caused by the target fact, "
124-
"'enables' = this fact enables/allows the target fact, "
125-
"'prevents' = this fact prevents/blocks the target fact"
114+
"""Causal relationship from this fact to a previous fact (stored format)."""
115+
116+
target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
117+
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
118+
description="How this fact relates to the target: "
119+
"'caused_by' = this fact was caused by the target, "
120+
"'enabled_by' = this fact was enabled by the target, "
121+
"'prevented_by' = this fact was prevented by the target"
126122
)
127123
strength: float = Field(
128-
description="Strength of causal relationship (0.0 to 1.0). "
129-
"1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
124+
description="Strength of relationship (0.0 to 1.0)",
130125
ge=0.0,
131126
le=1.0,
132127
default=1.0,
133128
)
134129

135130

136-
class TopLevelCausalRelation(BaseModel):
131+
class FactCausalRelation(BaseModel):
137132
"""
138-
Causal relationship between two facts (top-level schema).
133+
Causal relationship from this fact to a PREVIOUS fact (embedded in each fact).
139134
140-
This is the preferred format - defined AFTER all facts are extracted,
141-
allowing the LLM to see the full list of facts before specifying relationships.
135+
Uses index-based references but ONLY allows referencing facts that appear
136+
BEFORE this fact in the list. This prevents hallucination of invalid indices.
142137
"""
143138

144-
from_fact_index: int = Field(
145-
description="Index of the source fact (0-based). The fact that causes/enables/prevents."
146-
)
147-
to_fact_index: int = Field(
148-
description="Index of the target fact (0-based). The fact that is caused/enabled/prevented."
139+
target_index: int = Field(
140+
description="Index of the PREVIOUS fact this relates to (0-based). "
141+
"MUST be less than this fact's position in the list. "
142+
"Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
149143
)
150-
relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field(
151-
description="Type of causal relationship: "
152-
"'causes' = source fact directly causes the target fact, "
153-
"'caused_by' = source fact was caused by the target fact, "
154-
"'enables' = source fact enables/allows the target fact, "
155-
"'prevents' = source fact prevents/blocks the target fact"
144+
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
145+
description="How this fact relates to the target fact: "
146+
"'caused_by' = this fact was caused by the target fact, "
147+
"'enabled_by' = this fact was enabled by the target fact, "
148+
"'prevented_by' = this fact was blocked/prevented by the target fact"
156149
)
157150
strength: float = Field(
158-
description="Strength of causal relationship (0.0 to 1.0). "
159-
"1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
151+
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
160152
ge=0.0,
161153
le=1.0,
162154
default=1.0,
@@ -246,8 +238,12 @@ class ExtractedFact(BaseModel):
246238
default=None,
247239
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.",
248240
)
249-
causal_relations: list[CausalRelation] | None = Field(
250-
default=None, description="Causal links to other facts. Can be null."
241+
242+
# Causal relations to PREVIOUS facts only (prevents hallucination of invalid indices)
243+
causal_relations: list[FactCausalRelation] | None = Field(
244+
default=None,
245+
description="Causal links to PREVIOUS facts only. target_index MUST be less than this fact's position. "
246+
"Example: fact #3 can only reference facts 0, 1, or 2. Max 2 relations per fact.",
251247
)
252248

253249
@field_validator("entities", mode="before")
@@ -258,14 +254,6 @@ def ensure_entities_list(cls, v):
258254
return []
259255
return v
260256

261-
@field_validator("causal_relations", mode="before")
262-
@classmethod
263-
def ensure_causal_relations_list(cls, v):
264-
"""Ensure causal_relations is always a list (convert None to empty list)."""
265-
if v is None:
266-
return []
267-
return v
268-
269257
def build_fact_text(self) -> str:
270258
"""Combine all dimensions into a single comprehensive fact string."""
271259
parts = [self.what]
@@ -285,15 +273,9 @@ def build_fact_text(self) -> str:
285273

286274

287275
class FactExtractionResponse(BaseModel):
288-
"""Response containing all extracted facts and their causal relationships."""
276+
"""Response containing all extracted facts (causal relations are embedded in each fact)."""
289277

290278
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
291-
causal_relationships: list[TopLevelCausalRelation] | None = Field(
292-
default=None,
293-
description="Causal relationships between facts. Define these AFTER listing all facts. "
294-
"Each relationship specifies from_fact_index -> to_fact_index with a relation type. "
295-
"Indices must be valid (0 to N-1 where N is the number of facts).",
296-
)
297279

298280

299281
def chunk_text(text: str, max_chars: int) -> list[str]:
@@ -616,50 +598,49 @@ async def _extract_facts_from_chunk(
616598
❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements
617599
618600
══════════════════════════════════════════════════════════════════════════
619-
CAUSAL RELATIONSHIPS (CRITICAL - DEFINE AFTER ALL FACTS)
601+
CAUSAL RELATIONSHIPS (EMBEDDED IN EACH FACT - REFERENCE PREVIOUS FACTS ONLY)
620602
══════════════════════════════════════════════════════════════════════════
621603
622-
⚠️ IMPORTANT: Causal relationships are defined at the TOP LEVEL, AFTER listing all facts!
623-
624-
The `causal_relationships` array goes at the root of your response (NOT inside each fact).
625-
This allows you to see all facts first before defining how they relate.
604+
Each fact can have a `causal_relations` array that links to PREVIOUS facts only.
605+
⚠️ CRITICAL: target_index MUST be less than this fact's position in the list!
626606
627-
Format:
628-
```json
629-
{{
630-
"facts": [...all your extracted facts...],
631-
"causal_relationships": [
632-
{{"from_fact_index": 0, "to_fact_index": 1, "relation_type": "causes", "strength": 0.9}},
633-
{{"from_fact_index": 1, "to_fact_index": 2, "relation_type": "enables", "strength": 0.7}}
634-
]
635-
}}
636-
```
607+
If you're writing fact #5, you can only reference facts 0, 1, 2, 3, or 4.
608+
This ensures all references are valid.
637609
638-
Relationship types:
639-
- "causes": Fact A directly causes Fact B (A → B)
640-
- "caused_by": Fact A was caused by Fact B (A ← B)
641-
- "enables": Fact A enables/allows Fact B to happen
642-
- "prevents": Fact A prevents/blocks Fact B from happening
610+
Relationship types (all describe how THIS fact relates to the target):
611+
- "caused_by": This fact was caused by the target fact
612+
- "enabled_by": This fact was enabled/allowed by the target fact
613+
- "prevented_by": This fact was blocked/prevented by the target fact
643614
644-
⚠️ INDEX VALIDATION: If you extract N facts (indices 0 to N-1), both from_fact_index and to_fact_index MUST be in range [0, N-1].
615+
Max 2 causal relations per fact. Only add if there's a clear causal link.
645616
646617
Example (Event Date: March 15, 2024):
647618
Input: "I lost my job in January. Because of that, I couldn't pay rent. So I had to move to a cheaper apartment."
648619
649-
Facts extracted:
650-
- Fact 0: "User lost their job in January due to layoffs"
651-
- Fact 1: "User couldn't pay rent because of job loss"
652-
- Fact 2: "User moved to a cheaper apartment"
653-
654-
Causal relationships (at root level):
620+
Output facts:
655621
```json
656-
"causal_relationships": [
657-
{{"from_fact_index": 0, "to_fact_index": 1, "relation_type": "causes", "strength": 1.0}},
658-
{{"from_fact_index": 1, "to_fact_index": 2, "relation_type": "causes", "strength": 0.9}}
659-
]
622+
{{
623+
"facts": [
624+
{{
625+
"what": "User lost their job in January due to company layoffs",
626+
...other fields...
627+
"causal_relations": null // First fact - nothing to reference
628+
}},
629+
{{
630+
"what": "User couldn't pay rent because of job loss",
631+
...other fields...
632+
"causal_relations": [{{"target_index": 0, "relation_type": "caused_by", "strength": 1.0}}]
633+
}},
634+
{{
635+
"what": "User moved to a cheaper apartment",
636+
...other fields...
637+
"causal_relations": [{{"target_index": 1, "relation_type": "caused_by", "strength": 0.9}}]
638+
}}
639+
]
640+
}}
660641
```
661642
662-
This creates a chain: Job loss (0) Can't pay rent (1) Moved to cheaper apartment (2)"""
643+
This creates: Job loss (0) Can't pay rent (1) Moved apartment (2)"""
663644

664645
import logging
665646

@@ -722,8 +703,6 @@ async def _extract_facts_from_chunk(
722703
return [], usage
723704

724705
raw_facts = extraction_response_json.get("facts", [])
725-
# Get top-level causal relationships (new schema)
726-
top_level_causal_relations = extraction_response_json.get("causal_relationships", [])
727706

728707
if not raw_facts:
729708
logger.debug(
@@ -735,47 +714,6 @@ async def _extract_facts_from_chunk(
735714
f"text: {chunk}"
736715
)
737716

738-
# Build a map from fact index to causal relations (from top-level field)
739-
# This converts from_fact_index -> [{target_fact_index, relation_type, strength}]
740-
causal_relations_by_fact: dict[int, list[dict]] = {}
741-
if top_level_causal_relations:
742-
num_facts = len(raw_facts)
743-
for rel in top_level_causal_relations:
744-
if not isinstance(rel, dict):
745-
continue
746-
from_idx = rel.get("from_fact_index")
747-
to_idx = rel.get("to_fact_index")
748-
relation_type = rel.get("relation_type")
749-
strength = rel.get("strength", 1.0)
750-
751-
# Validate indices
752-
if from_idx is None or to_idx is None or relation_type is None:
753-
logger.warning(f"Skipping malformed top-level causal relation: {rel}")
754-
continue
755-
if from_idx < 0 or from_idx >= num_facts:
756-
logger.warning(
757-
f"Invalid from_fact_index {from_idx} in top-level causal relation "
758-
f"(valid range: 0-{num_facts - 1}). Skipping."
759-
)
760-
continue
761-
if to_idx < 0 or to_idx >= num_facts:
762-
logger.warning(
763-
f"Invalid to_fact_index {to_idx} in top-level causal relation "
764-
f"(valid range: 0-{num_facts - 1}). Skipping."
765-
)
766-
continue
767-
768-
# Add to the map for the from_fact_index
769-
if from_idx not in causal_relations_by_fact:
770-
causal_relations_by_fact[from_idx] = []
771-
causal_relations_by_fact[from_idx].append(
772-
{
773-
"target_fact_index": to_idx,
774-
"relation_type": relation_type,
775-
"strength": strength,
776-
}
777-
)
778-
779717
for i, llm_fact in enumerate(raw_facts):
780718
# Skip non-dict entries but track them for retry
781719
if not isinstance(llm_fact, dict):
@@ -880,37 +818,38 @@ def get_value(field_name):
880818
if validated_entities:
881819
fact_data["entities"] = validated_entities
882820

883-
# Add causal relations from both sources:
884-
# 1. Top-level causal_relationships (preferred, new schema)
885-
# 2. Per-fact causal_relations (legacy, for backward compatibility)
821+
# Add per-fact causal relations (new schema: target_index must be < current fact index)
886822
validated_relations = []
823+
causal_relations_raw = get_value("causal_relations")
824+
if causal_relations_raw:
825+
for rel in causal_relations_raw:
826+
if not isinstance(rel, dict):
827+
continue
828+
# New schema uses target_index
829+
target_idx = rel.get("target_index")
830+
relation_type = rel.get("relation_type")
831+
strength = rel.get("strength", 1.0)
832+
833+
if target_idx is None or relation_type is None:
834+
continue
835+
836+
# Validate: target_index must be < current fact index
837+
if target_idx < 0 or target_idx >= i:
838+
logger.debug(
839+
f"Invalid target_index {target_idx} for fact {i} (must be 0 to {i - 1}). Skipping."
840+
)
841+
continue
887842

888-
# First, add relations from top-level (already validated above)
889-
if i in causal_relations_by_fact:
890-
for rel in causal_relations_by_fact[i]:
891843
try:
892-
validated_relations.append(CausalRelation.model_validate(rel))
893-
except Exception as e:
894-
logger.warning(f"Invalid top-level causal relation for fact {i}: {rel}: {e}")
895-
896-
# Then, add any legacy per-fact relations (with index validation)
897-
legacy_causal_relations = get_value("causal_relations")
898-
if legacy_causal_relations:
899-
num_facts = len(raw_facts)
900-
for rel in legacy_causal_relations:
901-
if isinstance(rel, dict) and "target_fact_index" in rel and "relation_type" in rel:
902-
target_idx = rel.get("target_fact_index")
903-
# Validate target index for legacy format too
904-
if target_idx is not None and 0 <= target_idx < num_facts:
905-
try:
906-
validated_relations.append(CausalRelation.model_validate(rel))
907-
except Exception as e:
908-
logger.warning(f"Invalid causal relation {rel}: {e}")
909-
else:
910-
logger.warning(
911-
f"Invalid target_fact_index {target_idx} in per-fact causal relation "
912-
f"from fact {i} (valid range: 0-{num_facts - 1}). Skipping."
844+
validated_relations.append(
845+
CausalRelation(
846+
target_fact_index=target_idx,
847+
relation_type=relation_type,
848+
strength=strength,
913849
)
850+
)
851+
except Exception as e:
852+
logger.debug(f"Invalid causal relation {rel}: {e}")
914853

915854
if validated_relations:
916855
fact_data["causal_relations"] = validated_relations
@@ -1099,7 +1038,8 @@ async def extract_facts_from_text(
10991038
- chunks: List of tuples (chunk_text, fact_count) for each chunk
11001039
- usage: Aggregated token usage across all LLM calls
11011040
"""
1102-
chunks = chunk_text(text, max_chars=3000)
1041+
config = get_config()
1042+
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
11031043
tasks = [
11041044
_extract_facts_with_auto_split(
11051045
chunk=chunk,

hindsight-api/hindsight_api/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ def release_lock():
193193
observation_min_facts=config.observation_min_facts,
194194
observation_top_entities=config.observation_top_entities,
195195
retain_max_completion_tokens=config.retain_max_completion_tokens,
196+
retain_chunk_size=config.retain_chunk_size,
196197
skip_llm_verification=config.skip_llm_verification,
197198
lazy_reranker=config.lazy_reranker,
198199
run_migrations_on_startup=config.run_migrations_on_startup,

0 commit comments

Comments
 (0)