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 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