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.
Files changed (48) hide show
  1. hindsight_api/__init__.py +38 -0
  2. hindsight_api/api/__init__.py +105 -0
  3. hindsight_api/api/http.py +1872 -0
  4. hindsight_api/api/mcp.py +157 -0
  5. hindsight_api/engine/__init__.py +47 -0
  6. hindsight_api/engine/cross_encoder.py +97 -0
  7. hindsight_api/engine/db_utils.py +93 -0
  8. hindsight_api/engine/embeddings.py +113 -0
  9. hindsight_api/engine/entity_resolver.py +575 -0
  10. hindsight_api/engine/llm_wrapper.py +269 -0
  11. hindsight_api/engine/memory_engine.py +3095 -0
  12. hindsight_api/engine/query_analyzer.py +519 -0
  13. hindsight_api/engine/response_models.py +222 -0
  14. hindsight_api/engine/retain/__init__.py +50 -0
  15. hindsight_api/engine/retain/bank_utils.py +423 -0
  16. hindsight_api/engine/retain/chunk_storage.py +82 -0
  17. hindsight_api/engine/retain/deduplication.py +104 -0
  18. hindsight_api/engine/retain/embedding_processing.py +62 -0
  19. hindsight_api/engine/retain/embedding_utils.py +54 -0
  20. hindsight_api/engine/retain/entity_processing.py +90 -0
  21. hindsight_api/engine/retain/fact_extraction.py +1027 -0
  22. hindsight_api/engine/retain/fact_storage.py +176 -0
  23. hindsight_api/engine/retain/link_creation.py +121 -0
  24. hindsight_api/engine/retain/link_utils.py +651 -0
  25. hindsight_api/engine/retain/orchestrator.py +405 -0
  26. hindsight_api/engine/retain/types.py +206 -0
  27. hindsight_api/engine/search/__init__.py +15 -0
  28. hindsight_api/engine/search/fusion.py +122 -0
  29. hindsight_api/engine/search/observation_utils.py +132 -0
  30. hindsight_api/engine/search/reranking.py +103 -0
  31. hindsight_api/engine/search/retrieval.py +503 -0
  32. hindsight_api/engine/search/scoring.py +161 -0
  33. hindsight_api/engine/search/temporal_extraction.py +64 -0
  34. hindsight_api/engine/search/think_utils.py +255 -0
  35. hindsight_api/engine/search/trace.py +215 -0
  36. hindsight_api/engine/search/tracer.py +447 -0
  37. hindsight_api/engine/search/types.py +160 -0
  38. hindsight_api/engine/task_backend.py +223 -0
  39. hindsight_api/engine/utils.py +203 -0
  40. hindsight_api/metrics.py +227 -0
  41. hindsight_api/migrations.py +163 -0
  42. hindsight_api/models.py +309 -0
  43. hindsight_api/pg0.py +425 -0
  44. hindsight_api/web/__init__.py +12 -0
  45. hindsight_api/web/server.py +143 -0
  46. hindsight_api-0.0.13.dist-info/METADATA +41 -0
  47. hindsight_api-0.0.13.dist-info/RECORD +48 -0
  48. 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