hindsight-api 0.2.1__py3-none-any.whl → 0.3.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.
Files changed (46) hide show
  1. hindsight_api/admin/__init__.py +1 -0
  2. hindsight_api/admin/cli.py +252 -0
  3. hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
  4. hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
  5. hindsight_api/api/http.py +282 -20
  6. hindsight_api/api/mcp.py +47 -52
  7. hindsight_api/config.py +238 -6
  8. hindsight_api/engine/cross_encoder.py +599 -86
  9. hindsight_api/engine/db_budget.py +284 -0
  10. hindsight_api/engine/db_utils.py +11 -0
  11. hindsight_api/engine/embeddings.py +453 -26
  12. hindsight_api/engine/entity_resolver.py +8 -5
  13. hindsight_api/engine/interface.py +8 -4
  14. hindsight_api/engine/llm_wrapper.py +241 -27
  15. hindsight_api/engine/memory_engine.py +609 -122
  16. hindsight_api/engine/query_analyzer.py +4 -3
  17. hindsight_api/engine/response_models.py +38 -0
  18. hindsight_api/engine/retain/fact_extraction.py +388 -192
  19. hindsight_api/engine/retain/fact_storage.py +34 -8
  20. hindsight_api/engine/retain/link_utils.py +24 -16
  21. hindsight_api/engine/retain/orchestrator.py +52 -17
  22. hindsight_api/engine/retain/types.py +9 -0
  23. hindsight_api/engine/search/graph_retrieval.py +42 -13
  24. hindsight_api/engine/search/link_expansion_retrieval.py +256 -0
  25. hindsight_api/engine/search/mpfp_retrieval.py +362 -117
  26. hindsight_api/engine/search/reranking.py +2 -2
  27. hindsight_api/engine/search/retrieval.py +847 -200
  28. hindsight_api/engine/search/tags.py +172 -0
  29. hindsight_api/engine/search/think_utils.py +1 -1
  30. hindsight_api/engine/search/trace.py +12 -0
  31. hindsight_api/engine/search/tracer.py +24 -1
  32. hindsight_api/engine/search/types.py +21 -0
  33. hindsight_api/engine/task_backend.py +109 -18
  34. hindsight_api/engine/utils.py +1 -1
  35. hindsight_api/extensions/context.py +10 -1
  36. hindsight_api/main.py +56 -4
  37. hindsight_api/metrics.py +433 -48
  38. hindsight_api/migrations.py +141 -1
  39. hindsight_api/models.py +3 -1
  40. hindsight_api/pg0.py +53 -0
  41. hindsight_api/server.py +39 -2
  42. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/METADATA +5 -1
  43. hindsight_api-0.3.0.dist-info/RECORD +82 -0
  44. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/entry_points.txt +1 -0
  45. hindsight_api-0.2.1.dist-info/RECORD +0 -75
  46. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,256 @@
