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.
core/hybrid_search.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ core/hybrid_search.py — Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Hybrid Search Engine v4 — Complete orchestration of the search pipeline.
5
+
6
+ This module is the heart of memory retrieval. It orchestrates the three
7
+ relevance signals and combines them in the official weighted formula:
8
+
9
+ score = (0.50 × vector)
10
+ + (0.25 × normalized_fts5)
11
+ + (0.25 × max(recency, centrality))
12
+
13
+ Pipeline flow:
14
+ 1. STRICT SCOPING — ProjectIndex resolves which project_uuids are
15
+ in scope (Primary Wing, +References, or All Wings).
16
+ 2. EMBEDDING — EmbeddingManager generates the query vector.
17
+ 3. VECTOR SEARCH — ChromaVectorStore.search() returns candidates
18
+ with cosine similarity scores.
19
+ 4. FTS5 SEARCH — SqliteStore.fts_search() returns candidates with
20
+ normalized BM25 scores.
21
+ 5. MERGE — Merges both candidate sets by node_id.
22
+ 6. HYBRID SCORE — GraphLogic.hybrid_search_score_batch() calculates
23
+ the final weighted score.
24
+ 7. SORT — Returns sorted by final_score DESC.
25
+
26
+ Usage & Integration:
27
+ - core.project_index.ProjectIndex → Strict Scoping
28
+ - storage.vector_store.ChromaVectorStore → Vector search
29
+ - storage.vector_store.EmbeddingManager → Embedding generation
30
+ - storage.store.SqliteStore → FTS5 + Hybrid Score Batch
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ from typing import Any, Optional
37
+
38
+ from core.config import ConciergeConfig, DEFAULT_CONFIG
39
+ from core.project_index import ProjectIndex
40
+ from storage.store import SqliteStore
41
+ from storage.vector_store import ChromaVectorStore, EmbeddingManager
42
+
43
+ logger = logging.getLogger("grafo-concierge.hybrid-search")
44
+
45
+
46
+ class HybridSearchEngine:
47
+ """Hybrid Search Engine v4 — complete pipeline.
48
+
49
+ Combines vector search, FTS5, and Max(Recency, Centrality)
50
+ into a single optimized flow.
51
+
52
+ Args:
53
+ sqlite_store: SQLite facade for FTS5 and score batch.
54
+ vector_store: Vector backend for cosine similarity.
55
+ embedding_manager: Query embedding generator.
56
+ project_index: Knowledge GPS for Strict Scoping.
57
+ config: System parameters.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ sqlite_store: SqliteStore,
63
+ vector_store: ChromaVectorStore,
64
+ embedding_manager: EmbeddingManager,
65
+ project_index: ProjectIndex,
66
+ config: ConciergeConfig = DEFAULT_CONFIG,
67
+ ) -> None:
68
+ self._store = sqlite_store
69
+ self._vector = vector_store
70
+ self._embedder = embedding_manager
71
+ self._project_index = project_index
72
+ self._config = config
73
+
74
+ # ===================================================================
75
+ # SEARCH — Main Entry Point
76
+ # ===================================================================
77
+
78
+ def search(
79
+ self,
80
+ query: str,
81
+ project_uuid: str,
82
+ top_k: Optional[int] = None,
83
+ include_references: bool = False,
84
+ all_wings: bool = False,
85
+ node_type: Optional[str] = None,
86
+ enable_probabilistic: bool = False,
87
+ ) -> list[dict]:
88
+ """Executes the complete Hybrid Search v4.
89
+
90
+ This method implements the 8-step pipeline described in
91
+ Architecture v3.8.
92
+
93
+ Args:
94
+ query: Search text (natural or technical language).
95
+ project_uuid: Anchor project UUID.
96
+ top_k: Maximum final results (default: config.search_top_k).
97
+ include_references: Include Reference Wings in scope.
98
+ all_wings: Search in all wings (ignores Strict Scoping).
99
+ node_type: Surgical filter (FACT, SKILL, INSIGHT, etc.).
100
+
101
+ Returns:
102
+ List of dicts with final_score and complete breakdown,
103
+ sorted by relevance (DESC).
104
+ Each dict contains:
105
+ {
106
+ "node_id": int,
107
+ "score_final": float,
108
+ "score_breakdown": {
109
+ "vector": float,
110
+ "frequency": float,
111
+ "recency": float,
112
+ "centrality": float,
113
+ },
114
+ "is_super_node": bool,
115
+ }
116
+ """
117
+ if top_k is None:
118
+ top_k = self._config.search_top_k
119
+
120
+ logger.info(
121
+ "Hybrid Search v4: query='%.50s...', project=%s, top_k=%d, "
122
+ "refs=%s, all_wings=%s, node_type=%s",
123
+ query, project_uuid, top_k,
124
+ include_references, all_wings, node_type,
125
+ )
126
+
127
+ # --- STEP 1: STRICT SCOPING ---
128
+ scoped_uuids = self._project_index.resolve_scoped_uuids(
129
+ project_uuid,
130
+ include_references=include_references,
131
+ all_wings=all_wings,
132
+ )
133
+
134
+ if not scoped_uuids:
135
+ logger.warning("Strict Scoping returned 0 projects — empty search.")
136
+ return []
137
+
138
+ logger.debug("Strict Scoping: %d projects in scope.", len(scoped_uuids))
139
+
140
+ # --- STEP 2: QUERY EMBEDDING ---
141
+ query_embedding = self._embedder.embed(query)
142
+ if query_embedding is None:
143
+ logger.error("Semantic Fallback: query embedding failed. Returning FTS-only.")
144
+ return self._fts_only_fallback(query, project_uuid, node_type, top_k)
145
+
146
+ # --- STEP 3: VECTOR SEARCH ---
147
+ vector_results = self._vector.search(
148
+ query_embedding=query_embedding,
149
+ project_uuids=scoped_uuids,
150
+ top_k=top_k * 3, # Searches 3x to have enough candidates
151
+ filters={"node_type": node_type} if node_type else None,
152
+ )
153
+
154
+ logger.debug("Vector search returned %d candidates.", len(vector_results))
155
+
156
+ # Build dict of vector scores by node_id
157
+ # GROUP BY node_id, MAX(score) — as specified in Architecture
158
+ vector_scores: dict[int, float] = {}
159
+ for vr in vector_results:
160
+ if vr.node_id not in vector_scores or vr.score > vector_scores[vr.node_id]:
161
+ vector_scores[vr.node_id] = vr.score
162
+
163
+ # --- STEP 4: FTS5 SEARCH ---
164
+ fts_results = self._store.fts_search(
165
+ query=query,
166
+ project_uuid=project_uuid,
167
+ node_type=node_type,
168
+ limit=self._config.fts_limit,
169
+ )
170
+
171
+ logger.debug("FTS5 search returned %d candidates.", len(fts_results))
172
+
173
+ # Build dict of FTS scores by node_id
174
+ fts_scores: dict[int, float] = {}
175
+ for fr in fts_results:
176
+ node_id = fr.get("id")
177
+ if node_id is not None:
178
+ fts_scores[node_id] = fr.get("bm25_score", 0.0)
179
+
180
+ # --- STEP 5: MERGE — Combine candidates from both signals ---
181
+ all_node_ids = set(vector_scores.keys()) | set(fts_scores.keys())
182
+
183
+ if not all_node_ids:
184
+ logger.info("No candidates found in both signals.")
185
+ return []
186
+
187
+ candidates: list[dict] = []
188
+ for node_id in all_node_ids:
189
+ candidates.append({
190
+ "node_id": node_id,
191
+ "vector_score": vector_scores.get(node_id, 0.0),
192
+ "fts_score": fts_scores.get(node_id, 0.0),
193
+ })
194
+
195
+ logger.debug("Merge: %d unique candidates by node_id.", len(candidates))
196
+
197
+ # --- STEP 6: HYBRID SCORE ---
198
+ scored_results = self._store.hybrid_search_score_batch(candidates)
199
+
200
+ # --- STEP 7: THOMPSON SAMPLING (optional) ---
201
+ if enable_probabilistic:
202
+ scored_results = self._apply_thompson_sampling(scored_results)
203
+
204
+ # --- STEP 8: SORT and TRUNCATE ---
205
+ scored_results.sort(key=lambda x: x.get("score_final", 0), reverse=True)
206
+ final = scored_results[:top_k]
207
+
208
+ logger.info(
209
+ "Hybrid Search v4 complete: %d candidates → %d final results.",
210
+ len(candidates), len(final),
211
+ )
212
+ return final
213
+
214
+ # ===================================================================
215
+ # THOMPSON SAMPLING — Probabilistic multiplier (SA-CTS)
216
+ # ===================================================================
217
+
218
+ def _apply_thompson_sampling(self, results: list[dict]) -> list[dict]:
219
+ """Applies Thompson sampling probabilistic multiplier to score_final.
220
+
221
+ For each candidate, gets utility_alpha and utility_beta from
222
+ node metadata and samples a multiplier via Beta Distribution.
223
+ The score_final is multiplied by this factor, balancing
224
+ exploration (rarely accessed nodes) and exploitation (proven nodes).
225
+
226
+ Args:
227
+ results: List of dicts with score_final and score_breakdown.
228
+
229
+ Returns:
230
+ Same list with score_final adjusted by Thompson multiplier.
231
+ """
232
+ from core.probabilistic_retriever import ThompsonRetriever
233
+
234
+ for item in results:
235
+ try:
236
+ node = self._store.get_node(item["node_id"])
237
+ alpha = float(node.get("utility_alpha", 1.0) or 1.0)
238
+ beta_param = float(node.get("utility_beta", 1.0) or 1.0)
239
+ except Exception:
240
+ alpha, beta_param = 1.0, 1.0
241
+
242
+ multiplier = ThompsonRetriever.sample_multiplier(alpha, beta_param)
243
+ item["score_final"] = round(item["score_final"] * multiplier, 4)
244
+ item["score_breakdown"]["thompson_multiplier"] = round(multiplier, 4)
245
+
246
+ return results
247
+
248
+ # ===================================================================
249
+ # FALLBACK — Busca apenas FTS5 quando embedding falha
250
+ # ===================================================================
251
+
252
+ def _fts_only_fallback(
253
+ self,
254
+ query: str,
255
+ project_uuid: str,
256
+ node_type: Optional[str],
257
+ top_k: int,
258
+ ) -> list[dict]:
259
+ """Fallback: returns results from FTS5 only.
260
+
261
+ Used when query embedding fails (Semantic Fallback).
262
+ The results will have no vector score, only BM25 + recency/centrality.
263
+ """
264
+ logger.warning("FTS-only fallback active — results without vector component.")
265
+
266
+ fts_results = self._store.fts_search(
267
+ query=query,
268
+ project_uuid=project_uuid,
269
+ node_type=node_type,
270
+ limit=top_k,
271
+ )
272
+
273
+ if not fts_results:
274
+ return []
275
+
276
+ candidates = [
277
+ {
278
+ "node_id": fr["id"],
279
+ "vector_score": 0.0, # No vector component
280
+ "fts_score": fr.get("bm25_score", 0.0),
281
+ }
282
+ for fr in fts_results
283
+ if fr.get("id") is not None
284
+ ]
285
+
286
+ scored = self._store.hybrid_search_score_batch(candidates)
287
+ scored.sort(key=lambda x: x.get("score_final", 0), reverse=True)
288
+ return scored[:top_k]
@@ -0,0 +1,245 @@
1
+ """
2
+ core/memory_extractor.py — Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Semantic Extraction Engine and NOOP decision pipeline of the Hybrid Conversational Layer.
5
+ Ensures that new facts are classified and recorded in pure bi-temporal form (invalidation + insertion).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ import sqlite3
14
+ from typing import Any, Optional
15
+
16
+ from storage.semantic_logic import insert_semantic_fact, invalidate_semantic_fact, get_active_semantic_facts
17
+
18
+ logger = logging.getLogger("grafo-concierge.memory-extractor")
19
+
20
+ _JSON_BLOCK_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
21
+
22
+ DECISION_PROMPT_TEMPLATE = """You are an AI memory manager. Compare the NEW FACT below against the list of EXISTING FACTS for this scope, and decide which action to take: ADD, UPDATE, DELETE, or NOOP.
23
+
24
+ EXISTING FACTS:
25
+ {existing_facts_block}
26
+
27
+ NEW FACT to evaluate:
28
+ "{new_fact}"
29
+
30
+ INSTRUCTIONS:
31
+ 1. Choose "NOOP" if the NEW FACT is already fully covered by or redundant with the EXISTING FACTS, without adding any new or different information.
32
+ 2. Choose "ADD" if the NEW FACT is completely new, unrelated to any of the EXISTING FACTS, and doesn't contradict or update them.
33
+ 3. Choose "UPDATE" if the NEW FACT updates, refines, or adds details to an EXISTING FACT. You must provide the exact ID of the existing fact to update, and the new consolidated "updated_statement" that merges both facts cleanly (e.g. keeping it concise and natural).
34
+ 4. Choose "DELETE" if the NEW FACT explicitly contradicts, negates, or revokes an EXISTING FACT. You must provide the exact ID of the existing fact to delete.
35
+
36
+ You MUST respond with a single, valid JSON object and nothing else. No markdown formatting, no backticks, no comments.
37
+ JSON format:
38
+ {{
39
+ "action": "ADD" | "UPDATE" | "DELETE" | "NOOP",
40
+ "target_id": integer ID or null,
41
+ "updated_statement": string or null
42
+ }}"""
43
+
44
+
45
+ class SemanticExtractor:
46
+ """Processing and consolidation engine for semantic facts within memory scopes.
47
+
48
+ Uses an LLM adapter to classify new facts and decides whether to create them (ADD),
49
+ update them under bi-temporal logic (UPDATE), revoke them (DELETE), or ignore them (NOOP).
50
+ """
51
+
52
+ def __init__(self, llm_adapter: Any) -> None:
53
+ """Initializes the memory extractor.
54
+
55
+ Args:
56
+ llm_adapter: LLM adaptation object (e.g. LLMAdapter or equivalent mock).
57
+ """
58
+ self.llm = llm_adapter
59
+
60
+ def evaluate_and_store_facts(
61
+ self,
62
+ conn: sqlite3.Connection,
63
+ scope_type: str,
64
+ scope_id: str,
65
+ new_facts: list[str]
66
+ ) -> list[dict[str, Any]]:
67
+ """Evaluates new facts and stores them according to NOOP / bi-temporal logic.
68
+
69
+ Args:
70
+ conn: Direct connection to SQLite (used under a single transaction).
71
+ scope_type: Scope of the fact ('user', 'session', 'agent', 'org').
72
+ scope_id: Identifying ID of the scope.
73
+ new_facts: List of strings containing the raw memories to evaluate.
74
+
75
+ Returns:
76
+ List of dictionaries detailing the decisions made for each fact.
77
+ """
78
+ results: list[dict[str, Any]] = []
79
+
80
+ for new_fact in new_facts:
81
+ new_fact = new_fact.strip()
82
+ if not new_fact:
83
+ continue
84
+
85
+ active_facts = get_active_semantic_facts(conn, scope_type, scope_id)
86
+ if not active_facts:
87
+ # Optimization: if there are no registered facts, it is obligatorily an ADD
88
+ fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
89
+ results.append({
90
+ "fact": new_fact,
91
+ "action": "ADD",
92
+ "target_id": None,
93
+ "fact_id": fact_id
94
+ })
95
+ continue
96
+
97
+ # Builds block of existing facts for injection into the prompt
98
+ existing_facts_block = "\n".join(
99
+ f"- ID {f['id']}: {f['fact_statement']}" for f in active_facts
100
+ )
101
+
102
+ prompt = DECISION_PROMPT_TEMPLATE.format(
103
+ existing_facts_block=existing_facts_block,
104
+ new_fact=new_fact
105
+ )
106
+
107
+ try:
108
+ raw_response = self.llm.generate(prompt, max_tokens=300)
109
+ parsed = self._extract_json_with_fallback(raw_response)
110
+ except Exception as e:
111
+ logger.error("Falha ao chamar LLM para avaliar o fato '%s': %s", new_fact, e)
112
+ parsed = None
113
+
114
+ if not parsed or "action" not in parsed:
115
+ logger.warning("Falha ao parsear decisão estruturada para o fato '%s'. Executando ADD.", new_fact)
116
+ fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
117
+ results.append({
118
+ "fact": new_fact,
119
+ "action": "ADD",
120
+ "target_id": None,
121
+ "fact_id": fact_id,
122
+ "fallback": True
123
+ })
124
+ continue
125
+
126
+ action = parsed.get("action")
127
+ target_id = parsed.get("target_id")
128
+ updated_statement = parsed.get("updated_statement")
129
+
130
+ try:
131
+ t_id = int(target_id) if target_id is not None else None
132
+ except (ValueError, TypeError):
133
+ t_id = None
134
+
135
+ active_ids = {f["id"] for f in active_facts}
136
+
137
+ if action == "NOOP":
138
+ results.append({
139
+ "fact": new_fact,
140
+ "action": "NOOP",
141
+ "target_id": None,
142
+ "fact_id": None
143
+ })
144
+
145
+ elif action == "ADD":
146
+ fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
147
+ results.append({
148
+ "fact": new_fact,
149
+ "action": "ADD",
150
+ "target_id": None,
151
+ "fact_id": fact_id
152
+ })
153
+
154
+ elif action == "UPDATE":
155
+ if t_id is None or t_id not in active_ids:
156
+ # Invalid/non-existent ID in active facts results in pure insertion (ADD)
157
+ fact_id = insert_semantic_fact(conn, scope_type, scope_id, new_fact)
158
+ results.append({
159
+ "fact": new_fact,
160
+ "action": "ADD",
161
+ "target_id": None,
162
+ "fact_id": fact_id
163
+ })
164
+ else:
165
+ # Pure bi-temporal logic: invalidates the old one and inserts the new consolidated statement
166
+ invalidate_semantic_fact(conn, t_id)
167
+ stmt_to_insert = (updated_statement or new_fact).strip()
168
+ fact_id = insert_semantic_fact(conn, scope_type, scope_id, stmt_to_insert)
169
+ results.append({
170
+ "fact": new_fact,
171
+ "action": "UPDATE",
172
+ "target_id": t_id,
173
+ "fact_id": fact_id,
174
+ "updated_statement": stmt_to_insert
175
+ })
176
+
177
+ elif action == "DELETE":
178
+ if t_id is None or t_id not in active_ids:
179
+ # No valid ID for invalidation, treat as NOOP
180
+ results.append({
181
+ "fact": new_fact,
182
+ "action": "NOOP",
183
+ "target_id": None,
184
+ "fact_id": None
185
+ })
186
+ else:
187
+ invalidate_semantic_fact(conn, t_id)
188
+ results.append({
189
+ "fact": new_fact,
190
+ "action": "DELETE",
191
+ "target_id": t_id,
192
+ "fact_id": None
193
+ })
194
+
195
+ else:
196
+ # In case of unknown or invalid action, treat as NOOP
197
+ results.append({
198
+ "fact": new_fact,
199
+ "action": "NOOP",
200
+ "target_id": None,
201
+ "fact_id": None
202
+ })
203
+
204
+ return results
205
+
206
+ def _extract_json_with_fallback(self, raw_response: str) -> Optional[dict[str, Any]]:
207
+ """Attempts to extract the JSON dictionary from the LLM response with progressive fallback."""
208
+ if not raw_response or not raw_response.strip():
209
+ return None
210
+ text = raw_response.strip()
211
+
212
+ # Remove markdown delimiters if present
213
+ if text.startswith("```"):
214
+ lines = text.splitlines()
215
+ if lines[0].startswith("```"):
216
+ lines = lines[1:]
217
+ if lines and lines[-1].strip() == "```":
218
+ lines = lines[:-1]
219
+ text = "\n".join(lines).strip()
220
+
221
+ # Attempt 1: direct parsing
222
+ try:
223
+ return json.loads(text)
224
+ except (json.JSONDecodeError, ValueError):
225
+ pass
226
+
227
+ # Attempt 2: regex to capture braces block
228
+ matches = _JSON_BLOCK_RE.findall(text)
229
+ for match in matches:
230
+ try:
231
+ return json.loads(match)
232
+ except (json.JSONDecodeError, ValueError):
233
+ continue
234
+
235
+ # Attempt 3: simple capture of the first and last braces
236
+ first_brace = text.find("{")
237
+ last_brace = text.rfind("}")
238
+ if first_brace != -1 and last_brace > first_brace:
239
+ candidate = text[first_brace:last_brace + 1]
240
+ try:
241
+ return json.loads(candidate)
242
+ except (json.JSONDecodeError, ValueError):
243
+ pass
244
+
245
+ return None