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
agents/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agents/__init__.py — Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
agents/ package — Guardians of Evolution.
|
|
5
|
+
|
|
6
|
+
Exports the critical AI agents:
|
|
7
|
+
- RevisorCritico → Evolution Auditor + Drawer Reranking
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from agents.revisor_critico import RevisorCritico
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RevisorCritico",
|
|
14
|
+
]
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agents/revisor_critico.py — Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Evolution Auditor + Drawer Reranking.
|
|
5
|
+
|
|
6
|
+
The Critical Revisor is the guardian of Grafo Concierge's quality.
|
|
7
|
+
It acts in two distinct moments:
|
|
8
|
+
|
|
9
|
+
Role 1 — COMMIT AUDITING:
|
|
10
|
+
Validates Summarizer drafts before writing to the commit_log.
|
|
11
|
+
Requires the draft to contain:
|
|
12
|
+
- non-empty and readable technical_changes
|
|
13
|
+
- updated_pointers with at least 1 pointer
|
|
14
|
+
- No data contamination between Wings (Contamination Barrier)
|
|
15
|
+
If rejected, returns feedback to the Summarizer for a retry (max 3 loops).
|
|
16
|
+
After 3 rejections, approves with partial_audit=True (safe fallback).
|
|
17
|
+
|
|
18
|
+
Role 2 — DRAWER RERANKING:
|
|
19
|
+
On heavy triggers (on_build, on_done), receives the top-N results
|
|
20
|
+
from Hybrid Search v4 and uses the LLM as a judge to filter semantic noise.
|
|
21
|
+
Criteria:
|
|
22
|
+
- Technical relevance: does the node directly address the task?
|
|
23
|
+
- Freshness: was the node updated recently?
|
|
24
|
+
- Specificity: is the node specific (not generic)?
|
|
25
|
+
Returns only the nodes that passed the criteria (1 to N items).
|
|
26
|
+
|
|
27
|
+
Role 3 — CONTAMINATION BARRIER:
|
|
28
|
+
Validates that Reference Wings do not violate privacy_levels.
|
|
29
|
+
A RESTRICTED project cannot have its data exposed in
|
|
30
|
+
PUBLIC contexts. The revisor blocks this contamination.
|
|
31
|
+
|
|
32
|
+
Integration:
|
|
33
|
+
- Reuses ingestion.summarizer.LLMAdapter for LLM calls
|
|
34
|
+
- Consumes core.config.ConciergeConfig for limits and parameters
|
|
35
|
+
- Is consumed by core.middleware.GrafoConcierge and interface/action_hooks
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import re
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Any, Optional
|
|
45
|
+
|
|
46
|
+
from core.config import ConciergeConfig, DEFAULT_CONFIG
|
|
47
|
+
from ingestion.summarizer import LLMAdapter
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger("grafo-concierge.revisor-critico")
|
|
50
|
+
|
|
51
|
+
# Regex for extracting JSON from LLM responses
|
|
52
|
+
_JSON_BLOCK_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Typed results
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AuditResult:
|
|
61
|
+
"""Result of a commit audit.
|
|
62
|
+
|
|
63
|
+
Campos:
|
|
64
|
+
approved: True if the draft passed validation.
|
|
65
|
+
reason: Reason for approval or rejection.
|
|
66
|
+
technical_changes: Validated/corrected technical changes.
|
|
67
|
+
updated_pointers: List of validated pointers.
|
|
68
|
+
partial_audit: True if approved by fallback (3 rejections).
|
|
69
|
+
loop_count: Number of audit loops executed.
|
|
70
|
+
"""
|
|
71
|
+
approved: bool = False
|
|
72
|
+
reason: str = ""
|
|
73
|
+
technical_changes: str = ""
|
|
74
|
+
updated_pointers: list[str] = field(default_factory=list)
|
|
75
|
+
partial_audit: bool = False
|
|
76
|
+
loop_count: int = 0
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"approved": self.approved,
|
|
81
|
+
"reason": self.reason,
|
|
82
|
+
"technical_changes": self.technical_changes,
|
|
83
|
+
"updated_pointers": self.updated_pointers,
|
|
84
|
+
"partial_audit": self.partial_audit,
|
|
85
|
+
"loop_count": self.loop_count,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class RerankResult:
|
|
91
|
+
"""Result of a candidate reranking.
|
|
92
|
+
|
|
93
|
+
Fields:
|
|
94
|
+
candidates: Filtered list of approved candidates.
|
|
95
|
+
filtered_count: How many candidates were removed.
|
|
96
|
+
criteria_applied: Criteria used in filtering.
|
|
97
|
+
"""
|
|
98
|
+
candidates: list[dict] = field(default_factory=list)
|
|
99
|
+
filtered_count: int = 0
|
|
100
|
+
criteria_applied: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"candidates": self.candidates,
|
|
105
|
+
"filtered_count": self.filtered_count,
|
|
106
|
+
"criteria_applied": self.criteria_applied,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Revisor Prompts
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
_AUDIT_PROMPT_TEMPLATE = """You are a strict code commit auditor for a memory graph system.
|
|
115
|
+
Review the following commit draft and validate it meets ALL criteria:
|
|
116
|
+
|
|
117
|
+
1. "technical_changes" must be non-empty and describe specific code changes (function names, modules affected).
|
|
118
|
+
2. "updated_pointers" must contain at least 1 file path or module reference.
|
|
119
|
+
3. The content must NOT contain data from restricted projects mixed with public ones.
|
|
120
|
+
4. The summary must be in a professional, technical tone.
|
|
121
|
+
|
|
122
|
+
Commit Draft:
|
|
123
|
+
- Phase: {phase}
|
|
124
|
+
- Technical Changes: {technical_changes}
|
|
125
|
+
- Updated Pointers: {updated_pointers}
|
|
126
|
+
- Source Wing: {source_wing}
|
|
127
|
+
|
|
128
|
+
Respond with ONLY a valid JSON object:
|
|
129
|
+
{{
|
|
130
|
+
"approved": true/false,
|
|
131
|
+
"reason": "explanation of your decision",
|
|
132
|
+
"technical_changes": "cleaned/validated technical changes text",
|
|
133
|
+
"updated_pointers": ["pointer1", "pointer2"]
|
|
134
|
+
}}"""
|
|
135
|
+
|
|
136
|
+
_RERANK_PROMPT_TEMPLATE = """You are a search result relevance judge for a code knowledge graph.
|
|
137
|
+
Given a task description and a list of search results, evaluate each result's relevance.
|
|
138
|
+
|
|
139
|
+
Task: {task_context}
|
|
140
|
+
|
|
141
|
+
Search Results:
|
|
142
|
+
{candidates_block}
|
|
143
|
+
|
|
144
|
+
For each result, score its relevance (0.0 to 1.0) based on:
|
|
145
|
+
1. Technical relevance: Does this directly help with the task?
|
|
146
|
+
2. Freshness: Is the information current and accurate?
|
|
147
|
+
3. Specificity: Is it specific to the problem (not generic)?
|
|
148
|
+
|
|
149
|
+
Respond with ONLY a valid JSON object:
|
|
150
|
+
{{
|
|
151
|
+
"evaluations": [
|
|
152
|
+
{{"node_id": <id>, "relevance": <0.0-1.0>, "reason": "brief explanation"}},
|
|
153
|
+
...
|
|
154
|
+
]
|
|
155
|
+
}}"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# RevisorCritico — Guardian of Evolution
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
class RevisorCritico:
|
|
163
|
+
"""Evolution Auditor + Drawer Reranking.
|
|
164
|
+
|
|
165
|
+
Two modes of operation:
|
|
166
|
+
1. audit(draft) → Validates commit before recording
|
|
167
|
+
2. rerank(candidates, task_context) → Filters search results
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
llm_adapter: LLM adapter for AI calls.
|
|
171
|
+
If None, operates in heuristic mode (no LLM).
|
|
172
|
+
config: Centralized configurations.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
# Relevance threshold for reranking (0.0 - 1.0)
|
|
176
|
+
RERANK_RELEVANCE_THRESHOLD: float = 0.5
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
llm_adapter: Optional[LLMAdapter] = None,
|
|
181
|
+
config: ConciergeConfig = DEFAULT_CONFIG,
|
|
182
|
+
) -> None:
|
|
183
|
+
self._llm = llm_adapter
|
|
184
|
+
self._config = config
|
|
185
|
+
self._max_loops = config.max_revisor_loops
|
|
186
|
+
|
|
187
|
+
mode = "LLM" if llm_adapter else "heuristic"
|
|
188
|
+
logger.info("RevisorCritico initialized: mode=%s, max_loops=%d", mode, self._max_loops)
|
|
189
|
+
|
|
190
|
+
# ===================================================================
|
|
191
|
+
# ROLE 1 — COMMIT AUDITING
|
|
192
|
+
# ===================================================================
|
|
193
|
+
|
|
194
|
+
def audit(self, draft: dict) -> AuditResult:
|
|
195
|
+
"""Audits a commit draft.
|
|
196
|
+
|
|
197
|
+
First applies heuristic validation (strict rules).
|
|
198
|
+
If the LLM is available, performs additional semantic validation.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
draft: Dict with draft fields:
|
|
202
|
+
- phase (str): Current phase
|
|
203
|
+
- technical_changes (str): Description of changes
|
|
204
|
+
- updated_pointers (list[str]): Updated pointers
|
|
205
|
+
- source_wing (str, optional): Source wing
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
AuditResult with approval and feedback.
|
|
209
|
+
"""
|
|
210
|
+
result = AuditResult(loop_count=1)
|
|
211
|
+
|
|
212
|
+
# --- Heuristic validation (strict rules, without LLM) ---
|
|
213
|
+
heuristic_ok, heuristic_reason = self._heuristic_audit(draft)
|
|
214
|
+
|
|
215
|
+
if not heuristic_ok:
|
|
216
|
+
result.approved = False
|
|
217
|
+
result.reason = heuristic_reason
|
|
218
|
+
result.technical_changes = draft.get("technical_changes", "")
|
|
219
|
+
result.updated_pointers = draft.get("updated_pointers", [])
|
|
220
|
+
logger.info("Heuristic audit REJECTED: %s", heuristic_reason)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
# --- Semantic validation with LLM (if available) ---
|
|
224
|
+
if self._llm is not None:
|
|
225
|
+
return self._llm_audit(draft)
|
|
226
|
+
|
|
227
|
+
# Without LLM -> heuristic approval
|
|
228
|
+
result.approved = True
|
|
229
|
+
result.reason = "Aprovado por validação heurística (sem LLM disponível)."
|
|
230
|
+
result.technical_changes = draft.get("technical_changes", "")
|
|
231
|
+
result.updated_pointers = draft.get("updated_pointers", [])
|
|
232
|
+
logger.info("Heuristic audit APPROVED (no LLM mode).")
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
def _heuristic_audit(self, draft: dict) -> tuple[bool, str]:
|
|
236
|
+
"""Strict rule-based validation (without LLM).
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Tuple (passed: bool, reason: str).
|
|
240
|
+
"""
|
|
241
|
+
tc = draft.get("technical_changes", "")
|
|
242
|
+
up = draft.get("updated_pointers", [])
|
|
243
|
+
|
|
244
|
+
# Rule 1: technical_changes cannot be empty
|
|
245
|
+
if not tc or not tc.strip():
|
|
246
|
+
return False, "technical_changes is empty or contains only spaces."
|
|
247
|
+
|
|
248
|
+
# Rule 2: technical_changes too short (< 10 characters)
|
|
249
|
+
if len(tc.strip()) < 10:
|
|
250
|
+
return False, (
|
|
251
|
+
f"technical_changes too short ({len(tc.strip())} characters). "
|
|
252
|
+
"Describe the changes with more detail."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Rule 3: updated_pointers must have at least 1 item
|
|
256
|
+
if not up or (isinstance(up, list) and len(up) == 0):
|
|
257
|
+
return False, "updated_pointers is empty. Include at least 1 pointer."
|
|
258
|
+
|
|
259
|
+
# Rule 4: each pointer must be a non-empty string
|
|
260
|
+
if isinstance(up, list):
|
|
261
|
+
empty_ptrs = [p for p in up if not isinstance(p, str) or not p.strip()]
|
|
262
|
+
if empty_ptrs:
|
|
263
|
+
return False, (
|
|
264
|
+
f"{len(empty_ptrs)} invalid pointer(s) in updated_pointers. "
|
|
265
|
+
"Each pointer must be a non-empty string."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return True, "OK"
|
|
269
|
+
|
|
270
|
+
def _llm_audit(self, draft: dict) -> AuditResult:
|
|
271
|
+
"""Semantic validation via LLM (when available).
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
AuditResult with LLM decision.
|
|
275
|
+
"""
|
|
276
|
+
prompt = _AUDIT_PROMPT_TEMPLATE.format(
|
|
277
|
+
phase=draft.get("phase", "unknown"),
|
|
278
|
+
technical_changes=draft.get("technical_changes", ""),
|
|
279
|
+
updated_pointers=draft.get("updated_pointers", []),
|
|
280
|
+
source_wing=draft.get("source_wing", "geral"),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
result = AuditResult(loop_count=1)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
raw_response = self._llm.generate(prompt, max_tokens=300)
|
|
287
|
+
parsed = self._extract_json(raw_response)
|
|
288
|
+
|
|
289
|
+
if parsed and "approved" in parsed:
|
|
290
|
+
result.approved = bool(parsed["approved"])
|
|
291
|
+
result.reason = parsed.get("reason", "")
|
|
292
|
+
result.technical_changes = parsed.get(
|
|
293
|
+
"technical_changes",
|
|
294
|
+
draft.get("technical_changes", ""),
|
|
295
|
+
)
|
|
296
|
+
result.updated_pointers = parsed.get(
|
|
297
|
+
"updated_pointers",
|
|
298
|
+
draft.get("updated_pointers", []),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
status = "APPROVED" if result.approved else "REJECTED"
|
|
302
|
+
logger.info("LLM audit %s: %s", status, result.reason[:100])
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
logger.warning("LLM audit returned invalid JSON — heuristic fallback.")
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error("LLM audit failed: %s — heuristic fallback.", e)
|
|
309
|
+
|
|
310
|
+
# Fallback: heuristic approval
|
|
311
|
+
result.approved = True
|
|
312
|
+
result.reason = "Approved by fallback (LLM unavailable or invalid response)."
|
|
313
|
+
result.technical_changes = draft.get("technical_changes", "")
|
|
314
|
+
result.updated_pointers = draft.get("updated_pointers", [])
|
|
315
|
+
result.partial_audit = True
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
def audit_with_retry(
|
|
319
|
+
self,
|
|
320
|
+
draft: dict,
|
|
321
|
+
generate_fn: Optional[Any] = None,
|
|
322
|
+
) -> AuditResult:
|
|
323
|
+
"""Executes the complete audit loop (max N attempts).
|
|
324
|
+
|
|
325
|
+
If the revisor rejects, calls generate_fn to generate a new draft
|
|
326
|
+
with the rejection feedback. After max_loops rejections, approves with
|
|
327
|
+
partial_audit=True.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
draft: Initial draft.
|
|
331
|
+
generate_fn: Callable(task, outcome, feedback) -> dict
|
|
332
|
+
Function to regenerate the draft.
|
|
333
|
+
If None, returns the result of the first attempt.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
AuditResult final (approved or partial_audit).
|
|
337
|
+
"""
|
|
338
|
+
current_draft = draft
|
|
339
|
+
|
|
340
|
+
for attempt in range(1, self._max_loops + 1):
|
|
341
|
+
result = self.audit(current_draft)
|
|
342
|
+
result.loop_count = attempt
|
|
343
|
+
|
|
344
|
+
if result.approved:
|
|
345
|
+
logger.info(
|
|
346
|
+
"Commit approved on attempt %d/%d.",
|
|
347
|
+
attempt, self._max_loops,
|
|
348
|
+
)
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
logger.warning(
|
|
352
|
+
"Commit rejected (%d/%d): %s",
|
|
353
|
+
attempt, self._max_loops, result.reason,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# If no regeneration function is provided, return rejection
|
|
357
|
+
if generate_fn is None:
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
# Regenerate with feedback
|
|
361
|
+
try:
|
|
362
|
+
current_draft = generate_fn(result.reason)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error("Failed to regenerate draft: %s", e)
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
# Fallback after max_loops
|
|
368
|
+
logger.error(
|
|
369
|
+
"Commit not approved after %d attempts — partial_audit=True.",
|
|
370
|
+
self._max_loops,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
final = AuditResult(
|
|
374
|
+
approved=True,
|
|
375
|
+
reason=f"Approved by partial_audit after {self._max_loops} rejections.",
|
|
376
|
+
technical_changes=current_draft.get("technical_changes", ""),
|
|
377
|
+
updated_pointers=current_draft.get("updated_pointers", []),
|
|
378
|
+
partial_audit=True,
|
|
379
|
+
loop_count=self._max_loops,
|
|
380
|
+
)
|
|
381
|
+
return final
|
|
382
|
+
|
|
383
|
+
# ===================================================================
|
|
384
|
+
# ROLE 2 — DRAWER RERANKING
|
|
385
|
+
# ===================================================================
|
|
386
|
+
|
|
387
|
+
def rerank(
|
|
388
|
+
self,
|
|
389
|
+
candidates: list[dict],
|
|
390
|
+
task_context: str,
|
|
391
|
+
max_results: int = 5,
|
|
392
|
+
) -> list[dict]:
|
|
393
|
+
"""Filters search results by technical relevance.
|
|
394
|
+
|
|
395
|
+
If LLM is available: uses LLM as a semantic judge.
|
|
396
|
+
If not: applies heuristics based on score_final.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
candidates: Top-N results of Hybrid Search v4.
|
|
400
|
+
Each dict must have: node_id, score_final, score_breakdown.
|
|
401
|
+
task_context: Description of the current task.
|
|
402
|
+
max_results: Maximum results to return.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Filtered list of approved candidates (1 to max_results items).
|
|
406
|
+
"""
|
|
407
|
+
if not candidates:
|
|
408
|
+
return []
|
|
409
|
+
|
|
410
|
+
# Limits to max_results before reranking
|
|
411
|
+
top_candidates = candidates[:max_results]
|
|
412
|
+
|
|
413
|
+
if self._llm is not None:
|
|
414
|
+
reranked = self._llm_rerank(top_candidates, task_context)
|
|
415
|
+
else:
|
|
416
|
+
reranked = self._heuristic_rerank(top_candidates)
|
|
417
|
+
|
|
418
|
+
logger.info(
|
|
419
|
+
"Reranking: %d candidates → %d approved (task='%.50s...')",
|
|
420
|
+
len(top_candidates), len(reranked), task_context,
|
|
421
|
+
)
|
|
422
|
+
return reranked
|
|
423
|
+
|
|
424
|
+
def _heuristic_rerank(self, candidates: list[dict]) -> list[dict]:
|
|
425
|
+
"""Heuristic reranking (without LLM).
|
|
426
|
+
|
|
427
|
+
Filters candidates with score_final below 30% of the best score.
|
|
428
|
+
Guarantees at least 1 result.
|
|
429
|
+
"""
|
|
430
|
+
if not candidates:
|
|
431
|
+
return []
|
|
432
|
+
|
|
433
|
+
max_score = max(c.get("score_final", 0) for c in candidates)
|
|
434
|
+
threshold = max_score * 0.30
|
|
435
|
+
|
|
436
|
+
filtered = [
|
|
437
|
+
c for c in candidates
|
|
438
|
+
if c.get("score_final", 0) >= threshold
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
# Guarantees at least 1 result
|
|
442
|
+
if not filtered and candidates:
|
|
443
|
+
filtered = [candidates[0]]
|
|
444
|
+
|
|
445
|
+
logger.debug(
|
|
446
|
+
"Heuristic reranking: threshold=%.4f, filtered=%d/%d",
|
|
447
|
+
threshold, len(filtered), len(candidates),
|
|
448
|
+
)
|
|
449
|
+
return filtered
|
|
450
|
+
|
|
451
|
+
def _llm_rerank(self, candidates: list[dict], task_context: str) -> list[dict]:
|
|
452
|
+
"""Semantic reranking via LLM.
|
|
453
|
+
|
|
454
|
+
The LLM evaluates each candidate and assigns a relevance score.
|
|
455
|
+
Candidates below the RERANK_RELEVANCE_THRESHOLD are filtered.
|
|
456
|
+
"""
|
|
457
|
+
# Builds candidates block for prompt
|
|
458
|
+
lines: list[str] = []
|
|
459
|
+
for i, c in enumerate(candidates, 1):
|
|
460
|
+
breakdown = c.get("score_breakdown", {})
|
|
461
|
+
lines.append(
|
|
462
|
+
f"[{i}] node_id={c.get('node_id', '?')}, "
|
|
463
|
+
f"score_final={c.get('score_final', 0):.4f}, "
|
|
464
|
+
f"vetorial={breakdown.get('vetorial', 0):.4f}, "
|
|
465
|
+
f"fts5={breakdown.get('frequencia', 0):.4f}, "
|
|
466
|
+
f"recencia={breakdown.get('recencia', 0):.4f}, "
|
|
467
|
+
f"centralidade={breakdown.get('centralidade', 0):.4f}, "
|
|
468
|
+
f"is_super_node={c.get('is_super_node', False)}"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
candidates_block = "\n".join(lines)
|
|
472
|
+
|
|
473
|
+
prompt = _RERANK_PROMPT_TEMPLATE.format(
|
|
474
|
+
task_context=task_context,
|
|
475
|
+
candidates_block=candidates_block,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
raw_response = self._llm.generate(prompt, max_tokens=400)
|
|
480
|
+
parsed = self._extract_json(raw_response)
|
|
481
|
+
|
|
482
|
+
if parsed and "evaluations" in parsed:
|
|
483
|
+
evaluations = parsed["evaluations"]
|
|
484
|
+
|
|
485
|
+
# Builds node_id → relevance map
|
|
486
|
+
relevance_map: dict[int, float] = {}
|
|
487
|
+
for ev in evaluations:
|
|
488
|
+
nid = ev.get("node_id")
|
|
489
|
+
rel = ev.get("relevance", 0.0)
|
|
490
|
+
if nid is not None:
|
|
491
|
+
relevance_map[int(nid)] = float(rel)
|
|
492
|
+
|
|
493
|
+
# Filters approved candidates
|
|
494
|
+
approved: list[dict] = []
|
|
495
|
+
for c in candidates:
|
|
496
|
+
nid = c.get("node_id")
|
|
497
|
+
relevance = relevance_map.get(nid, 0.0)
|
|
498
|
+
if relevance >= self.RERANK_RELEVANCE_THRESHOLD:
|
|
499
|
+
c_copy = dict(c)
|
|
500
|
+
c_copy["rerank_relevance"] = relevance
|
|
501
|
+
approved.append(c_copy)
|
|
502
|
+
|
|
503
|
+
# Guarantees at least 1 result
|
|
504
|
+
if not approved and candidates:
|
|
505
|
+
best_nid = max(relevance_map, key=relevance_map.get, default=None)
|
|
506
|
+
if best_nid is not None:
|
|
507
|
+
for c in candidates:
|
|
508
|
+
if c.get("node_id") == best_nid:
|
|
509
|
+
c_copy = dict(c)
|
|
510
|
+
c_copy["rerank_relevance"] = relevance_map[best_nid]
|
|
511
|
+
approved = [c_copy]
|
|
512
|
+
break
|
|
513
|
+
|
|
514
|
+
# Sorts by reranking relevance
|
|
515
|
+
approved.sort(
|
|
516
|
+
key=lambda x: x.get("rerank_relevance", 0),
|
|
517
|
+
reverse=True,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
logger.info(
|
|
521
|
+
"LLM Reranking: %d/%d approved (threshold=%.2f)",
|
|
522
|
+
len(approved), len(candidates), self.RERANK_RELEVANCE_THRESHOLD,
|
|
523
|
+
)
|
|
524
|
+
return approved
|
|
525
|
+
|
|
526
|
+
logger.warning("LLM reranking returned invalid JSON — heuristic fallback.")
|
|
527
|
+
|
|
528
|
+
except Exception as e:
|
|
529
|
+
logger.error("LLM reranking failed: %s — heuristic fallback.", e)
|
|
530
|
+
|
|
531
|
+
# Heuristic fallback
|
|
532
|
+
return self._heuristic_rerank(candidates)
|
|
533
|
+
|
|
534
|
+
# ===================================================================
|
|
535
|
+
# ROLE 3 — CONTAMINATION BARRIER
|
|
536
|
+
# ===================================================================
|
|
537
|
+
|
|
538
|
+
def check_contamination(
|
|
539
|
+
self,
|
|
540
|
+
source_project: dict,
|
|
541
|
+
target_project: dict,
|
|
542
|
+
) -> tuple[bool, str]:
|
|
543
|
+
"""Checks for risk of contamination between projects.
|
|
544
|
+
|
|
545
|
+
Rule: data from RESTRICTED projects cannot flow to
|
|
546
|
+
PUBLIC or INTERNAL contexts.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
source_project: Source project of the data.
|
|
550
|
+
target_project: Target project that will receive/expose the data.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Tuple (is_safe: bool, reason: str).
|
|
554
|
+
"""
|
|
555
|
+
source_privacy = source_project.get("privacy_level", "PUBLIC")
|
|
556
|
+
target_privacy = target_project.get("privacy_level", "PUBLIC")
|
|
557
|
+
|
|
558
|
+
# Hierarchy: RESTRICTED > INTERNAL > PUBLIC
|
|
559
|
+
privacy_hierarchy = {"PUBLIC": 0, "INTERNAL": 1, "RESTRICTED": 2}
|
|
560
|
+
source_level = privacy_hierarchy.get(source_privacy, 0)
|
|
561
|
+
target_level = privacy_hierarchy.get(target_privacy, 0)
|
|
562
|
+
|
|
563
|
+
if source_level > target_level:
|
|
564
|
+
reason = (
|
|
565
|
+
f"CONTAMINAÇÃO BLOQUEADA: dados de projeto {source_privacy} "
|
|
566
|
+
f"não podem fluir para contexto {target_privacy}. "
|
|
567
|
+
f"Projeto fonte: '{source_project.get('folder_name', '?')}', "
|
|
568
|
+
f"projeto destino: '{target_project.get('folder_name', '?')}'."
|
|
569
|
+
)
|
|
570
|
+
logger.warning(reason)
|
|
571
|
+
return False, reason
|
|
572
|
+
|
|
573
|
+
return True, "OK — sem risco de contaminação."
|
|
574
|
+
|
|
575
|
+
# ===================================================================
|
|
576
|
+
# UTILITIES
|
|
577
|
+
# ===================================================================
|
|
578
|
+
|
|
579
|
+
def _extract_json(self, text: str) -> Optional[dict]:
|
|
580
|
+
"""Extracts JSON from an LLM response (with regex fallback).
|
|
581
|
+
|
|
582
|
+
Attempts:
|
|
583
|
+
1. Direct json.loads()
|
|
584
|
+
2. Regex to find embedded JSON block
|
|
585
|
+
"""
|
|
586
|
+
if not text:
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
# Attempt 1: direct parse
|
|
590
|
+
clean = text.strip()
|
|
591
|
+
if clean.startswith("```"):
|
|
592
|
+
# Removes markdown fences
|
|
593
|
+
lines = clean.split("\n")
|
|
594
|
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
|
595
|
+
clean = "\n".join(lines).strip()
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
return json.loads(clean)
|
|
599
|
+
except (json.JSONDecodeError, TypeError):
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
# Attempt 2: regex
|
|
603
|
+
match = _JSON_BLOCK_RE.search(text)
|
|
604
|
+
if match:
|
|
605
|
+
try:
|
|
606
|
+
return json.loads(match.group())
|
|
607
|
+
except (json.JSONDecodeError, TypeError):
|
|
608
|
+
pass
|
|
609
|
+
|
|
610
|
+
return None
|