concierge-graph 3.8.2__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.
- agents/__init__.py +14 -0
- agents/revisor_critico.py +610 -0
- concierge_graph-3.8.2.dist-info/METADATA +327 -0
- concierge_graph-3.8.2.dist-info/RECORD +37 -0
- concierge_graph-3.8.2.dist-info/WHEEL +5 -0
- concierge_graph-3.8.2.dist-info/entry_points.txt +3 -0
- concierge_graph-3.8.2.dist-info/licenses/LICENSE +21 -0
- concierge_graph-3.8.2.dist-info/top_level.txt +8 -0
- core/__init__.py +23 -0
- core/config.py +192 -0
- core/hybrid_search.py +288 -0
- core/memory_extractor.py +245 -0
- core/middleware.py +723 -0
- core/probabilistic_retriever.py +99 -0
- core/project_index.py +316 -0
- core/vector_backend.py +471 -0
- ingestion/__init__.py +25 -0
- ingestion/crawler.py +722 -0
- ingestion/orchestrator.py +920 -0
- ingestion/parser.py +984 -0
- ingestion/summarizer.py +948 -0
- interface/__init__.py +16 -0
- interface/action_hooks.py +261 -0
- interface/cli.py +391 -0
- interface/mcp_server.py +1737 -0
- main.py +281 -0
- scripts/bootstrap_core_memory.py +93 -0
- services/__init__.py +13 -0
- services/janitor.py +762 -0
- storage/__init__.py +31 -0
- storage/base_backend.py +241 -0
- storage/connection.py +310 -0
- storage/logic.py +703 -0
- storage/schema.py +390 -0
- storage/semantic_logic.py +125 -0
- storage/store.py +802 -0
- storage/vector_store.py +759 -0
storage/logic.py
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""
|
|
2
|
+
storage/logic.py - Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Graph Intelligence Core — Apex Algorithms.
|
|
5
|
+
|
|
6
|
+
Three pillars:
|
|
7
|
+
1. Trajectories Decay (Version-Binding)
|
|
8
|
+
2. Node Centrality (normalized in-degree + Super-Node detection)
|
|
9
|
+
3. Weighted Hybrid Search (FTS5 BM25 + Vector + Max(Recency, Centrality))
|
|
10
|
+
|
|
11
|
+
Reference formulas (Architecture v3.8):
|
|
12
|
+
- Centrality: min(in_degree / 10, 1.0)
|
|
13
|
+
- Recency: max(e^(-λ × t), 0.01) where λ = ln(2)/7 ≈ 0.0990
|
|
14
|
+
- Final Score: 0.50×vector + 0.25×fts5 + 0.25×max(recency, centrality)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from storage.schema import VALID_STATUSES
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from core.config import ConciergeConfig
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("grafo-concierge.logic")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Specific exceptions for the intelligence module
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class TrajectoryNotFoundError(Exception):
|
|
37
|
+
"""Trajectory with the provided ID does not exist in the database."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidTransitionError(Exception):
|
|
41
|
+
"""Illegal status transition (e.g. ARCHIVED → ACTIVE)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# GraphLogic — Grafo Concierge intelligence engine
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
class GraphLogic:
|
|
49
|
+
"""Intelligence engine over the graph persisted in SQLite.
|
|
50
|
+
|
|
51
|
+
Depends on a ConnectionManager (connection.py) for database access.
|
|
52
|
+
Reads via conn_manager.read(), writes via conn_manager.write().
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
conn_manager: Instance of ConnectionManager.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# --- Recency Constants (Exponential Decay) ---
|
|
59
|
+
# Half-life of 7 days: after 7 days without commit, score drops to 0.50.
|
|
60
|
+
RECENCY_HALF_LIFE_DAYS: float = 7.0
|
|
61
|
+
RECENCY_LAMBDA: float = math.log(2) / 7.0 # ≈ 0.09902
|
|
62
|
+
RECENCY_MIN_SCORE: float = 0.01 # Old nodes never reach zero
|
|
63
|
+
|
|
64
|
+
# --- Centrality Constants ---
|
|
65
|
+
# A node with 10+ dependents is considered a "Super-Node" (score = 1.0).
|
|
66
|
+
CENTRALITY_MAX_IN_DEGREE: int = 10
|
|
67
|
+
|
|
68
|
+
# --- Hybrid Search Weights v4 ---
|
|
69
|
+
WEIGHT_VECTOR: float = 0.50
|
|
70
|
+
WEIGHT_FTS5: float = 0.25
|
|
71
|
+
WEIGHT_RECENCY_CENTRALITY: float = 0.25
|
|
72
|
+
|
|
73
|
+
# --- Valid status transitions for Trajectories ---
|
|
74
|
+
# Each key maps to the states it can transition to.
|
|
75
|
+
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
|
|
76
|
+
"ACTIVE": frozenset({"STALE", "ARCHIVED"}),
|
|
77
|
+
"STALE": frozenset({"ACTIVE", "ARCHIVED"}),
|
|
78
|
+
"ARCHIVED": frozenset(), # Terminal state — no return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __init__(self, conn_manager: Any, config: Optional["ConciergeConfig"] = None) -> None:
|
|
82
|
+
self._conn = conn_manager
|
|
83
|
+
|
|
84
|
+
# If config provided, overrides class constants with user values
|
|
85
|
+
if config is not None:
|
|
86
|
+
self.WEIGHT_VECTOR = config.weight_vector
|
|
87
|
+
self.WEIGHT_FTS5 = config.weight_fts5
|
|
88
|
+
self.WEIGHT_RECENCY_CENTRALITY = config.weight_recency_centrality
|
|
89
|
+
self.CENTRALITY_MAX_IN_DEGREE = config.centrality_max_in_degree
|
|
90
|
+
self.RECENCY_HALF_LIFE_DAYS = config.recency_half_life_days
|
|
91
|
+
self.RECENCY_LAMBDA = config.recency_lambda
|
|
92
|
+
self.RECENCY_MIN_SCORE = config.recency_min_score
|
|
93
|
+
|
|
94
|
+
# ===================================================================
|
|
95
|
+
# 1. TRAJECTORIES DECAY (Version-Binding)
|
|
96
|
+
# ===================================================================
|
|
97
|
+
|
|
98
|
+
def decay_trajectory(self, trajectory_id: int, new_status: str) -> bool:
|
|
99
|
+
"""Changes status of an episodic trajectory with transition validation.
|
|
100
|
+
|
|
101
|
+
Transition rules (state machine):
|
|
102
|
+
ACTIVE → STALE, ARCHIVED
|
|
103
|
+
STALE → ACTIVE, ARCHIVED (re-activation allowed)
|
|
104
|
+
ARCHIVED → (terminal, no transition allowed)
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
trajectory_id: ID in trajectories table.
|
|
108
|
+
new_status: Target status (ACTIVE, STALE or ARCHIVED).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if the transition was applied.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If new_status is not a valid status.
|
|
115
|
+
TrajectoryNotFoundError: If trajectory_id does not exist.
|
|
116
|
+
InvalidTransitionError: If the transition is illegal.
|
|
117
|
+
"""
|
|
118
|
+
# Validation 1: is destination status recognized?
|
|
119
|
+
if new_status not in VALID_STATUSES:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Invalid status: '{new_status}'. "
|
|
122
|
+
f"Accepted values: {sorted(VALID_STATUSES)}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Reading current status
|
|
126
|
+
with self._conn.read() as conn:
|
|
127
|
+
row = conn.execute(
|
|
128
|
+
"SELECT id, status FROM trajectories WHERE id = ?",
|
|
129
|
+
(trajectory_id,)
|
|
130
|
+
).fetchone()
|
|
131
|
+
|
|
132
|
+
if row is None:
|
|
133
|
+
raise TrajectoryNotFoundError(
|
|
134
|
+
f"Trajectory ID={trajectory_id} not found."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
current_status = row["status"]
|
|
138
|
+
|
|
139
|
+
# Validation 2: is transition legal?
|
|
140
|
+
allowed = self._VALID_TRANSITIONS.get(current_status, frozenset())
|
|
141
|
+
if new_status not in allowed:
|
|
142
|
+
raise InvalidTransitionError(
|
|
143
|
+
f"Illegal transition: {current_status} → {new_status}. "
|
|
144
|
+
f"Allowed transitions from '{current_status}': {sorted(allowed) or 'none (terminal)'}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Serialized write
|
|
148
|
+
def _do(conn: Any, tid: int, status: str) -> bool:
|
|
149
|
+
conn.execute(
|
|
150
|
+
"UPDATE trajectories SET status = ? WHERE id = ?",
|
|
151
|
+
(status, tid)
|
|
152
|
+
)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
result = self._conn.write(_do, trajectory_id, new_status)
|
|
156
|
+
logger.info(
|
|
157
|
+
"Trajectory ID=%d: %s → %s", trajectory_id, current_status, new_status
|
|
158
|
+
)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def bulk_decay_stale_trajectories(
|
|
162
|
+
self, project_uuid: str, stale_threshold_days: int = 30
|
|
163
|
+
) -> int:
|
|
164
|
+
"""Marks as STALE all ACTIVE trajectories older than the threshold.
|
|
165
|
+
|
|
166
|
+
Used by Background Janitor in the Reconciliation Loop.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
project_uuid: Target project UUID.
|
|
170
|
+
stale_threshold_days: Days since created_at to consider stale.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Number of affected trajectories.
|
|
174
|
+
"""
|
|
175
|
+
def _do(conn: Any, pu: str, days: int) -> int:
|
|
176
|
+
cursor = conn.execute(
|
|
177
|
+
"""UPDATE trajectories
|
|
178
|
+
SET status = 'STALE'
|
|
179
|
+
WHERE project_uuid = ?
|
|
180
|
+
AND status = 'ACTIVE'
|
|
181
|
+
AND julianday('now') - julianday(created_at) > ?""",
|
|
182
|
+
(pu, days)
|
|
183
|
+
)
|
|
184
|
+
return cursor.rowcount
|
|
185
|
+
|
|
186
|
+
affected = self._conn.write(_do, project_uuid, stale_threshold_days)
|
|
187
|
+
if affected > 0:
|
|
188
|
+
logger.info(
|
|
189
|
+
"Bulk decay: %d trajectories marked STALE in project %s (threshold=%d days)",
|
|
190
|
+
affected, project_uuid, stale_threshold_days
|
|
191
|
+
)
|
|
192
|
+
return affected
|
|
193
|
+
|
|
194
|
+
# ===================================================================
|
|
195
|
+
# 2. CENTRALITY (normalized in-degree + Super-Nodes)
|
|
196
|
+
# ===================================================================
|
|
197
|
+
|
|
198
|
+
def compute_centrality(self, node_id: int) -> float:
|
|
199
|
+
"""Calculates normalized centrality of a node.
|
|
200
|
+
|
|
201
|
+
Formula: min(in_degree / CENTRALITY_MAX_IN_DEGREE, 1.0)
|
|
202
|
+
|
|
203
|
+
A node with in_degree >= 10 is a "Super-Node": stable core code
|
|
204
|
+
that many files depend on. Receives maximum centrality (1.0),
|
|
205
|
+
protecting it against low recency penalty in Hybrid Search.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
node_id: Node ID in nodes table.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Float in interval [0.0, 1.0].
|
|
212
|
+
"""
|
|
213
|
+
with self._conn.read() as conn:
|
|
214
|
+
row = conn.execute(
|
|
215
|
+
"SELECT COUNT(*) AS in_deg FROM edges WHERE target_id = ?",
|
|
216
|
+
(node_id,)
|
|
217
|
+
).fetchone()
|
|
218
|
+
|
|
219
|
+
in_degree = row["in_deg"] if row else 0
|
|
220
|
+
centrality = min(in_degree / self.CENTRALITY_MAX_IN_DEGREE, 1.0)
|
|
221
|
+
|
|
222
|
+
logger.debug(
|
|
223
|
+
"Centrality node ID=%d: in_degree=%d, score=%.4f%s",
|
|
224
|
+
node_id, in_degree, centrality,
|
|
225
|
+
" [SUPER-NODE]" if centrality >= 1.0 else ""
|
|
226
|
+
)
|
|
227
|
+
return centrality
|
|
228
|
+
|
|
229
|
+
def compute_centrality_batch(self, node_ids: list[int]) -> dict[int, float]:
|
|
230
|
+
"""Calculates centrality for multiple nodes in a single SQL query.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
node_ids: List of node IDs.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Dict mapping node_id → normalized centrality [0.0, 1.0].
|
|
237
|
+
Nodes without incoming edges return 0.0.
|
|
238
|
+
"""
|
|
239
|
+
if not node_ids:
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
# Initializes all with 0.0 (in case they have no edges)
|
|
243
|
+
result: dict[int, float] = {nid: 0.0 for nid in node_ids}
|
|
244
|
+
|
|
245
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
246
|
+
with self._conn.read() as conn:
|
|
247
|
+
rows = conn.execute(
|
|
248
|
+
f"""SELECT target_id, COUNT(*) AS in_deg
|
|
249
|
+
FROM edges
|
|
250
|
+
WHERE target_id IN ({placeholders})
|
|
251
|
+
GROUP BY target_id""",
|
|
252
|
+
tuple(node_ids)
|
|
253
|
+
).fetchall()
|
|
254
|
+
|
|
255
|
+
for row in rows:
|
|
256
|
+
in_deg = row["in_deg"]
|
|
257
|
+
result[row["target_id"]] = min(in_deg / self.CENTRALITY_MAX_IN_DEGREE, 1.0)
|
|
258
|
+
|
|
259
|
+
super_nodes = [nid for nid, score in result.items() if score >= 1.0]
|
|
260
|
+
if super_nodes:
|
|
261
|
+
logger.debug("Super-Nodes detected (batch): %s", super_nodes)
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# ===================================================================
|
|
266
|
+
# 3. RECENCY SCORE (Exponential Decay)
|
|
267
|
+
# ===================================================================
|
|
268
|
+
|
|
269
|
+
def compute_recency_score(self, node_id: int) -> float:
|
|
270
|
+
"""Calculates recency score via exponential decay.
|
|
271
|
+
|
|
272
|
+
Formula: max(e^(-λ × t), RECENCY_MIN_SCORE)
|
|
273
|
+
Where:
|
|
274
|
+
λ = ln(2) / 7 ≈ 0.09902
|
|
275
|
+
t = days since nodes.last_commit_at
|
|
276
|
+
|
|
277
|
+
If last_commit_at is NULL, returns RECENCY_MIN_SCORE (0.01).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
node_id: Node ID.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Float in interval [0.01, 1.0].
|
|
284
|
+
"""
|
|
285
|
+
with self._conn.read() as conn:
|
|
286
|
+
row = conn.execute(
|
|
287
|
+
"SELECT last_commit_at FROM nodes WHERE id = ?",
|
|
288
|
+
(node_id,)
|
|
289
|
+
).fetchone()
|
|
290
|
+
|
|
291
|
+
if row is None or row["last_commit_at"] is None:
|
|
292
|
+
return self.RECENCY_MIN_SCORE
|
|
293
|
+
|
|
294
|
+
return self._calculate_decay(row["last_commit_at"])
|
|
295
|
+
|
|
296
|
+
def compute_recency_batch(self, node_ids: list[int]) -> dict[int, float]:
|
|
297
|
+
"""Calculates recency in batch for multiple nodes.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
node_ids: List of node IDs.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dict mapping node_id → recency score [0.01, 1.0].
|
|
304
|
+
"""
|
|
305
|
+
if not node_ids:
|
|
306
|
+
return {}
|
|
307
|
+
|
|
308
|
+
result: dict[int, float] = {}
|
|
309
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
310
|
+
|
|
311
|
+
with self._conn.read() as conn:
|
|
312
|
+
rows = conn.execute(
|
|
313
|
+
f"SELECT id, last_commit_at FROM nodes WHERE id IN ({placeholders})",
|
|
314
|
+
tuple(node_ids)
|
|
315
|
+
).fetchall()
|
|
316
|
+
|
|
317
|
+
for row in rows:
|
|
318
|
+
if row["last_commit_at"] is None:
|
|
319
|
+
result[row["id"]] = self.RECENCY_MIN_SCORE
|
|
320
|
+
else:
|
|
321
|
+
result[row["id"]] = self._calculate_decay(row["last_commit_at"])
|
|
322
|
+
|
|
323
|
+
# Ensures not found nodes return minimum score
|
|
324
|
+
for nid in node_ids:
|
|
325
|
+
if nid not in result:
|
|
326
|
+
result[nid] = self.RECENCY_MIN_SCORE
|
|
327
|
+
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
def _calculate_decay(self, last_commit_at: str) -> float:
|
|
331
|
+
"""Applies exponential decay formula.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
last_commit_at: ISO timestamp of last commit (e.g. '2026-05-01 12:00:00').
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Score in interval [RECENCY_MIN_SCORE, 1.0].
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
commit_dt = datetime.fromisoformat(last_commit_at)
|
|
341
|
+
now = datetime.utcnow()
|
|
342
|
+
delta_days = max((now - commit_dt).total_seconds() / 86400, 0.0)
|
|
343
|
+
except (ValueError, TypeError):
|
|
344
|
+
logger.warning("invalid last_commit_at: '%s'. Using minimum score.", last_commit_at)
|
|
345
|
+
return self.RECENCY_MIN_SCORE
|
|
346
|
+
|
|
347
|
+
# e^(-λ × t) with floor at MIN_SCORE
|
|
348
|
+
score = math.exp(-self.RECENCY_LAMBDA * delta_days)
|
|
349
|
+
return max(score, self.RECENCY_MIN_SCORE)
|
|
350
|
+
|
|
351
|
+
# ===================================================================
|
|
352
|
+
# 4. FTS5 — Text search with normalized BM25
|
|
353
|
+
# ===================================================================
|
|
354
|
+
|
|
355
|
+
def fts_search(
|
|
356
|
+
self,
|
|
357
|
+
query: str,
|
|
358
|
+
project_uuid: Optional[str] = None,
|
|
359
|
+
node_type: Optional[str] = None,
|
|
360
|
+
limit: int = 20,
|
|
361
|
+
) -> list[dict]:
|
|
362
|
+
"""Text search via FTS5 with BM25 normalized to [0, 1].
|
|
363
|
+
|
|
364
|
+
SQLite returns bm25() as negative value (more negative = more relevant).
|
|
365
|
+
We normalize to [0, 1] using: 1.0 - (rank / min_rank)
|
|
366
|
+
where min_rank is the most negative (most relevant) value of the batch.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
query: Search text. Special characters are escaped.
|
|
370
|
+
project_uuid: Optional filter by project (Strict Scoping).
|
|
371
|
+
node_type: Targeted filter (FACT, SKILL, INSIGHT, TRAJECTORY, PATCH).
|
|
372
|
+
limit: Maximum results.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of dicts with node fields + normalized 'bm25_score' [0, 1].
|
|
376
|
+
Sorted from most relevant to least relevant.
|
|
377
|
+
"""
|
|
378
|
+
# Escapes quotes in query to prevent FTS5 injection
|
|
379
|
+
safe_query = query.replace('"', '""')
|
|
380
|
+
|
|
381
|
+
# Assembles the SQL query dynamically according to the filters
|
|
382
|
+
sql = """
|
|
383
|
+
SELECT n.*, bm25(nodes_fts) AS rank
|
|
384
|
+
FROM nodes_fts f
|
|
385
|
+
JOIN nodes n ON n.id = f.rowid
|
|
386
|
+
WHERE nodes_fts MATCH ?
|
|
387
|
+
"""
|
|
388
|
+
params: list[Any] = [f'"{safe_query}"']
|
|
389
|
+
|
|
390
|
+
if project_uuid:
|
|
391
|
+
sql += " AND n.project_uuid = ?"
|
|
392
|
+
params.append(project_uuid)
|
|
393
|
+
|
|
394
|
+
if node_type:
|
|
395
|
+
sql += " AND n.node_type = ?"
|
|
396
|
+
params.append(node_type)
|
|
397
|
+
|
|
398
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
399
|
+
params.append(limit)
|
|
400
|
+
|
|
401
|
+
with self._conn.read() as conn:
|
|
402
|
+
rows = conn.execute(sql, tuple(params)).fetchall()
|
|
403
|
+
|
|
404
|
+
if not rows:
|
|
405
|
+
return []
|
|
406
|
+
|
|
407
|
+
results = [dict(r) for r in rows]
|
|
408
|
+
|
|
409
|
+
# Normalizes BM25: the most negative rank becomes 1.0 and the least negative becomes ~0.0
|
|
410
|
+
min_rank = min(r["rank"] for r in results) # most negative value
|
|
411
|
+
for r in results:
|
|
412
|
+
if min_rank < 0:
|
|
413
|
+
r["bm25_score"] = round(r["rank"] / min_rank, 4)
|
|
414
|
+
else:
|
|
415
|
+
r["bm25_score"] = 1.0
|
|
416
|
+
del r["rank"] # Removes internal SQLite field
|
|
417
|
+
|
|
418
|
+
return results
|
|
419
|
+
|
|
420
|
+
def fts_rebuild(self) -> None:
|
|
421
|
+
"""Rebuilds the complete FTS5 index.
|
|
422
|
+
|
|
423
|
+
Use after massive ingestion (concierge mine) to optimize performance.
|
|
424
|
+
"""
|
|
425
|
+
def _do(conn: Any) -> None:
|
|
426
|
+
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');")
|
|
427
|
+
|
|
428
|
+
self._conn.write(_do)
|
|
429
|
+
logger.info("FTS5 index rebuilt successfully.")
|
|
430
|
+
|
|
431
|
+
# ===================================================================
|
|
432
|
+
# 5. WEIGHTED HYBRID SEARCH (Hybrid Search v4)
|
|
433
|
+
# ===================================================================
|
|
434
|
+
|
|
435
|
+
def hybrid_search_score(
|
|
436
|
+
self,
|
|
437
|
+
node_id: int,
|
|
438
|
+
vector_score: float,
|
|
439
|
+
fts_score: float,
|
|
440
|
+
) -> dict:
|
|
441
|
+
"""Calculates final combined score for a node in Hybrid Search v4.
|
|
442
|
+
|
|
443
|
+
Formula (Architecture v3.8):
|
|
444
|
+
score = (0.50 × vector)
|
|
445
|
+
+ (0.25 × normalized_fts5)
|
|
446
|
+
+ (0.25 × max(recency, centrality))
|
|
447
|
+
|
|
448
|
+
The third component uses max(recency, centrality) to protect
|
|
449
|
+
stable "core code": if a node is old (low recency) but has
|
|
450
|
+
high centrality (many dependents), centrality prevails.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
node_id: Node ID.
|
|
454
|
+
vector_score: Vector similarity score [0, 1] (from pluggable backend).
|
|
455
|
+
fts_score: Normalized BM25 score [0, 1] (from fts_search).
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Dict with complete breakdown:
|
|
459
|
+
{
|
|
460
|
+
"node_id": int,
|
|
461
|
+
"score_final": float,
|
|
462
|
+
"score_breakdown": {
|
|
463
|
+
"vetorial": float,
|
|
464
|
+
"frequencia": float,
|
|
465
|
+
"recencia": float,
|
|
466
|
+
"centralidade": float
|
|
467
|
+
},
|
|
468
|
+
"is_super_node": bool
|
|
469
|
+
}
|
|
470
|
+
"""
|
|
471
|
+
# Calculates individual components
|
|
472
|
+
recency = self.compute_recency_score(node_id)
|
|
473
|
+
centrality = self.compute_centrality(node_id)
|
|
474
|
+
|
|
475
|
+
# max(recency, centrality): protects stable Super-Nodes
|
|
476
|
+
recency_centrality = max(recency, centrality)
|
|
477
|
+
|
|
478
|
+
# Weighted final score
|
|
479
|
+
score_final = (
|
|
480
|
+
self.WEIGHT_VECTOR * vector_score
|
|
481
|
+
+ self.WEIGHT_FTS5 * fts_score
|
|
482
|
+
+ self.WEIGHT_RECENCY_CENTRALITY * recency_centrality
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
"node_id": node_id,
|
|
487
|
+
"score_final": round(score_final, 4),
|
|
488
|
+
"score_breakdown": {
|
|
489
|
+
"vetorial": round(vector_score, 4),
|
|
490
|
+
"frequencia": round(fts_score, 4),
|
|
491
|
+
"recencia": round(recency, 4),
|
|
492
|
+
"centralidade": round(centrality, 4),
|
|
493
|
+
},
|
|
494
|
+
"is_super_node": centrality >= 1.0,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
def hybrid_search_score_batch(
|
|
498
|
+
self,
|
|
499
|
+
candidates: list[dict],
|
|
500
|
+
) -> list[dict]:
|
|
501
|
+
"""Calculates hybrid scores in batch and returns sorted by relevance.
|
|
502
|
+
|
|
503
|
+
Optimized: does centrality and recency queries in batch
|
|
504
|
+
instead of N individual queries.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
candidates: List of dicts, each containing:
|
|
508
|
+
- "node_id" (int)
|
|
509
|
+
- "vector_score" (float) — from vector backend
|
|
510
|
+
- "fts_score" (float) — from fts_search (normalized BM25)
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
List of dicts with score_final and breakdown, sorted DESC by score.
|
|
514
|
+
"""
|
|
515
|
+
if not candidates:
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
node_ids = [c["node_id"] for c in candidates]
|
|
519
|
+
|
|
520
|
+
# Batch queries — 2 SQL queries instead of 2N
|
|
521
|
+
centrality_map = self.compute_centrality_batch(node_ids)
|
|
522
|
+
recency_map = self.compute_recency_batch(node_ids)
|
|
523
|
+
|
|
524
|
+
results = []
|
|
525
|
+
for c in candidates:
|
|
526
|
+
nid = c["node_id"]
|
|
527
|
+
vector_score = c.get("vector_score", 0.0)
|
|
528
|
+
fts_score = c.get("fts_score", 0.0)
|
|
529
|
+
recency = recency_map.get(nid, self.RECENCY_MIN_SCORE)
|
|
530
|
+
centrality = centrality_map.get(nid, 0.0)
|
|
531
|
+
|
|
532
|
+
recency_centrality = max(recency, centrality)
|
|
533
|
+
score_final = (
|
|
534
|
+
self.WEIGHT_VECTOR * vector_score
|
|
535
|
+
+ self.WEIGHT_FTS5 * fts_score
|
|
536
|
+
+ self.WEIGHT_RECENCY_CENTRALITY * recency_centrality
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
results.append({
|
|
540
|
+
"node_id": nid,
|
|
541
|
+
"score_final": round(score_final, 4),
|
|
542
|
+
"score_breakdown": {
|
|
543
|
+
"vetorial": round(vector_score, 4),
|
|
544
|
+
"frequencia": round(fts_score, 4),
|
|
545
|
+
"recencia": round(recency, 4),
|
|
546
|
+
"centralidade": round(centrality, 4),
|
|
547
|
+
},
|
|
548
|
+
"is_super_node": centrality >= 1.0,
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
# Sorts by score_final DESC (most relevant first)
|
|
552
|
+
results.sort(key=lambda x: x["score_final"], reverse=True)
|
|
553
|
+
|
|
554
|
+
logger.debug(
|
|
555
|
+
"Hybrid batch: %d candidates processed. Top score=%.4f",
|
|
556
|
+
len(results), results[0]["score_final"] if results else 0.0
|
|
557
|
+
)
|
|
558
|
+
return results
|
|
559
|
+
|
|
560
|
+
# ===================================================================
|
|
561
|
+
# 6. RECURSIVE QUERIES (CTE) with loop protection
|
|
562
|
+
# ===================================================================
|
|
563
|
+
|
|
564
|
+
def get_dependency_tree(
|
|
565
|
+
self, start_node_id: int, max_depth: int = 10
|
|
566
|
+
) -> list[dict]:
|
|
567
|
+
"""Dependency tree: source → target (who this node depends on).
|
|
568
|
+
|
|
569
|
+
CTE with depth limit to prevent infinite loops.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
start_node_id: Root node.
|
|
573
|
+
max_depth: Maximum depth (default: 10).
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
List of dicts: id, label, node_type, depth.
|
|
577
|
+
"""
|
|
578
|
+
sql = """
|
|
579
|
+
WITH RECURSIVE dep_tree(id, label, node_type, depth) AS (
|
|
580
|
+
SELECT id, label, node_type, 0
|
|
581
|
+
FROM nodes WHERE id = ?
|
|
582
|
+
UNION ALL
|
|
583
|
+
SELECT n.id, n.label, n.node_type, dt.depth + 1
|
|
584
|
+
FROM nodes n
|
|
585
|
+
JOIN edges e ON n.id = e.target_id
|
|
586
|
+
JOIN dep_tree dt ON e.source_id = dt.id
|
|
587
|
+
WHERE dt.depth < ?
|
|
588
|
+
)
|
|
589
|
+
SELECT DISTINCT * FROM dep_tree ORDER BY depth;
|
|
590
|
+
"""
|
|
591
|
+
with self._conn.read() as conn:
|
|
592
|
+
rows = conn.execute(sql, (start_node_id, max_depth)).fetchall()
|
|
593
|
+
return [dict(r) for r in rows]
|
|
594
|
+
|
|
595
|
+
def get_reverse_dependency_tree(
|
|
596
|
+
self, start_node_id: int, max_depth: int = 10
|
|
597
|
+
) -> list[dict]:
|
|
598
|
+
"""Reverse dependency tree: who depends ON THIS node (target → source).
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
start_node_id: Target node.
|
|
602
|
+
max_depth: Maximum depth.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
List of dicts: id, label, node_type, depth.
|
|
606
|
+
"""
|
|
607
|
+
sql = """
|
|
608
|
+
WITH RECURSIVE rev_tree(id, label, node_type, depth) AS (
|
|
609
|
+
SELECT id, label, node_type, 0
|
|
610
|
+
FROM nodes WHERE id = ?
|
|
611
|
+
UNION ALL
|
|
612
|
+
SELECT n.id, n.label, n.node_type, rt.depth + 1
|
|
613
|
+
FROM nodes n
|
|
614
|
+
JOIN edges e ON n.id = e.source_id
|
|
615
|
+
JOIN rev_tree rt ON e.target_id = rt.id
|
|
616
|
+
WHERE rt.depth < ?
|
|
617
|
+
)
|
|
618
|
+
SELECT DISTINCT * FROM rev_tree ORDER BY depth;
|
|
619
|
+
"""
|
|
620
|
+
with self._conn.read() as conn:
|
|
621
|
+
rows = conn.execute(sql, (start_node_id, max_depth)).fetchall()
|
|
622
|
+
return [dict(r) for r in rows]
|
|
623
|
+
|
|
624
|
+
# ===================================================================
|
|
625
|
+
# 7. PROJECT STATISTICS
|
|
626
|
+
# ===================================================================
|
|
627
|
+
|
|
628
|
+
def get_project_stats(self, project_uuid: str) -> dict:
|
|
629
|
+
"""Complete statistics of a project via aggregated SQL.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Dict with: nodes, nodes_by_type, edges, commits,
|
|
633
|
+
last_commit_at, trajectories, trajectories_active.
|
|
634
|
+
"""
|
|
635
|
+
with self._conn.read() as conn:
|
|
636
|
+
# Total node count
|
|
637
|
+
nodes_total = conn.execute(
|
|
638
|
+
"SELECT COUNT(*) AS c FROM nodes WHERE project_uuid = ?",
|
|
639
|
+
(project_uuid,)
|
|
640
|
+
).fetchone()["c"]
|
|
641
|
+
|
|
642
|
+
# Count by node type
|
|
643
|
+
type_rows = conn.execute(
|
|
644
|
+
"""SELECT node_type, COUNT(*) AS c FROM nodes
|
|
645
|
+
WHERE project_uuid = ? GROUP BY node_type""",
|
|
646
|
+
(project_uuid,)
|
|
647
|
+
).fetchall()
|
|
648
|
+
nodes_by_type = {r["node_type"]: r["c"] for r in type_rows}
|
|
649
|
+
|
|
650
|
+
# Project edges
|
|
651
|
+
edges_count = conn.execute(
|
|
652
|
+
"""SELECT COUNT(*) AS c FROM edges e
|
|
653
|
+
JOIN nodes n ON e.source_id = n.id
|
|
654
|
+
WHERE n.project_uuid = ?""",
|
|
655
|
+
(project_uuid,)
|
|
656
|
+
).fetchone()["c"]
|
|
657
|
+
|
|
658
|
+
# Commits
|
|
659
|
+
commits_count = conn.execute(
|
|
660
|
+
"SELECT COUNT(*) AS c FROM commit_log WHERE project_uuid = ?",
|
|
661
|
+
(project_uuid,)
|
|
662
|
+
).fetchone()["c"]
|
|
663
|
+
|
|
664
|
+
last_commit = conn.execute(
|
|
665
|
+
"SELECT MAX(created_at) AS last_at FROM commit_log WHERE project_uuid = ?",
|
|
666
|
+
(project_uuid,)
|
|
667
|
+
).fetchone()["last_at"]
|
|
668
|
+
|
|
669
|
+
# Trajectories
|
|
670
|
+
traj_total = conn.execute(
|
|
671
|
+
"SELECT COUNT(*) AS c FROM trajectories WHERE project_uuid = ?",
|
|
672
|
+
(project_uuid,)
|
|
673
|
+
).fetchone()["c"]
|
|
674
|
+
|
|
675
|
+
traj_active = conn.execute(
|
|
676
|
+
"SELECT COUNT(*) AS c FROM trajectories WHERE project_uuid = ? AND status = 'ACTIVE'",
|
|
677
|
+
(project_uuid,)
|
|
678
|
+
).fetchone()["c"]
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
"nodes": nodes_total,
|
|
682
|
+
"nodes_by_type": nodes_by_type,
|
|
683
|
+
"edges": edges_count,
|
|
684
|
+
"commits": commits_count,
|
|
685
|
+
"last_commit_at": last_commit,
|
|
686
|
+
"trajectories": traj_total,
|
|
687
|
+
"trajectories_active": traj_active,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
def get_last_commit_phase(self, project_uuid: str) -> Optional[str]:
|
|
691
|
+
"""Returns the phase of the project's most recent commit.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
String of phase (e.g. 'build') or None if no commits.
|
|
695
|
+
"""
|
|
696
|
+
with self._conn.read() as conn:
|
|
697
|
+
row = conn.execute(
|
|
698
|
+
"""SELECT phase FROM commit_log
|
|
699
|
+
WHERE project_uuid = ?
|
|
700
|
+
ORDER BY created_at DESC LIMIT 1""",
|
|
701
|
+
(project_uuid,)
|
|
702
|
+
).fetchone()
|
|
703
|
+
return row["phase"] if row else None
|