hindsight-api 0.2.0__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.
- hindsight_api/admin/__init__.py +1 -0
- hindsight_api/admin/cli.py +252 -0
- hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
- hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
- hindsight_api/api/http.py +282 -20
- hindsight_api/api/mcp.py +47 -52
- hindsight_api/config.py +238 -6
- hindsight_api/engine/cross_encoder.py +599 -86
- hindsight_api/engine/db_budget.py +284 -0
- hindsight_api/engine/db_utils.py +11 -0
- hindsight_api/engine/embeddings.py +453 -26
- hindsight_api/engine/entity_resolver.py +8 -5
- hindsight_api/engine/interface.py +8 -4
- hindsight_api/engine/llm_wrapper.py +241 -27
- hindsight_api/engine/memory_engine.py +609 -122
- hindsight_api/engine/query_analyzer.py +4 -3
- hindsight_api/engine/response_models.py +38 -0
- hindsight_api/engine/retain/fact_extraction.py +388 -192
- hindsight_api/engine/retain/fact_storage.py +34 -8
- hindsight_api/engine/retain/link_utils.py +24 -16
- hindsight_api/engine/retain/orchestrator.py +52 -17
- hindsight_api/engine/retain/types.py +9 -0
- hindsight_api/engine/search/graph_retrieval.py +42 -13
- hindsight_api/engine/search/link_expansion_retrieval.py +256 -0
- hindsight_api/engine/search/mpfp_retrieval.py +362 -117
- hindsight_api/engine/search/reranking.py +2 -2
- hindsight_api/engine/search/retrieval.py +847 -200
- hindsight_api/engine/search/tags.py +172 -0
- hindsight_api/engine/search/think_utils.py +1 -1
- hindsight_api/engine/search/trace.py +12 -0
- hindsight_api/engine/search/tracer.py +24 -1
- hindsight_api/engine/search/types.py +21 -0
- hindsight_api/engine/task_backend.py +109 -18
- hindsight_api/engine/utils.py +1 -1
- hindsight_api/extensions/context.py +10 -1
- hindsight_api/main.py +56 -4
- hindsight_api/metrics.py +433 -48
- hindsight_api/migrations.py +141 -1
- hindsight_api/models.py +3 -1
- hindsight_api/pg0.py +53 -0
- hindsight_api/server.py +39 -2
- {hindsight_api-0.2.0.dist-info → hindsight_api-0.3.0.dist-info}/METADATA +5 -1
- hindsight_api-0.3.0.dist-info/RECORD +82 -0
- {hindsight_api-0.2.0.dist-info → hindsight_api-0.3.0.dist-info}/entry_points.txt +1 -0
- hindsight_api-0.2.0.dist-info/RECORD +0 -75
- {hindsight_api-0.2.0.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
|