hindsight-api 0.3.0__py3-none-any.whl → 0.4.1__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 (75) hide show
  1. hindsight_api/__init__.py +1 -1
  2. hindsight_api/admin/cli.py +59 -0
  3. hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
  4. hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
  5. hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
  6. hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
  7. hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
  8. hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
  9. hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
  10. hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
  11. hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
  12. hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
  13. hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
  14. hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
  15. hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
  16. hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
  17. hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
  18. hindsight_api/api/http.py +1120 -93
  19. hindsight_api/api/mcp.py +11 -191
  20. hindsight_api/config.py +174 -46
  21. hindsight_api/engine/consolidation/__init__.py +5 -0
  22. hindsight_api/engine/consolidation/consolidator.py +926 -0
  23. hindsight_api/engine/consolidation/prompts.py +77 -0
  24. hindsight_api/engine/cross_encoder.py +153 -22
  25. hindsight_api/engine/directives/__init__.py +5 -0
  26. hindsight_api/engine/directives/models.py +37 -0
  27. hindsight_api/engine/embeddings.py +136 -13
  28. hindsight_api/engine/interface.py +32 -13
  29. hindsight_api/engine/llm_wrapper.py +505 -43
  30. hindsight_api/engine/memory_engine.py +2101 -1094
  31. hindsight_api/engine/mental_models/__init__.py +14 -0
  32. hindsight_api/engine/mental_models/models.py +53 -0
  33. hindsight_api/engine/reflect/__init__.py +18 -0
  34. hindsight_api/engine/reflect/agent.py +933 -0
  35. hindsight_api/engine/reflect/models.py +109 -0
  36. hindsight_api/engine/reflect/observations.py +186 -0
  37. hindsight_api/engine/reflect/prompts.py +483 -0
  38. hindsight_api/engine/reflect/tools.py +437 -0
  39. hindsight_api/engine/reflect/tools_schema.py +250 -0
  40. hindsight_api/engine/response_models.py +130 -4
  41. hindsight_api/engine/retain/bank_utils.py +79 -201
  42. hindsight_api/engine/retain/fact_extraction.py +81 -48
  43. hindsight_api/engine/retain/fact_storage.py +5 -8
  44. hindsight_api/engine/retain/link_utils.py +5 -8
  45. hindsight_api/engine/retain/orchestrator.py +1 -55
  46. hindsight_api/engine/retain/types.py +2 -2
  47. hindsight_api/engine/search/graph_retrieval.py +2 -2
  48. hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
  49. hindsight_api/engine/search/mpfp_retrieval.py +1 -1
  50. hindsight_api/engine/search/retrieval.py +14 -14
  51. hindsight_api/engine/search/think_utils.py +41 -140
  52. hindsight_api/engine/search/trace.py +0 -1
  53. hindsight_api/engine/search/tracer.py +2 -5
  54. hindsight_api/engine/search/types.py +0 -3
  55. hindsight_api/engine/task_backend.py +112 -196
  56. hindsight_api/engine/utils.py +0 -151
  57. hindsight_api/extensions/__init__.py +10 -1
  58. hindsight_api/extensions/builtin/tenant.py +11 -4
  59. hindsight_api/extensions/operation_validator.py +81 -4
  60. hindsight_api/extensions/tenant.py +26 -0
  61. hindsight_api/main.py +28 -5
  62. hindsight_api/mcp_local.py +12 -53
  63. hindsight_api/mcp_tools.py +494 -0
  64. hindsight_api/models.py +0 -2
  65. hindsight_api/worker/__init__.py +11 -0
  66. hindsight_api/worker/main.py +296 -0
  67. hindsight_api/worker/poller.py +486 -0
  68. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/METADATA +12 -6
  69. hindsight_api-0.4.1.dist-info/RECORD +112 -0
  70. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/entry_points.txt +1 -0
  71. hindsight_api/engine/retain/observation_regeneration.py +0 -254
  72. hindsight_api/engine/search/observation_utils.py +0 -125
  73. hindsight_api/engine/search/scoring.py +0 -159
  74. hindsight_api-0.3.0.dist-info/RECORD +0 -82
  75. {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.1.dist-info}/WHEEL +0 -0
