@@ -111,52 +111,44 @@ class Fact(BaseModel):
111111
112112
113113class 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
287275class 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
299281def 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
646617Example (Event Date: March 15, 2024):
647618Input: "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 ,
0 commit comments