1
+ """
2
+ Link Expansion graph retrieval.
3
+
4
+ A simple, fast graph retrieval that expands from seeds via:
5
+ 1. Entity links: Find facts sharing entities with seeds (filtered by entity frequency)
6
+ 2. Causal links: Find facts causally linked to seeds (top-k by weight)
7
+
8
+ Characteristics:
9
+ - 2-3 DB queries (seed finding + parallel entity/causal expansion)
10
+ - Sublinear: only touches connected facts via indexes
11
+ - No iteration, no propagation, no normalization
12
+ - Target: <100ms
13
+ """
14
+
15
+ import logging
16
+ import time
17
+
18
+ from ..db_utils import acquire_with_retry
19
+ from ..memory_engine import fq_table
20
+ from .graph_retrieval import GraphRetriever
21
+ from .tags import TagsMatch, filter_results_by_tags
22
+ from .types import MPFPTimings, RetrievalResult
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ async def _find_semantic_seeds(
28
+ conn,
29
+ query_embedding_str: str,
30
+ bank_id: str,
31
+ fact_type: str,
32
+ limit: int = 20,
33
+ threshold: float = 0.3,
34
+ tags: list[str] | None = None,
35
+ tags_match: TagsMatch = "any",
36
+ ) -> list[RetrievalResult]:
37
+ """Find semantic seeds via embedding search."""
38
+ from .tags import build_tags_where_clause_simple
39
+
40
+ tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
41
+ params = [query_embedding_str, bank_id, fact_type, threshold, limit]
42
+ if tags:
43
+ params.append(tags)
44
+
45
+ rows = await conn.fetch(
46
+ f"""
47
+ SELECT id, text, context, event_date, occurred_start, occurred_end,
48
+ mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
49
+ 1 - (embedding <=> $1::vector) AS similarity
50
+ FROM {fq_table("memory_units")}
51
+ WHERE bank_id = $2
52
+ AND embedding IS NOT NULL
53
+ AND fact_type = $3
54
+ AND (1 - (embedding <=> $1::vector)) >= $4
55
+ {tags_clause}
56
+ ORDER BY embedding <=> $1::vector
57
+ LIMIT $5
58
+ """,
59
+ *params,
60
+ )
61
+ return [RetrievalResult.from_db_row(dict(r)) for r in rows]
62
+
63
+
64
+ class LinkExpansionRetriever(GraphRetriever):
65
+ """
66
+ Graph retrieval via direct link expansion from seeds.
67
+
68
+ Expands through entity co-occurrence and causal links in a single query.
69
+ Fast and simple alternative to MPFP.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ max_entity_frequency: int = 500,
75
+ causal_weight_threshold: float = 0.3,
76
+ causal_limit_per_seed: int = 10,
77
+ ):
78
+ """
79
+ Initialize link expansion retriever.
80
+
81
+ Args:
82
+ max_entity_frequency: Skip entities appearing in more than this many facts
83
+ causal_weight_threshold: Minimum weight for causal links
84
+ causal_limit_per_seed: Max causal links to follow per seed
85
+ """
86
+ self.max_entity_frequency = max_entity_frequency
87
+ self.causal_weight_threshold = causal_weight_threshold
88
+ self.causal_limit_per_seed = causal_limit_per_seed
89
+
90
+ @property
91
+ def name(self) -> str:
92
+ return "link_expansion"
93
+
94
+ async def retrieve(
95
+ self,
96
+ pool,
97
+ query_embedding_str: str,
98
+ bank_id: str,
99
+ fact_type: str,
100
+ budget: int,
101
+ query_text: str | None = None,
102
+ semantic_seeds: list[RetrievalResult] | None = None,
103
+ temporal_seeds: list[RetrievalResult] | None = None,
104
+ adjacency=None,
105
+ tags: list[str] | None = None,
106
+ tags_match: TagsMatch = "any",
107
+ ) -> tuple[list[RetrievalResult], MPFPTimings | None]:
108
+ """
109
+ Retrieve facts by expanding links from seeds.
110
+
111
+ Args:
112
+ pool: Database connection pool
113
+ query_embedding_str: Query embedding (unused, kept for interface)
114
+ bank_id: Memory bank ID
115
+ fact_type: Fact type to filter
116
+ budget: Maximum results to return
117
+ query_text: Original query text (unused)
118
+ semantic_seeds: Pre-computed semantic entry points
119
+ temporal_seeds: Pre-computed temporal entry points
120
+ adjacency: Unused, kept for interface compatibility
121
+ tags: Optional list of tags for visibility filtering (OR matching)
122
+
123
+ Returns:
124
+ Tuple of (results, timings)
125
+ """
126
+ start_time = time.time()
127
+ timings = MPFPTimings(fact_type=fact_type)
128
+
129
+ # Use single connection for all queries to reduce pool pressure
130
+ # (queries are fast ~50ms each, connection acquisition is the bottleneck)
131
+ async with acquire_with_retry(pool) as conn:
132
+ # Find seeds if not provided
133
+ if semantic_seeds:
134
+ all_seeds = list(semantic_seeds)
135
+ else:
136
+ seeds_start = time.time()
137
+ all_seeds = await _find_semantic_seeds(
138
+ conn,
139
+ query_embedding_str,
140
+ bank_id,
141
+ fact_type,
142
+ limit=20,
143
+ threshold=0.3,
144
+ tags=tags,
145
+ tags_match=tags_match,
146
+ )
147
+ timings.seeds_time = time.time() - seeds_start
148
+ logger.debug(
149
+ f"[LinkExpansion] Found {len(all_seeds)} semantic seeds for fact_type={fact_type} "
150
+ f"(tags={tags}, tags_match={tags_match})"
151
+ )
152
+
153
+ # Add temporal seeds if provided
154
+ if temporal_seeds:
155
+ all_seeds.extend(temporal_seeds)
156
+
157
+ if not all_seeds:
158
+ logger.debug("[LinkExpansion] No seeds found, returning empty results")
159
+ return [], timings
160
+
161
+ seed_ids = list({s.id for s in all_seeds})
162
+ timings.pattern_count = len(seed_ids)
163
+
164
+ # Run entity and causal expansion sequentially on same connection
165
+ query_start = time.time()
166
+
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
+ )
191
+
192
+ causal_rows = await conn.fetch(
193
+ f"""
194
+ SELECT DISTINCT ON (mu.id)
195
+ mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
196
+ mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
197
+ mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
198
+ ml.weight + 1.0 AS score
199
+ FROM {fq_table("memory_links")} ml
200
+ JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
201
+ WHERE ml.from_unit_id = ANY($1::uuid[])
202
+ AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
203
+ AND ml.weight >= $2
204
+ AND mu.fact_type = $3
205
+ ORDER BY mu.id, ml.weight DESC
206
+ LIMIT $4
207
+ """,
208
+ seed_ids,
209
+ self.causal_weight_threshold,
210
+ fact_type,
211
+ budget,
212
+ )
213
+
214
+ timings.edge_load_time = time.time() - query_start
215
+ timings.db_queries = 2
216
+ timings.edge_count = len(entity_rows) + len(causal_rows)
217
+
218
+ # Merge results, taking max score per fact
219
+ score_map: dict[str, float] = {}
220
+ row_map: dict[str, dict] = {}
221
+
222
+ for row in entity_rows:
223
+ fact_id = str(row["id"])
224
+ score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
225
+ row_map[fact_id] = dict(row)
226
+
227
+ for row in causal_rows:
228
+ fact_id = str(row["id"])
229
+ score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
230
+ if fact_id not in row_map:
231
+ row_map[fact_id] = dict(row)
232
+
233
+ # Sort by score and limit
234
+ sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
235
+ rows = [row_map[fact_id] for fact_id in sorted_ids]
236
+
237
+ # Convert to results
238
+ results = []
239
+ for row in rows:
240
+ result = RetrievalResult.from_db_row(dict(row))
241
+ result.activation = row["score"]
242
+ results.append(result)
243
+
244
+ # Apply tags filtering (graph expansion may reach untagged memories)
245
+ if tags:
246
+ results = filter_results_by_tags(results, tags, match=tags_match)
247
+
248
+ timings.result_count = len(results)
249
+ timings.traverse = time.time() - start_time
250
+
251
+ logger.debug(
252
+ f"LinkExpansion: {len(results)} results from {len(seed_ids)} seeds "
253
+ f"in {timings.traverse * 1000:.1f}ms (query: {timings.edge_load_time * 1000:.1f}ms)"
254
+ )
255
+
256
+ return results, timings