@@ -114,11 +114,8 @@ class CausalRelation(BaseModel):
114
114
  """Causal relationship from this fact to a previous fact (stored format)."""
115
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", "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"
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"
122
119
  )
123
120
  strength: float = Field(
124
121
  description="Strength of relationship (0.0 to 1.0)",
@@ -141,11 +138,8 @@ class FactCausalRelation(BaseModel):
141
138
  "MUST be less than this fact's position in the list. "
142
139
  "Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
143
140
  )
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"
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"
149
143
  )
150
144
  strength: float = Field(
151
145
  description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
@@ -438,34 +432,15 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
438
432
  # FACT EXTRACTION PROMPTS
439
433
  # =============================================================================
440
434
 
441
- # Concise extraction prompt (default) - selective, high-quality facts
442
- CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
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.
443
438
 
444
- LANGUAGE RULE (CRITICAL): Output facts in the EXACT SAME language as the input text. If input is Japanese, output Japanese. If input is Chinese, output Chinese. NEVER translate to English. Preserve original language completely.
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.
445
440
 
446
441
  {fact_types_instruction}
447
442
 
448
- ══════════════════════════════════════════════════════════════════════════
449
- SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
450
- ══════════════════════════════════════════════════════════════════════════
451
-
452
- ONLY extract facts that are:
453
- ✅ Personal info: names, relationships, roles, background
454
- ✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
455
- ✅ Significant events: milestones, decisions, achievements, changes
456
- ✅ Plans/goals: future intentions, deadlines, commitments
457
- ✅ Expertise: skills, knowledge, certifications, experience
458
- ✅ Important context: projects, problems, constraints
459
- ✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
460
- ✅ Observations: descriptions of people, places, things with specific details
461
-
462
- DO NOT extract:
463
- ❌ Generic greetings: "how are you", "hello", pleasantries without substance
464
- ❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
465
- ❌ Process chatter: "let me check", "one moment", "I'll look into it"
466
- ❌ Repeated info: if already stated, don't extract again
467
-
468
- CONSOLIDATE related statements into ONE fact when possible.
443
+ {extraction_guidelines}
469
444
 
470
445
  ══════════════════════════════════════════════════════════════════════════
471
446
  FACT FORMAT - BE CONCISE
@@ -513,7 +488,33 @@ ENTITIES
513
488
  ══════════════════════════════════════════════════════════════════════════
514
489
 
515
490
  Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
516
- Always include "user" when fact is about the user.
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
513
+
514
+ CONSOLIDATE related statements into ONE fact when possible."""
515
+
516
+ # Concise mode examples
517
+ _CONCISE_EXAMPLES = """
517
518
 
518
519
  ══════════════════════════════════════════════════════════════════════════
519
520
  EXAMPLES
@@ -539,6 +540,20 @@ QUALITY OVER QUANTITY
539
540
 
540
541
  Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
541
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
+
542
557
 
543
558
  # Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
544
559
  VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -662,7 +677,7 @@ CAUSAL RELATIONSHIPS
662
677
  ══════════════════════════════════════════════════════════════════════════
663
678
 
664
679
  Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
665
- Types: "caused_by", "enabled_by", "prevented_by"
680
+ Type: "caused_by" (this fact was caused by the target fact)
666
681
 
667
682
  Example: "Lost job → couldn't pay rent → moved apartment"
668
683
  - Fact 0: Lost job, causal_relations: null
@@ -686,6 +701,12 @@ async def _extract_facts_from_chunk(
686
701
  Note: event_date parameter is kept for backward compatibility but not used in prompt.
687
702
  The LLM extracts temporal information from the context string instead.
688
703
  """
704
+ import logging
705
+
706
+ from openai import BadRequestError
707
+
708
+ logger = logging.getLogger(__name__)
709
+
689
710
  memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
690
711
 
691
712
  # Determine which fact types to extract based on the flag
@@ -704,13 +725,27 @@ async def _extract_facts_from_chunk(
704
725
  extract_causal_links = config.retain_extract_causal_links
705
726
 
706
727
  # Select base prompt based on extraction mode
707
- if extraction_mode == "verbose":
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":
708
744
  base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
745
+ prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
709
746
  else:
710
747
  base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
711
-
712
- # Format the prompt with fact types instruction
713
- prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
748
+ prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
714
749
 
715
750
  # Build the full prompt with or without causal relationships section
716
751
  # Select appropriate response schema based on extraction mode and causal links
@@ -723,12 +758,6 @@ async def _extract_facts_from_chunk(
723
758
  else:
724
759
  response_schema = FactExtractionResponseNoCausal
725
760
 
726
- import logging
727
-
728
- from openai import BadRequestError
729
-
730
- logger = logging.getLogger(__name__)
731
-
732
761
  # Retry logic for JSON validation errors
733
762
  max_retries = 2
734
763
  last_error = None
@@ -823,7 +852,8 @@ Text:
823
852
 
824
853
  # Critical field: fact_type
825
854
  # LLM uses "assistant" but we convert to "experience" for storage
826
- fact_type = llm_fact.get("fact_type")
855
+ original_fact_type = llm_fact.get("fact_type")
856
+ fact_type = original_fact_type
827
857
 
828
858
  # Convert "assistant" → "experience" for storage
829
859
  if fact_type == "assistant":
@@ -840,7 +870,10 @@ Text:
840
870
  else:
841
871
  # Default to 'world' if we can't determine
842
872
  fact_type = "world"
843
- 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
+ )
844
877
 
845
878
  # Get fact_kind for temporal handling (but don't store it)
846
879
  fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -41,7 +41,6 @@ async def insert_facts_batch(
41
41
  contexts = []
42
42
  fact_types = []
43
43
  confidence_scores = []
44
- access_counts = []
45
44
  metadata_jsons = []
46
45
  chunk_ids = []
47
46
  document_ids = []
@@ -61,7 +60,6 @@ async def insert_facts_batch(
61
60
  fact_types.append(fact.fact_type)
62
61
  # confidence_score is only for opinion facts
63
62
  confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
64
- access_counts.append(0) # Initial access count
65
63
  metadata_jsons.append(json.dumps(fact.metadata))
66
64
  chunk_ids.append(fact.chunk_id)
67
65
  # Use per-fact document_id if available, otherwise fallback to batch-level document_id
@@ -76,16 +74,16 @@ async def insert_facts_batch(
76
74
  WITH input_data AS (
77
75
  SELECT * FROM unnest(
78
76
  $2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
79
- $8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[], $15::jsonb[]
77
+ $8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
80
78
  ) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
81
- context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags_json)
79
+ context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
82
80
  )
83
81
  INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
84
- context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags)
82
+ context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
85
83
  SELECT
86
84
  $1,
87
85
  text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
88
- context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id,
86
+ context, fact_type, confidence_score, metadata, chunk_id, document_id,
89
87
  COALESCE(
90
88
  (SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
91
89
  '{{}}'::varchar[]
@@ -103,7 +101,6 @@ async def insert_facts_batch(
103
101
  contexts,
104
102
  fact_types,
105
103
  confidence_scores,
106
- access_counts,
107
104
  metadata_jsons,
108
105
  chunk_ids,
109
106
  document_ids,
@@ -126,7 +123,7 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
126
123
  """
127
124
  await conn.execute(
128
125
  f"""
129
- INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
126
+ INSERT INTO {fq_table("banks")} (bank_id, disposition, mission)
130
127
  VALUES ($1, $2::jsonb, $3)
131
128
  ON CONFLICT (bank_id) DO UPDATE
132
129
  SET updated_at = NOW()
@@ -754,17 +754,14 @@ async def create_causal_links_batch(
754
754
  causal_relations_per_fact: List of causal relations for each fact.
755
755
  Each element is a list of dicts with:
756
756
  - target_fact_index: Index into unit_ids for the target fact
757
- - relation_type: "causes", "caused_by", "enables", or "prevents"
757
+ - relation_type: "caused_by"
758
758
  - strength: Float in [0.0, 1.0] representing relationship strength
759
759
 
760
760
  Returns:
761
761
  Number of causal links created
762
762
 
763
- Causal link types:
764
- - "causes": This fact directly causes the target fact (forward causation)
765
- - "caused_by": This fact was caused by the target fact (backward causation)
766
- - "enables": This fact enables/allows the target fact (enablement)
767
- - "prevents": This fact prevents/blocks the target fact (prevention)
763
+ Causal link type:
764
+ - "caused_by": This fact was caused by the target fact
768
765
  """
769
766
  if not unit_ids or not causal_relations_per_fact:
770
767
  return 0
@@ -787,8 +784,8 @@ async def create_causal_links_batch(
787
784
  relation_type = relation["relation_type"]
788
785
  strength = relation.get("strength", 1.0)
789
786
 
790
- # Validate relation_type - must match database constraint
791
- valid_types = {"causes", "caused_by", "enables", "prevents"}
787
+ # Validate relation_type - only "caused_by" is supported (DB constraint)
788
+ valid_types = {"caused_by"}
792
789
  if relation_type not in valid_types:
793
790
  logger.error(
794
791
  f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -9,7 +9,6 @@ import time
9
9
  import uuid
10
10
  from datetime import UTC, datetime
11
11
 
12
- from ...config import get_config
13
12
  from ..db_utils import acquire_with_retry
14
13
  from . import bank_utils
15
14
 
@@ -28,9 +27,8 @@ from . import (
28
27
  fact_extraction,
29
28
  fact_storage,
30
29
  link_creation,
31
- observation_regeneration,
32
30
  )
33
- from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
31
+ from .types import EntityLink, ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
34
32
 
35
33
  logger = logging.getLogger(__name__)
36
34
 
@@ -40,7 +38,6 @@ async def retain_batch(
40
38
  embeddings_model,
41
39
  llm_config,
42
40
  entity_resolver,
43
- task_backend,
44
41
  format_date_fn,
45
42
  duplicate_checker_fn,
46
43
  bank_id: str,
@@ -59,7 +56,6 @@ async def retain_batch(
59
56
  embeddings_model: Embeddings model for generating embeddings
60
57
  llm_config: LLM configuration for fact extraction
61
58
  entity_resolver: Entity resolver for entity processing
62
- task_backend: Task backend for background jobs
63
59
  format_date_fn: Function to format datetime to readable string
64
60
  duplicate_checker_fn: Function to check for duplicate facts
65
61
  bank_id: Bank identifier
@@ -408,27 +404,9 @@ async def retain_batch(
408
404
  causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
409
405
  log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
410
406
 
411
- # Regenerate observations - sync (in transaction) or async (background task)
412
- config = get_config()
413
- if config.retain_observations_async:
414
- # Queue for async processing after transaction commits
415
- entity_ids_for_async = list(set(link.entity_id for link in entity_links)) if entity_links else []
416
- log_buffer.append(
417
- f"[11] Observations: queued {len(entity_ids_for_async)} entities for async processing"
418
- )
419
- else:
420
- # Run synchronously inside transaction for atomicity
421
- await observation_regeneration.regenerate_observations_batch(
422
- conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
423
- )
424
- entity_ids_for_async = []
425
-
426
407
  # Map results back to original content items
427
408
  result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
428
409
 
429
- # Trigger background tasks AFTER transaction commits
430
- await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts, entity_ids_for_async)
431
-
432
410
  # Log final summary
433
411
  total_time = time.time() - start_time
434
412
  log_buffer.append(f"{'=' * 60}")
@@ -470,35 +448,3 @@ def _map_results_to_contents(
470
448
  result_unit_ids.append(content_unit_ids)
471
449
 
472
450
  return result_unit_ids
473
-
474
-
475
- async def _trigger_background_tasks(
476
- task_backend,
477
- bank_id: str,
478
- unit_ids: list[str],
479
- facts: list[ProcessedFact],
480
- entity_ids_for_observations: list[str] | None = None,
481
- ) -> None:
482
- """Trigger background tasks after transaction commits."""
483
- # Trigger opinion reinforcement if there are entities
484
- fact_entities = [[e.name for e in fact.entities] for fact in facts]
485
- if any(fact_entities):
486
- await task_backend.submit_task(
487
- {
488
- "type": "reinforce_opinion",
489
- "bank_id": bank_id,
490
- "created_unit_ids": unit_ids,
491
- "unit_texts": [fact.fact_text for fact in facts],
492
- "unit_entities": fact_entities,
493
- }
494
- )
495
-
496
- # Trigger observation regeneration if async mode is enabled
497
- if entity_ids_for_observations:
498
- await task_backend.submit_task(
499
- {
500
- "type": "regenerate_observations",
501
- "bank_id": bank_id,
502
- "entity_ids": entity_ids_for_observations,
503
- }
504
- )
@@ -86,10 +86,10 @@ class CausalRelation:
86
86
  """
87
87
  Causal relationship between facts.
88
88
 
89
- Represents how one fact causes, enables, or prevents another.
89
+ Represents how one fact was caused by another.
90
90
  """
91
91
 
92
- relation_type: str # "causes", "enables", "prevents", "caused_by"
92
+ relation_type: str # "caused_by"
93
93
  target_fact_index: int # Index of the target fact in the batch
94
94
  strength: float = 1.0 # Strength of the causal relationship
95
95
 
@@ -162,7 +162,7 @@ class BFSGraphRetriever(GraphRetriever):
162
162
  entry_points = await conn.fetch(
163
163
  f"""
164
164
  SELECT id, text, context, event_date, occurred_start, occurred_end,
165
- mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
165
+ mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
166
166
  1 - (embedding <=> $1::vector) AS similarity
167
167
  FROM {fq_table("memory_units")}
168
168
  WHERE bank_id = $2
@@ -216,7 +216,7 @@ class BFSGraphRetriever(GraphRetriever):
216
216
  neighbors = await conn.fetch(
217
217
  f"""
218
218
  SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
219
- mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
219
+ mu.mentioned_at, mu.embedding, mu.fact_type,
220
220
  mu.document_id, mu.chunk_id, mu.tags,
221
221
  ml.weight, ml.link_type, ml.from_unit_id
222
222
  FROM {fq_table("memory_links")} ml
@@ -45,7 +45,7 @@ async def _find_semantic_seeds(
45
45
  rows = await conn.fetch(
46
46
  f"""
47
47
  SELECT id, text, context, event_date, occurred_start, occurred_end,
48
- mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
48
+ mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
49
49
  1 - (embedding <=> $1::vector) AS similarity
50
50
  FROM {fq_table("memory_units")}
51
51
  WHERE bank_id = $2
@@ -155,7 +155,6 @@ class LinkExpansionRetriever(GraphRetriever):
155
155
  all_seeds.extend(temporal_seeds)
156
156
 
157
157
  if not all_seeds:
158
- logger.debug("[LinkExpansion] No seeds found, returning empty results")
159
158
  return [], timings
160
159
 
161
160
  seed_ids = list({s.id for s in all_seeds})
@@ -164,36 +163,108 @@ class LinkExpansionRetriever(GraphRetriever):
164
163
  # Run entity and causal expansion sequentially on same connection
165
164
  query_start = time.time()
166
165
 
167
- entity_rows = await conn.fetch(
168
- f"""
169
- SELECT
170
- mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
171
- mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
172
- mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
173
- COUNT(*)::float AS score
174
- FROM {fq_table("unit_entities")} seed_ue
175
- JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
176
- JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
177
- JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
178
- WHERE seed_ue.unit_id = ANY($1::uuid[])
179
- AND e.mention_count < $2
180
- AND mu.id != ALL($1::uuid[])
181
- AND mu.fact_type = $3
182
- GROUP BY mu.id
183
- ORDER BY score DESC
184
- LIMIT $4
185
- """,
186
- seed_ids,
187
- self.max_entity_frequency,
188
- fact_type,
189
- budget,
190
- )
166
+ # For observations, traverse through source_memory_ids to find entity connections.
167
+ # Observations don't have direct unit_entities - they inherit entities via their
168
+ # source world/experience facts.
169
+ #
170
+ # Path: observation → source_memory_ids → world fact → entities →
171
+ # ALL world facts with those entities → their observations (excluding seeds)
172
+ if fact_type == "observation":
173
+ # Debug: Check what source_memory_ids exist on seed observations
174
+ debug_sources = await conn.fetch(
175
+ f"""
176
+ SELECT id, source_memory_ids
177
+ FROM {fq_table("memory_units")}
178
+ WHERE id = ANY($1::uuid[])
179
+ """,
180
+ seed_ids,
181
+ )
182
+ source_ids_found = []
183
+ for row in debug_sources:
184
+ if row["source_memory_ids"]:
185
+ source_ids_found.extend(row["source_memory_ids"])
186
+ logger.debug(
187
+ f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
188
+ f"{len(source_ids_found)} source_memory_ids found"
189
+ )
190
+
191
+ entity_rows = await conn.fetch(
192
+ f"""
193
+ WITH seed_sources AS (
194
+ -- Get source memory IDs from seed observations
195
+ SELECT DISTINCT unnest(source_memory_ids) AS source_id
196
+ FROM {fq_table("memory_units")}
197
+ WHERE id = ANY($1::uuid[])
198
+ AND source_memory_ids IS NOT NULL
199
+ ),
200
+ source_entities AS (
201
+ -- Get entities from those source memories (filtered by frequency)
202
+ SELECT DISTINCT ue.entity_id
203
+ FROM seed_sources ss
204
+ JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
205
+ JOIN {fq_table("entities")} e ON ue.entity_id = e.id
206
+ WHERE e.mention_count < $2
207
+ ),
208
+ all_connected_sources AS (
209
+ -- Find ALL world facts sharing those entities (don't exclude seed sources)
210
+ -- The exclusion happens at the observation level, not the source level
211
+ SELECT DISTINCT other_ue.unit_id AS source_id
212
+ FROM source_entities se
213
+ JOIN {fq_table("unit_entities")} other_ue ON se.entity_id = other_ue.entity_id
214
+ )
215
+ -- Find observations derived from connected source memories
216
+ -- Only exclude the actual seed observations
217
+ SELECT
218
+ mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
219
+ mu.occurred_end, mu.mentioned_at, mu.embedding,
220
+ mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
221
+ COUNT(DISTINCT cs.source_id)::float AS score
222
+ FROM all_connected_sources cs
223
+ JOIN {fq_table("memory_units")} mu
224
+ ON mu.source_memory_ids @> ARRAY[cs.source_id]
225
+ WHERE mu.fact_type = 'observation'
226
+ AND mu.id != ALL($1::uuid[])
227
+ GROUP BY mu.id
228
+ ORDER BY score DESC
229
+ LIMIT $3
230
+ """,
231
+ seed_ids,
232
+ self.max_entity_frequency,
233
+ budget,
234
+ )
235
+ logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
236
+ else:
237
+ # For world/experience facts, use direct entity lookup
238
+ entity_rows = await conn.fetch(
239
+ f"""
240
+ SELECT
241
+ mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
242
+ mu.occurred_end, mu.mentioned_at, mu.embedding,
243
+ mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
244
+ COUNT(*)::float AS score
245
+ FROM {fq_table("unit_entities")} seed_ue
246
+ JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
247
+ JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
248
+ JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
249
+ WHERE seed_ue.unit_id = ANY($1::uuid[])
250
+ AND e.mention_count < $2
251
+ AND mu.id != ALL($1::uuid[])
252
+ AND mu.fact_type = $3
253
+ GROUP BY mu.id
254
+ ORDER BY score DESC
255
+ LIMIT $4
256
+ """,
257
+ seed_ids,
258
+ self.max_entity_frequency,
259
+ fact_type,
260
+ budget,
261
+ )
191
262
 
192
263
  causal_rows = await conn.fetch(
193
264
  f"""
194
265
  SELECT DISTINCT ON (mu.id)
195
266
  mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
196
- mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
267
+ mu.occurred_end, mu.mentioned_at, mu.embedding,
197
268
  mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
198
269
  ml.weight + 1.0 AS score
199
270
  FROM {fq_table("memory_links")} ml
@@ -211,11 +282,69 @@ class LinkExpansionRetriever(GraphRetriever):
211
282
  budget,
212
283
  )
213
284
 
285
+ # Fallback: semantic/temporal/entity links from memory_links table
286
+ # These are secondary to entity links (via unit_entities) and causal links
287
+ # Weight is halved (0.5x) to prioritize primary link types
288
+ # Check both directions: seeds -> others AND others -> seeds
289
+ fallback_rows = await conn.fetch(
290
+ f"""
291
+ WITH outgoing AS (
292
+ -- Links FROM seeds TO other facts
293
+ SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
294
+ mu.occurred_end, mu.mentioned_at, mu.embedding,
295
+ mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
296
+ ml.weight
297
+ FROM {fq_table("memory_links")} ml
298
+ JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
299
+ WHERE ml.from_unit_id = ANY($1::uuid[])
300
+ AND ml.link_type IN ('semantic', 'temporal', 'entity')
301
+ AND ml.weight >= $2
302
+ AND mu.fact_type = $3
303
+ AND mu.id != ALL($1::uuid[])
304
+ ),
305
+ incoming AS (
306
+ -- Links FROM other facts TO seeds (reverse direction)
307
+ SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
308
+ mu.occurred_end, mu.mentioned_at, mu.embedding,
309
+ mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
310
+ ml.weight
311
+ FROM {fq_table("memory_links")} ml
312
+ JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
313
+ WHERE ml.to_unit_id = ANY($1::uuid[])
314
+ AND ml.link_type IN ('semantic', 'temporal', 'entity')
315
+ AND ml.weight >= $2
316
+ AND mu.fact_type = $3
317
+ AND mu.id != ALL($1::uuid[])
318
+ ),
319
+ combined AS (
320
+ SELECT * FROM outgoing
321
+ UNION ALL
322
+ SELECT * FROM incoming
323
+ )
324
+ SELECT DISTINCT ON (id)
325
+ id, text, context, event_date, occurred_start,
326
+ occurred_end, mentioned_at, embedding,
327
+ fact_type, document_id, chunk_id, tags,
328
+ (MAX(weight) * 0.5) AS score
329
+ FROM combined
330
+ GROUP BY id, text, context, event_date, occurred_start,
331
+ occurred_end, mentioned_at, embedding,
332
+ fact_type, document_id, chunk_id, tags
333
+ ORDER BY id, score DESC
334
+ LIMIT $4
335
+ """,
336
+ seed_ids,
337
+ self.causal_weight_threshold,
338
+ fact_type,
339
+ budget,
340
+ )
341
+
214
342
  timings.edge_load_time = time.time() - query_start
215
- timings.db_queries = 2
216
- timings.edge_count = len(entity_rows) + len(causal_rows)
343
+ timings.db_queries = 3
344
+ timings.edge_count = len(entity_rows) + len(causal_rows) + len(fallback_rows)
217
345
 
218
346
  # Merge results, taking max score per fact
347
+ # Priority: entity links (unit_entities) > causal links > fallback links
219
348
  score_map: dict[str, float] = {}
220
349
  row_map: dict[str, dict] = {}
221
350
 
@@ -230,6 +359,12 @@ class LinkExpansionRetriever(GraphRetriever):
230
359
  if fact_id not in row_map:
231
360
  row_map[fact_id] = dict(row)
232
361
 
362
+ for row in fallback_rows:
363
+ fact_id = str(row["id"])
364
+ score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
365
+ if fact_id not in row_map:
366
+ row_map[fact_id] = dict(row)
367
+
233
368
  # Sort by score and limit
234
369
  sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
235
370
  rows = [row_map[fact_id] for fact_id in sorted_ids]