hindsight-api 0.3.0__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/cli.py +59 -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 +1119 -93
- hindsight_api/api/mcp.py +11 -191
- hindsight_api/config.py +145 -45
- 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 +114 -9
- hindsight_api/engine/directives/__init__.py +5 -0
- hindsight_api/engine/directives/models.py +37 -0
- hindsight_api/engine/embeddings.py +102 -5
- hindsight_api/engine/interface.py +32 -13
- hindsight_api/engine/llm_wrapper.py +505 -43
- hindsight_api/engine/memory_engine.py +2090 -1089
- hindsight_api/engine/mental_models/__init__.py +14 -0
- hindsight_api/engine/mental_models/models.py +53 -0
- 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 +130 -4
- hindsight_api/engine/retain/bank_utils.py +79 -201
- hindsight_api/engine/retain/fact_extraction.py +81 -48
- hindsight_api/engine/retain/fact_storage.py +5 -8
- hindsight_api/engine/retain/link_utils.py +5 -8
- hindsight_api/engine/retain/orchestrator.py +1 -55
- hindsight_api/engine/retain/types.py +2 -2
- hindsight_api/engine/search/graph_retrieval.py +2 -2
- hindsight_api/engine/search/link_expansion_retrieval.py +164 -29
- hindsight_api/engine/search/mpfp_retrieval.py +1 -1
- hindsight_api/engine/search/retrieval.py +14 -14
- hindsight_api/engine/search/think_utils.py +41 -140
- hindsight_api/engine/search/trace.py +0 -1
- hindsight_api/engine/search/tracer.py +2 -5
- hindsight_api/engine/search/types.py +0 -3
- hindsight_api/engine/task_backend.py +112 -196
- hindsight_api/engine/utils.py +0 -151
- hindsight_api/extensions/__init__.py +10 -1
- hindsight_api/extensions/builtin/tenant.py +5 -1
- hindsight_api/extensions/operation_validator.py +81 -4
- hindsight_api/extensions/tenant.py +26 -0
- hindsight_api/main.py +16 -5
- hindsight_api/mcp_local.py +12 -53
- hindsight_api/mcp_tools.py +494 -0
- hindsight_api/models.py +0 -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.3.0.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +12 -6
- hindsight_api-0.4.0.dist-info/RECORD +112 -0
- {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +1 -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.3.0.dist-info/RECORD +0 -82
- {hindsight_api-0.3.0.dist-info → hindsight_api-0.4.0.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"
|
|
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"
|
|
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
|
-
#
|
|
442
|
-
|
|
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
|
|
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
|
-
|
|
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 == "
|
|
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
|
-
|
|
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(
|
|
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::
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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: "
|
|
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
|
|
764
|
-
- "
|
|
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 -
|
|
791
|
-
valid_types = {"
|
|
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
|
|
89
|
+
Represents how one fact was caused by another.
|
|
90
90
|
"""
|
|
91
91
|
|
|
92
|
-
relation_type: str # "
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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 =
|
|
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]
|