hindsight-api 0.0.13__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/__init__.py +38 -0
- hindsight_api/api/__init__.py +105 -0
- hindsight_api/api/http.py +1872 -0
- hindsight_api/api/mcp.py +157 -0
- hindsight_api/engine/__init__.py +47 -0
- hindsight_api/engine/cross_encoder.py +97 -0
- hindsight_api/engine/db_utils.py +93 -0
- hindsight_api/engine/embeddings.py +113 -0
- hindsight_api/engine/entity_resolver.py +575 -0
- hindsight_api/engine/llm_wrapper.py +269 -0
- hindsight_api/engine/memory_engine.py +3095 -0
- hindsight_api/engine/query_analyzer.py +519 -0
- hindsight_api/engine/response_models.py +222 -0
- hindsight_api/engine/retain/__init__.py +50 -0
- hindsight_api/engine/retain/bank_utils.py +423 -0
- hindsight_api/engine/retain/chunk_storage.py +82 -0
- hindsight_api/engine/retain/deduplication.py +104 -0
- hindsight_api/engine/retain/embedding_processing.py +62 -0
- hindsight_api/engine/retain/embedding_utils.py +54 -0
- hindsight_api/engine/retain/entity_processing.py +90 -0
- hindsight_api/engine/retain/fact_extraction.py +1027 -0
- hindsight_api/engine/retain/fact_storage.py +176 -0
- hindsight_api/engine/retain/link_creation.py +121 -0
- hindsight_api/engine/retain/link_utils.py +651 -0
- hindsight_api/engine/retain/orchestrator.py +405 -0
- hindsight_api/engine/retain/types.py +206 -0
- hindsight_api/engine/search/__init__.py +15 -0
- hindsight_api/engine/search/fusion.py +122 -0
- hindsight_api/engine/search/observation_utils.py +132 -0
- hindsight_api/engine/search/reranking.py +103 -0
- hindsight_api/engine/search/retrieval.py +503 -0
- hindsight_api/engine/search/scoring.py +161 -0
- hindsight_api/engine/search/temporal_extraction.py +64 -0
- hindsight_api/engine/search/think_utils.py +255 -0
- hindsight_api/engine/search/trace.py +215 -0
- hindsight_api/engine/search/tracer.py +447 -0
- hindsight_api/engine/search/types.py +160 -0
- hindsight_api/engine/task_backend.py +223 -0
- hindsight_api/engine/utils.py +203 -0
- hindsight_api/metrics.py +227 -0
- hindsight_api/migrations.py +163 -0
- hindsight_api/models.py +309 -0
- hindsight_api/pg0.py +425 -0
- hindsight_api/web/__init__.py +12 -0
- hindsight_api/web/server.py +143 -0
- hindsight_api-0.0.13.dist-info/METADATA +41 -0
- hindsight_api-0.0.13.dist-info/RECORD +48 -0
- hindsight_api-0.0.13.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for hybrid search (semantic + BM25 + graph).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict, Any, Tuple
|
|
6
|
+
import asyncio
|
|
7
|
+
from .types import RetrievalResult, MergedCandidate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def reciprocal_rank_fusion(
|
|
11
|
+
result_lists: List[List[RetrievalResult]],
|
|
12
|
+
k: int = 60
|
|
13
|
+
) -> List[MergedCandidate]:
|
|
14
|
+
"""
|
|
15
|
+
Merge multiple ranked result lists using Reciprocal Rank Fusion.
|
|
16
|
+
|
|
17
|
+
RRF formula: score(d) = sum_over_lists(1 / (k + rank(d)))
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
result_lists: List of result lists, each containing RetrievalResult objects
|
|
21
|
+
k: Constant for RRF formula (default: 60)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Merged list of MergedCandidate objects, sorted by RRF score
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
semantic_results = [RetrievalResult(...), RetrievalResult(...), ...]
|
|
28
|
+
bm25_results = [RetrievalResult(...), RetrievalResult(...), ...]
|
|
29
|
+
graph_results = [RetrievalResult(...), RetrievalResult(...), ...]
|
|
30
|
+
|
|
31
|
+
merged = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results])
|
|
32
|
+
# Returns: [MergedCandidate(...), MergedCandidate(...), ...]
|
|
33
|
+
"""
|
|
34
|
+
# Track scores from each list
|
|
35
|
+
rrf_scores = {}
|
|
36
|
+
source_ranks = {} # Track rank from each source for each doc_id
|
|
37
|
+
all_retrievals = {} # Store the actual RetrievalResult (use first occurrence)
|
|
38
|
+
|
|
39
|
+
source_names = ["semantic", "bm25", "graph", "temporal"]
|
|
40
|
+
|
|
41
|
+
for source_idx, results in enumerate(result_lists):
|
|
42
|
+
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
|
|
43
|
+
|
|
44
|
+
for rank, retrieval in enumerate(results, start=1):
|
|
45
|
+
# Type check to catch tuple issues
|
|
46
|
+
if isinstance(retrieval, tuple):
|
|
47
|
+
raise TypeError(
|
|
48
|
+
f"Expected RetrievalResult but got tuple in {source_name} results at rank {rank}. "
|
|
49
|
+
f"Tuple value: {retrieval[:2] if len(retrieval) >= 2 else retrieval}. "
|
|
50
|
+
f"This suggests the retrieval function returned tuples instead of RetrievalResult objects."
|
|
51
|
+
)
|
|
52
|
+
if not isinstance(retrieval, RetrievalResult):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"Expected RetrievalResult but got {type(retrieval).__name__} in {source_name} results at rank {rank}"
|
|
55
|
+
)
|
|
56
|
+
doc_id = retrieval.id
|
|
57
|
+
|
|
58
|
+
# Store retrieval result (use first occurrence)
|
|
59
|
+
if doc_id not in all_retrievals:
|
|
60
|
+
all_retrievals[doc_id] = retrieval
|
|
61
|
+
|
|
62
|
+
# Calculate RRF score contribution
|
|
63
|
+
if doc_id not in rrf_scores:
|
|
64
|
+
rrf_scores[doc_id] = 0.0
|
|
65
|
+
source_ranks[doc_id] = {}
|
|
66
|
+
|
|
67
|
+
rrf_scores[doc_id] += 1.0 / (k + rank)
|
|
68
|
+
source_ranks[doc_id][f"{source_name}_rank"] = rank
|
|
69
|
+
|
|
70
|
+
# Combine into final results with metadata
|
|
71
|
+
merged_results = []
|
|
72
|
+
for rrf_rank, (doc_id, rrf_score) in enumerate(
|
|
73
|
+
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
|
|
74
|
+
):
|
|
75
|
+
merged_candidate = MergedCandidate(
|
|
76
|
+
retrieval=all_retrievals[doc_id],
|
|
77
|
+
rrf_score=rrf_score,
|
|
78
|
+
rrf_rank=rrf_rank,
|
|
79
|
+
source_ranks=source_ranks[doc_id]
|
|
80
|
+
)
|
|
81
|
+
merged_results.append(merged_candidate)
|
|
82
|
+
|
|
83
|
+
return merged_results
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_scores_on_deltas(
|
|
87
|
+
results: List[Dict[str, Any]],
|
|
88
|
+
score_keys: List[str]
|
|
89
|
+
) -> List[Dict[str, Any]]:
|
|
90
|
+
"""
|
|
91
|
+
Normalize scores based on deltas (min-max normalization within result set).
|
|
92
|
+
|
|
93
|
+
This ensures all scores are in [0, 1] range based on the spread in THIS result set.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
results: List of result dicts
|
|
97
|
+
score_keys: Keys to normalize (e.g., ["recency", "frequency"])
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Results with normalized scores added as "{key}_normalized"
|
|
101
|
+
"""
|
|
102
|
+
for key in score_keys:
|
|
103
|
+
values = [r.get(key, 0.0) for r in results if key in r]
|
|
104
|
+
|
|
105
|
+
if not values:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
min_val = min(values)
|
|
109
|
+
max_val = max(values)
|
|
110
|
+
delta = max_val - min_val
|
|
111
|
+
|
|
112
|
+
if delta > 0:
|
|
113
|
+
for r in results:
|
|
114
|
+
if key in r:
|
|
115
|
+
r[f"{key}_normalized"] = (r[key] - min_val) / delta
|
|
116
|
+
else:
|
|
117
|
+
# All values are the same, set to 0.5
|
|
118
|
+
for r in results:
|
|
119
|
+
if key in r:
|
|
120
|
+
r[f"{key}_normalized"] = 0.5
|
|
121
|
+
|
|
122
|
+
return results
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observation utilities for generating entity observations from facts.
|
|
3
|
+
|
|
4
|
+
Observations are objective facts synthesized from multiple memory facts
|
|
5
|
+
about an entity, without personality influence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List, Dict, Any
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from ..response_models import MemoryFact
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Observation(BaseModel):
|
|
18
|
+
"""An observation about an entity."""
|
|
19
|
+
observation: str = Field(description="The observation text - a factual statement about the entity")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ObservationExtractionResponse(BaseModel):
|
|
23
|
+
"""Response containing extracted observations."""
|
|
24
|
+
observations: List[Observation] = Field(
|
|
25
|
+
default_factory=list,
|
|
26
|
+
description="List of observations about the entity"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
|
|
31
|
+
"""Format facts as text for observation extraction prompt."""
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
if not facts:
|
|
35
|
+
return "[]"
|
|
36
|
+
formatted = []
|
|
37
|
+
for fact in facts:
|
|
38
|
+
fact_obj = {
|
|
39
|
+
"text": fact.text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Add context if available
|
|
43
|
+
if fact.context:
|
|
44
|
+
fact_obj["context"] = fact.context
|
|
45
|
+
|
|
46
|
+
# Add occurred_start if available
|
|
47
|
+
if fact.occurred_start:
|
|
48
|
+
fact_obj["occurred_at"] = fact.occurred_start
|
|
49
|
+
|
|
50
|
+
formatted.append(fact_obj)
|
|
51
|
+
|
|
52
|
+
return json.dumps(formatted, indent=2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_observation_prompt(
|
|
56
|
+
entity_name: str,
|
|
57
|
+
facts_text: str,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Build the observation extraction prompt for the LLM."""
|
|
60
|
+
return f"""Based on the following facts about "{entity_name}", generate a list of key observations.
|
|
61
|
+
|
|
62
|
+
FACTS ABOUT {entity_name.upper()}:
|
|
63
|
+
{facts_text}
|
|
64
|
+
|
|
65
|
+
Your task: Synthesize the facts into clear, objective observations about {entity_name}.
|
|
66
|
+
|
|
67
|
+
GUIDELINES:
|
|
68
|
+
1. Each observation should be a factual statement about {entity_name}
|
|
69
|
+
2. Combine related facts into single observations where appropriate
|
|
70
|
+
3. Be objective - do not add opinions, judgments, or interpretations
|
|
71
|
+
4. Focus on what we KNOW about {entity_name}, not what we assume
|
|
72
|
+
5. Include observations about: identity, characteristics, roles, relationships, activities
|
|
73
|
+
6. Write in third person (e.g., "John is..." not "I think John is...")
|
|
74
|
+
7. If there are conflicting facts, note the most recent or most supported one
|
|
75
|
+
|
|
76
|
+
EXAMPLES of good observations:
|
|
77
|
+
- "John works at Google as a software engineer"
|
|
78
|
+
- "John is detail-oriented and methodical in his approach"
|
|
79
|
+
- "John collaborates frequently with Sarah on the AI project"
|
|
80
|
+
- "John joined the company in 2023"
|
|
81
|
+
|
|
82
|
+
EXAMPLES of bad observations (avoid these):
|
|
83
|
+
- "John seems like a good person" (opinion/judgment)
|
|
84
|
+
- "John probably likes his job" (assumption)
|
|
85
|
+
- "I believe John is reliable" (first-person opinion)
|
|
86
|
+
|
|
87
|
+
Generate 3-7 observations based on the available facts. If there are very few facts, generate fewer observations."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_observation_system_message() -> str:
|
|
91
|
+
"""Get the system message for observation extraction."""
|
|
92
|
+
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def extract_observations_from_facts(
|
|
96
|
+
llm_config,
|
|
97
|
+
entity_name: str,
|
|
98
|
+
facts: List[MemoryFact]
|
|
99
|
+
) -> List[str]:
|
|
100
|
+
"""
|
|
101
|
+
Extract observations from facts about an entity using LLM.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
llm_config: LLM configuration to use
|
|
105
|
+
entity_name: Name of the entity to generate observations about
|
|
106
|
+
facts: List of facts mentioning the entity
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of observation strings
|
|
110
|
+
"""
|
|
111
|
+
if not facts:
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
facts_text = format_facts_for_observation_prompt(facts)
|
|
115
|
+
prompt = build_observation_prompt(entity_name, facts_text)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
result = await llm_config.call(
|
|
119
|
+
messages=[
|
|
120
|
+
{"role": "system", "content": get_observation_system_message()},
|
|
121
|
+
{"role": "user", "content": prompt}
|
|
122
|
+
],
|
|
123
|
+
response_format=ObservationExtractionResponse,
|
|
124
|
+
scope="memory_extract_observation"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
observations = [op.observation for op in result.observations]
|
|
128
|
+
return observations
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.warning(f"Failed to extract observations for {entity_name}: {str(e)}")
|
|
132
|
+
return []
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-encoder neural reranking for search results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
from .types import MergedCandidate, ScoredResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CrossEncoderReranker:
|
|
10
|
+
"""
|
|
11
|
+
Neural reranking using a cross-encoder model.
|
|
12
|
+
|
|
13
|
+
Uses cross-encoder/ms-marco-MiniLM-L-6-v2 by default:
|
|
14
|
+
- Fast inference (~80ms for 100 pairs on CPU)
|
|
15
|
+
- Small model (80MB)
|
|
16
|
+
- Trained for passage re-ranking
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cross_encoder=None):
|
|
20
|
+
"""
|
|
21
|
+
Initialize cross-encoder reranker.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
cross_encoder: CrossEncoderReranker instance. If None, uses default
|
|
25
|
+
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
|
|
26
|
+
(loaded lazily for faster startup)
|
|
27
|
+
"""
|
|
28
|
+
if cross_encoder is None:
|
|
29
|
+
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
|
|
30
|
+
# Model is loaded lazily - call ensure_loaded() during initialize()
|
|
31
|
+
cross_encoder = SentenceTransformersCrossEncoder()
|
|
32
|
+
self.cross_encoder = cross_encoder
|
|
33
|
+
|
|
34
|
+
def rerank(
|
|
35
|
+
self,
|
|
36
|
+
query: str,
|
|
37
|
+
candidates: List[MergedCandidate]
|
|
38
|
+
) -> List[ScoredResult]:
|
|
39
|
+
"""
|
|
40
|
+
Rerank candidates using cross-encoder scores.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
query: Search query
|
|
44
|
+
candidates: Merged candidates from RRF
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of ScoredResult objects sorted by cross-encoder score
|
|
48
|
+
"""
|
|
49
|
+
if not candidates:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
# Prepare query-document pairs with date information
|
|
53
|
+
pairs = []
|
|
54
|
+
for candidate in candidates:
|
|
55
|
+
retrieval = candidate.retrieval
|
|
56
|
+
|
|
57
|
+
# Use text + context for better ranking
|
|
58
|
+
doc_text = retrieval.text
|
|
59
|
+
if retrieval.context:
|
|
60
|
+
doc_text = f"{retrieval.context}: {doc_text}"
|
|
61
|
+
|
|
62
|
+
# Add formatted date information for temporal awareness
|
|
63
|
+
if retrieval.occurred_start:
|
|
64
|
+
occurred_start = retrieval.occurred_start
|
|
65
|
+
|
|
66
|
+
# Format in two styles for better model understanding
|
|
67
|
+
# 1. ISO format: YYYY-MM-DD
|
|
68
|
+
date_iso = occurred_start.strftime("%Y-%m-%d")
|
|
69
|
+
|
|
70
|
+
# 2. Human-readable: "June 5, 2022"
|
|
71
|
+
date_readable = occurred_start.strftime("%B %d, %Y")
|
|
72
|
+
|
|
73
|
+
# Prepend date to document text
|
|
74
|
+
doc_text = f"[Date: {date_readable} ({date_iso})] {doc_text}"
|
|
75
|
+
|
|
76
|
+
pairs.append([query, doc_text])
|
|
77
|
+
|
|
78
|
+
# Get cross-encoder scores
|
|
79
|
+
scores = self.cross_encoder.predict(pairs)
|
|
80
|
+
|
|
81
|
+
# Normalize scores using sigmoid to [0, 1] range
|
|
82
|
+
# Cross-encoder returns logits which can be negative
|
|
83
|
+
import numpy as np
|
|
84
|
+
def sigmoid(x):
|
|
85
|
+
return 1 / (1 + np.exp(-x))
|
|
86
|
+
|
|
87
|
+
normalized_scores = [sigmoid(score) for score in scores]
|
|
88
|
+
|
|
89
|
+
# Create ScoredResult objects with cross-encoder scores
|
|
90
|
+
scored_results = []
|
|
91
|
+
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
|
|
92
|
+
scored_result = ScoredResult(
|
|
93
|
+
candidate=candidate,
|
|
94
|
+
cross_encoder_score=float(raw_score),
|
|
95
|
+
cross_encoder_score_normalized=float(norm_score),
|
|
96
|
+
weight=float(norm_score) # Initial weight is just cross-encoder score
|
|
97
|
+
)
|
|
98
|
+
scored_results.append(scored_result)
|
|
99
|
+
|
|
100
|
+
# Sort by cross-encoder score
|
|
101
|
+
scored_results.sort(key=lambda x: x.weight, reverse=True)
|
|
102
|
+
|
|
103
|
+
return scored_results
|