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
ingestion/summarizer.py
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ingestion/summarizer.py - Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Zoom Gear — Generation of recursive L0/L1/L2 summaries.
|
|
5
|
+
|
|
6
|
+
Responsibilities:
|
|
7
|
+
- L0 (File): Generates individual summary of each file/chunk ingested.
|
|
8
|
+
- L1 (Cluster): Aggregates L0 summaries into directory/topic/module summaries.
|
|
9
|
+
- L2 (Compass): Generates global project summary (Context Compass).
|
|
10
|
+
- Relevance Pruning (Selective Amnesia): In L2, discards nodes with
|
|
11
|
+
relevance score below the threshold for giant projects.
|
|
12
|
+
- Model Tiering: Uses Flash models (cheap) for L0/L1, preserving
|
|
13
|
+
Elite models for execution tasks.
|
|
14
|
+
|
|
15
|
+
Heuristic Fallback & Retry Loop:
|
|
16
|
+
Flash models have a higher failure rate in JSON extraction.
|
|
17
|
+
The Summarizer implements:
|
|
18
|
+
1. Normal attempt at JSON parsing.
|
|
19
|
+
2. Regex fallback: raw extraction via \\{.*\\}.
|
|
20
|
+
3. Up to 3 attempts.
|
|
21
|
+
4. If all fail: "Dumb Summary" (truncated plain text).
|
|
22
|
+
|
|
23
|
+
Integration:
|
|
24
|
+
- Receives ParsedChunk from parser.py.
|
|
25
|
+
- Writes summaries to SqliteStore (nodes table, summary field).
|
|
26
|
+
- L2 summaries feed concierge_resume / wake_up.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from typing import Any, Callable, Optional
|
|
37
|
+
|
|
38
|
+
from storage import SqliteStore
|
|
39
|
+
from ingestion.parser import ParsedChunk
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("grafo-concierge.summarizer")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Zoom Gear Settings
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
MAX_RETRY_LOOPS: int = 3
|
|
49
|
+
MAX_L0_TOKENS: int = 1000
|
|
50
|
+
MAX_L1_TOKENS: int = 1500
|
|
51
|
+
MAX_L2_TOKENS: int = 1500
|
|
52
|
+
L2_RELEVANCE_THRESHOLD: float = 0.15
|
|
53
|
+
|
|
54
|
+
# Tags indicating high priority (relevance score boost)
|
|
55
|
+
HIGH_PRIORITY_TAGS: set[str] = {
|
|
56
|
+
"fastapi", "flask", "django", "express", "react", "nextjs",
|
|
57
|
+
"vue", "angular", "pytorch", "tensorflow", "graphql", "grpc",
|
|
58
|
+
"auth", "jwt", "oauth", "database", "sqlalchemy", "prisma",
|
|
59
|
+
"api", "security", "kubernetes", "docker",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Regex to extract embedded JSON from LLM response
|
|
63
|
+
_JSON_BLOCK_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# ZoomLevel
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class ZoomLevel(str, Enum):
|
|
71
|
+
"""Hierarchical summary levels."""
|
|
72
|
+
L0 = "L0"
|
|
73
|
+
L1 = "L1"
|
|
74
|
+
L2 = "L2"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# SummaryResult
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SummaryResult:
|
|
83
|
+
"""Result of a summarization operation."""
|
|
84
|
+
level: ZoomLevel
|
|
85
|
+
summary: str
|
|
86
|
+
source_label: str
|
|
87
|
+
source_chunks: int = 0
|
|
88
|
+
tokens_used: int = 0
|
|
89
|
+
model_used: str = ""
|
|
90
|
+
is_dumb_summary: bool = False
|
|
91
|
+
detected_tags: list[str] = field(default_factory=list)
|
|
92
|
+
relevance_score: float = 1.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# PROMPTS — Templates for each summary level
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
_L0_PROMPT_TEMPLATE = """You are a code analysis assistant. Summarize the following code/document chunk.
|
|
100
|
+
Return ONLY a valid JSON object with these fields:
|
|
101
|
+
- "summary": A concise description of what this code does (max 2 sentences).
|
|
102
|
+
- "tags": A list of detected technologies, frameworks, or key concepts.
|
|
103
|
+
|
|
104
|
+
Source file: {source_file}
|
|
105
|
+
Chunk type: {chunk_type}
|
|
106
|
+
Symbol: {symbol_name}
|
|
107
|
+
|
|
108
|
+
Content:
|
|
109
|
+
{armored_content}
|
|
110
|
+
|
|
111
|
+
Respond with ONLY the JSON object, no markdown fences, no extra text."""
|
|
112
|
+
|
|
113
|
+
_L1_PROMPT_TEMPLATE = """You are a software architect. Synthesize the following file summaries into a single module/directory description.
|
|
114
|
+
Return ONLY a valid JSON object with these fields:
|
|
115
|
+
- "summary": A cohesive description of this module's purpose and responsibilities (max 3 sentences).
|
|
116
|
+
- "tags": Consolidated list of key technologies and concepts.
|
|
117
|
+
|
|
118
|
+
Module: {cluster_label}
|
|
119
|
+
File summaries:
|
|
120
|
+
{summaries_block}
|
|
121
|
+
|
|
122
|
+
Respond with ONLY the JSON object, no markdown fences, no extra text."""
|
|
123
|
+
|
|
124
|
+
_L2_PROMPT_TEMPLATE = """You are a senior software architect. Create a high-level project overview (Compass) from the module summaries below.
|
|
125
|
+
Return ONLY a valid JSON object with these fields:
|
|
126
|
+
- "summary": A comprehensive project overview covering architecture, purpose, and key technologies (max 4 sentences).
|
|
127
|
+
- "tags": The most important technologies and architectural patterns.
|
|
128
|
+
|
|
129
|
+
Project: {project_name}
|
|
130
|
+
Module summaries:
|
|
131
|
+
{summaries_block}
|
|
132
|
+
|
|
133
|
+
Respond with ONLY the JSON object, no markdown fences, no extra text."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# LLMAdapter — interface for LLM calls (Model Tiering)
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
class LLMAdapter:
|
|
141
|
+
"""Adapter for LLM calls with support for Model Tiering.
|
|
142
|
+
|
|
143
|
+
Allows changing the model without altering the Summarizer logic.
|
|
144
|
+
Accepts a custom call_fn for testing and alternative providers.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
model_name: Model name (e.g. 'gemini-2.0-flash', 'claude-haiku').
|
|
148
|
+
api_key: Provider API key.
|
|
149
|
+
call_fn: Custom call function.
|
|
150
|
+
Signature: (prompt: str, max_tokens: int) -> str
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
model_name: str = "gemini-2.0-flash",
|
|
156
|
+
api_key: Optional[str] = None,
|
|
157
|
+
base_url: Optional[str] = None,
|
|
158
|
+
call_fn: Optional[Callable[[str, int], str]] = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._model_name = model_name
|
|
161
|
+
self._api_key = api_key
|
|
162
|
+
self._base_url = base_url
|
|
163
|
+
self._call_fn = call_fn
|
|
164
|
+
logger.info("LLMAdapter inicializado: model=%s, custom_fn=%s", model_name, call_fn is not None)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def model_name(self) -> str:
|
|
168
|
+
return self._model_name
|
|
169
|
+
|
|
170
|
+
def generate(self, prompt: str, max_tokens: int = 300) -> str:
|
|
171
|
+
"""Sends prompt to LLM and returns the response.
|
|
172
|
+
|
|
173
|
+
If call_fn was provided, uses it directly.
|
|
174
|
+
Otherwise, attempts to use google.generativeai (Gemini).
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
RuntimeError: If no LLM backend is available.
|
|
178
|
+
"""
|
|
179
|
+
# Custom mode (for testing or alternative providers)
|
|
180
|
+
if self._call_fn is not None:
|
|
181
|
+
return self._call_fn(prompt, max_tokens)
|
|
182
|
+
|
|
183
|
+
# Attempt with Google GenAI SDK (google.genai)
|
|
184
|
+
try:
|
|
185
|
+
from google import genai
|
|
186
|
+
client = genai.Client(api_key=self._api_key) if self._api_key else genai.Client()
|
|
187
|
+
response = client.models.generate_content(
|
|
188
|
+
model=self._model_name,
|
|
189
|
+
contents=prompt,
|
|
190
|
+
)
|
|
191
|
+
if response.text:
|
|
192
|
+
return response.text
|
|
193
|
+
except ImportError:
|
|
194
|
+
# Attempt with legacy Google Generative AI
|
|
195
|
+
try:
|
|
196
|
+
import warnings
|
|
197
|
+
with warnings.catch_warnings():
|
|
198
|
+
warnings.simplefilter("ignore")
|
|
199
|
+
import google.generativeai as legacy_genai
|
|
200
|
+
if self._api_key:
|
|
201
|
+
legacy_genai.configure(api_key=self._api_key)
|
|
202
|
+
model = legacy_genai.GenerativeModel(self._model_name)
|
|
203
|
+
response = model.generate_content(
|
|
204
|
+
prompt,
|
|
205
|
+
generation_config=legacy_genai.GenerationConfig(max_output_tokens=max_tokens),
|
|
206
|
+
)
|
|
207
|
+
if response.text:
|
|
208
|
+
return response.text
|
|
209
|
+
except ImportError:
|
|
210
|
+
pass
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error("Failed to call legacy Gemini (%s): %s", self._model_name, e)
|
|
213
|
+
raise RuntimeError(f"LLM call failed: {e}") from e
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error("Failed to call Google GenAI (%s): %s", self._model_name, e)
|
|
216
|
+
raise RuntimeError(f"LLM call failed: {e}") from e
|
|
217
|
+
|
|
218
|
+
# Attempt with OpenAI / Compatible Provider
|
|
219
|
+
try:
|
|
220
|
+
import openai
|
|
221
|
+
|
|
222
|
+
# Auto-detection of OpenRouter by key if no base_url is informed
|
|
223
|
+
target_base_url = self._base_url
|
|
224
|
+
if not target_base_url and self._api_key and self._api_key.startswith("sk-or-"):
|
|
225
|
+
target_base_url = "https://openrouter.ai/api/v1"
|
|
226
|
+
|
|
227
|
+
if target_base_url:
|
|
228
|
+
client = openai.OpenAI(
|
|
229
|
+
api_key=self._api_key,
|
|
230
|
+
base_url=target_base_url
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
client = openai.OpenAI(api_key=self._api_key)
|
|
234
|
+
|
|
235
|
+
response = client.chat.completions.create(
|
|
236
|
+
model=self._model_name,
|
|
237
|
+
messages=[{"role": "user", "content": prompt}],
|
|
238
|
+
max_tokens=max_tokens,
|
|
239
|
+
)
|
|
240
|
+
return response.choices[0].message.content or ""
|
|
241
|
+
except ImportError:
|
|
242
|
+
pass
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.error("Failed to call OpenAI/Compatible Provider (%s): %s", self._model_name, e)
|
|
245
|
+
raise RuntimeError(f"LLM call failed: {e}") from e
|
|
246
|
+
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
"No LLM backend available. Install 'google-generativeai' or 'openai', "
|
|
249
|
+
"or provide a custom call_fn."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def generate_async(self, prompt: str, max_tokens: int = 300) -> str:
|
|
253
|
+
"""Asynchronous version of generate() for high throughput.
|
|
254
|
+
|
|
255
|
+
Strategy by backend:
|
|
256
|
+
- custom call_fn: uses asyncio.to_thread (safe fallback).
|
|
257
|
+
- Native Gemini models (no custom base_url): prioritizes native Gemini SDK.
|
|
258
|
+
- OpenAI / compatible providers: uses native openai.AsyncOpenAI.
|
|
259
|
+
"""
|
|
260
|
+
import asyncio
|
|
261
|
+
|
|
262
|
+
# Custom mode
|
|
263
|
+
if self._call_fn is not None:
|
|
264
|
+
return await asyncio.to_thread(self._call_fn, prompt, max_tokens)
|
|
265
|
+
|
|
266
|
+
# If it is a native Gemini model and does not have an external custom base_url,
|
|
267
|
+
# prioritize native Gemini SDK to avoid conflict with the openai library
|
|
268
|
+
if self._model_name.startswith("gemini-") and not self._base_url:
|
|
269
|
+
try:
|
|
270
|
+
return await asyncio.to_thread(self.generate, prompt, max_tokens)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.warning("Failed initial asynchronous attempt with Gemini SDK: %s. Trying OpenAI...", e)
|
|
273
|
+
|
|
274
|
+
# Attempt with OpenAI / Compatible Provider (native async)
|
|
275
|
+
try:
|
|
276
|
+
import openai
|
|
277
|
+
|
|
278
|
+
target_base_url = self._base_url
|
|
279
|
+
if not target_base_url and self._api_key and self._api_key.startswith("sk-or-"):
|
|
280
|
+
target_base_url = "https://openrouter.ai/api/v1"
|
|
281
|
+
|
|
282
|
+
if target_base_url:
|
|
283
|
+
client = openai.AsyncOpenAI(
|
|
284
|
+
api_key=self._api_key,
|
|
285
|
+
base_url=target_base_url,
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
client = openai.AsyncOpenAI(api_key=self._api_key)
|
|
289
|
+
|
|
290
|
+
response = await client.chat.completions.create(
|
|
291
|
+
model=self._model_name,
|
|
292
|
+
messages=[{"role": "user", "content": prompt}],
|
|
293
|
+
max_tokens=max_tokens,
|
|
294
|
+
)
|
|
295
|
+
return response.choices[0].message.content or ""
|
|
296
|
+
except ImportError:
|
|
297
|
+
pass
|
|
298
|
+
except Exception as e:
|
|
299
|
+
logger.error("Async: Failed to call OpenAI/Provider (%s): %s", self._model_name, e)
|
|
300
|
+
raise RuntimeError(f"Async LLM call failed: {e}") from e
|
|
301
|
+
|
|
302
|
+
# General fallback
|
|
303
|
+
try:
|
|
304
|
+
return await asyncio.to_thread(self.generate, prompt, max_tokens)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
raise RuntimeError(f"Async LLM fallback failed: {e}") from e
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# ZoomSummarizer — Zoom Gear Engine
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
class ZoomSummarizer:
|
|
315
|
+
"""Zoom Gear Engine — generates recursive summaries L0 → L1 → L2.
|
|
316
|
+
|
|
317
|
+
Heuristic Fallback:
|
|
318
|
+
If LLM returns invalid JSON, attempts regex + up to 3 retries.
|
|
319
|
+
If all fails, generates Dumb Summary (truncated plain text).
|
|
320
|
+
|
|
321
|
+
Relevance Pruning (Selective Amnesia):
|
|
322
|
+
In L2, nodes with relevance_score < L2_RELEVANCE_THRESHOLD are
|
|
323
|
+
discarded from synthesis to keep the Compass concise.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self,
|
|
328
|
+
llm_adapter: LLMAdapter,
|
|
329
|
+
sqlite_store: Optional[SqliteStore] = None,
|
|
330
|
+
) -> None:
|
|
331
|
+
self._llm = llm_adapter
|
|
332
|
+
self._store = sqlite_store
|
|
333
|
+
|
|
334
|
+
# ===================================================================
|
|
335
|
+
# L0 — Summary of individual chunk
|
|
336
|
+
# ===================================================================
|
|
337
|
+
|
|
338
|
+
def summarize_l0(self, chunk: ParsedChunk) -> SummaryResult:
|
|
339
|
+
"""Generates L0 summary for an individual chunk/file."""
|
|
340
|
+
prompt = _L0_PROMPT_TEMPLATE.format(
|
|
341
|
+
source_file=chunk.source_file,
|
|
342
|
+
chunk_type=chunk.chunk_type.value,
|
|
343
|
+
symbol_name=chunk.symbol_name,
|
|
344
|
+
armored_content=chunk.armored_content,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Retry loop with Heuristic Fallback
|
|
348
|
+
for attempt in range(1, MAX_RETRY_LOOPS + 1):
|
|
349
|
+
try:
|
|
350
|
+
raw_response = self._llm.generate(prompt, max_tokens=MAX_L0_TOKENS)
|
|
351
|
+
parsed = self._extract_json_with_fallback(raw_response)
|
|
352
|
+
|
|
353
|
+
if parsed and "summary" in parsed:
|
|
354
|
+
summary_text = parsed["summary"]
|
|
355
|
+
tags = parsed.get("tags", [])
|
|
356
|
+
if isinstance(tags, list):
|
|
357
|
+
tags = [str(t).lower() for t in tags]
|
|
358
|
+
else:
|
|
359
|
+
tags = []
|
|
360
|
+
|
|
361
|
+
return SummaryResult(
|
|
362
|
+
level=ZoomLevel.L0,
|
|
363
|
+
summary=summary_text,
|
|
364
|
+
source_label=chunk.source_file,
|
|
365
|
+
source_chunks=1,
|
|
366
|
+
tokens_used=self._estimate_tokens(summary_text),
|
|
367
|
+
model_used=self._llm.model_name,
|
|
368
|
+
is_dumb_summary=False,
|
|
369
|
+
detected_tags=sorted(set(chunk.detected_tags + tags)),
|
|
370
|
+
relevance_score=1.0,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
logger.warning(
|
|
374
|
+
"L0 attempt %d/%d: invalid JSON for %s",
|
|
375
|
+
attempt, MAX_RETRY_LOOPS, chunk.source_file,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning(
|
|
380
|
+
"L0 attempt %d/%d failed for %s: %s",
|
|
381
|
+
attempt, MAX_RETRY_LOOPS, chunk.source_file, e,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Dumb Summary — last resort
|
|
385
|
+
logger.error("L0: all attempts failed for %s — generating Dumb Summary.", chunk.source_file)
|
|
386
|
+
dumb = self._generate_dumb_summary(chunk.content, MAX_L0_TOKENS)
|
|
387
|
+
return SummaryResult(
|
|
388
|
+
level=ZoomLevel.L0,
|
|
389
|
+
summary=dumb,
|
|
390
|
+
source_label=chunk.source_file,
|
|
391
|
+
source_chunks=1,
|
|
392
|
+
tokens_used=self._estimate_tokens(dumb),
|
|
393
|
+
model_used="dumb_fallback",
|
|
394
|
+
is_dumb_summary=True,
|
|
395
|
+
detected_tags=chunk.detected_tags,
|
|
396
|
+
relevance_score=0.5,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def summarize_l0_batch(self, chunks: list[ParsedChunk]) -> list[SummaryResult]:
|
|
400
|
+
"""Generates L0 summaries for multiple chunks with Semantic Fallback."""
|
|
401
|
+
results: list[SummaryResult] = []
|
|
402
|
+
for i, chunk in enumerate(chunks):
|
|
403
|
+
try:
|
|
404
|
+
result = self.summarize_l0(chunk)
|
|
405
|
+
results.append(result)
|
|
406
|
+
logger.debug("L0 [%d/%d] OK: %s", i + 1, len(chunks), chunk.source_file)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error("L0 batch fallback — skip chunk %s: %s", chunk.source_file, e)
|
|
409
|
+
# Generates Dumb Summary to not lose the chunk
|
|
410
|
+
dumb = self._generate_dumb_summary(chunk.content, MAX_L0_TOKENS)
|
|
411
|
+
results.append(SummaryResult(
|
|
412
|
+
level=ZoomLevel.L0,
|
|
413
|
+
summary=dumb,
|
|
414
|
+
source_label=chunk.source_file,
|
|
415
|
+
source_chunks=1,
|
|
416
|
+
tokens_used=self._estimate_tokens(dumb),
|
|
417
|
+
model_used="dumb_fallback",
|
|
418
|
+
is_dumb_summary=True,
|
|
419
|
+
detected_tags=chunk.detected_tags,
|
|
420
|
+
relevance_score=0.5,
|
|
421
|
+
))
|
|
422
|
+
return results
|
|
423
|
+
|
|
424
|
+
async def summarize_l0_async(self, chunk: ParsedChunk) -> SummaryResult:
|
|
425
|
+
"""Asynchronous version of summarize_l0 for use with asyncio.gather."""
|
|
426
|
+
prompt = _L0_PROMPT_TEMPLATE.format(
|
|
427
|
+
source_file=chunk.source_file,
|
|
428
|
+
chunk_type=chunk.chunk_type.value,
|
|
429
|
+
symbol_name=chunk.symbol_name,
|
|
430
|
+
armored_content=chunk.armored_content,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
for attempt in range(1, MAX_RETRY_LOOPS + 1):
|
|
434
|
+
try:
|
|
435
|
+
raw_response = await self._llm.generate_async(prompt, max_tokens=MAX_L0_TOKENS)
|
|
436
|
+
parsed = self._extract_json_with_fallback(raw_response)
|
|
437
|
+
|
|
438
|
+
if parsed and "summary" in parsed:
|
|
439
|
+
summary_text = parsed["summary"]
|
|
440
|
+
tags = parsed.get("tags", [])
|
|
441
|
+
if isinstance(tags, list):
|
|
442
|
+
tags = [str(t).lower() for t in tags]
|
|
443
|
+
else:
|
|
444
|
+
tags = []
|
|
445
|
+
|
|
446
|
+
return SummaryResult(
|
|
447
|
+
level=ZoomLevel.L0,
|
|
448
|
+
summary=summary_text,
|
|
449
|
+
source_label=chunk.source_file,
|
|
450
|
+
source_chunks=1,
|
|
451
|
+
tokens_used=self._estimate_tokens(summary_text),
|
|
452
|
+
model_used=self._llm.model_name,
|
|
453
|
+
is_dumb_summary=False,
|
|
454
|
+
detected_tags=sorted(set(chunk.detected_tags + tags)),
|
|
455
|
+
relevance_score=1.0,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
logger.warning(
|
|
459
|
+
"L0 async attempt %d/%d: invalid JSON for %s",
|
|
460
|
+
attempt, MAX_RETRY_LOOPS, chunk.source_file,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.warning(
|
|
465
|
+
"L0 async attempt %d/%d failed for %s: %s",
|
|
466
|
+
attempt, MAX_RETRY_LOOPS, chunk.source_file, e,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Dumb Summary — last resort
|
|
470
|
+
logger.error("L0 async: all attempts failed for %s — generating Dumb Summary.", chunk.source_file)
|
|
471
|
+
dumb = self._generate_dumb_summary(chunk.content, MAX_L0_TOKENS)
|
|
472
|
+
return SummaryResult(
|
|
473
|
+
level=ZoomLevel.L0,
|
|
474
|
+
summary=dumb,
|
|
475
|
+
source_label=chunk.source_file,
|
|
476
|
+
source_chunks=1,
|
|
477
|
+
tokens_used=self._estimate_tokens(dumb),
|
|
478
|
+
model_used="dumb_fallback",
|
|
479
|
+
is_dumb_summary=True,
|
|
480
|
+
detected_tags=chunk.detected_tags,
|
|
481
|
+
relevance_score=0.5,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# ===================================================================
|
|
485
|
+
# L0 Grouped — Grouping of small chunks in a single prompt
|
|
486
|
+
# ===================================================================
|
|
487
|
+
|
|
488
|
+
_L0_GROUPED_PROMPT = """You are a code analysis assistant. Summarize each of the following code chunks individually.
|
|
489
|
+
Return ONLY a valid JSON array of objects. Each object must have:
|
|
490
|
+
- "index": The chunk index number (integer, starting from the values given).
|
|
491
|
+
- "summary": A concise description of what this code does (max 2 sentences).
|
|
492
|
+
- "tags": A list of detected technologies, frameworks, or key concepts.
|
|
493
|
+
|
|
494
|
+
Chunks:
|
|
495
|
+
{chunks_block}
|
|
496
|
+
|
|
497
|
+
Respond with ONLY the JSON array, no markdown fences, no extra text."""
|
|
498
|
+
|
|
499
|
+
def summarize_l0_grouped(
|
|
500
|
+
self,
|
|
501
|
+
chunks: list[ParsedChunk],
|
|
502
|
+
indices: list[int],
|
|
503
|
+
) -> list[tuple[int, SummaryResult]]:
|
|
504
|
+
"""Summarizes multiple small chunks in a single LLM call.
|
|
505
|
+
|
|
506
|
+
Optimization for getter/setter functions and chunks < 50 tokens,
|
|
507
|
+
reducing the total number of HTTP calls to the provider.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
chunks: List of small ParsedChunks to group.
|
|
511
|
+
indices: Original indices of each chunk in the global list.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
List of tuples (original_index, SummaryResult).
|
|
515
|
+
"""
|
|
516
|
+
chunks_block = ""
|
|
517
|
+
for idx, chunk in zip(indices, chunks):
|
|
518
|
+
chunks_block += (
|
|
519
|
+
f"\n--- Chunk index={idx} file={chunk.source_file} "
|
|
520
|
+
f"type={chunk.chunk_type.value} symbol={chunk.symbol_name} ---\n"
|
|
521
|
+
f"{chunk.armored_content}\n"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
prompt = self._L0_GROUPED_PROMPT.format(chunks_block=chunks_block)
|
|
525
|
+
|
|
526
|
+
# Estimates response tokens: ~200 tokens per chunk in the group
|
|
527
|
+
estimated_response_tokens = min(len(chunks) * 200, 4000)
|
|
528
|
+
|
|
529
|
+
for attempt in range(1, MAX_RETRY_LOOPS + 1):
|
|
530
|
+
try:
|
|
531
|
+
raw_response = self._llm.generate(prompt, max_tokens=estimated_response_tokens)
|
|
532
|
+
|
|
533
|
+
# Tries to parse as JSON array
|
|
534
|
+
parsed_array = None
|
|
535
|
+
try:
|
|
536
|
+
parsed_array = json.loads(raw_response)
|
|
537
|
+
except json.JSONDecodeError:
|
|
538
|
+
# Tries to extract array from within markdown fences
|
|
539
|
+
import re
|
|
540
|
+
array_match = re.search(r'\[.*\]', raw_response, re.DOTALL)
|
|
541
|
+
if array_match:
|
|
542
|
+
try:
|
|
543
|
+
parsed_array = json.loads(array_match.group())
|
|
544
|
+
except json.JSONDecodeError:
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
if isinstance(parsed_array, list) and len(parsed_array) > 0:
|
|
548
|
+
results: list[tuple[int, SummaryResult]] = []
|
|
549
|
+
for item in parsed_array:
|
|
550
|
+
if not isinstance(item, dict) or "summary" not in item:
|
|
551
|
+
continue
|
|
552
|
+
item_idx = item.get("index", -1)
|
|
553
|
+
if item_idx not in indices:
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
chunk_pos = indices.index(item_idx)
|
|
557
|
+
chunk = chunks[chunk_pos]
|
|
558
|
+
tags = item.get("tags", [])
|
|
559
|
+
if isinstance(tags, list):
|
|
560
|
+
tags = [str(t).lower() for t in tags]
|
|
561
|
+
else:
|
|
562
|
+
tags = []
|
|
563
|
+
|
|
564
|
+
results.append((item_idx, SummaryResult(
|
|
565
|
+
level=ZoomLevel.L0,
|
|
566
|
+
summary=item["summary"],
|
|
567
|
+
source_label=chunk.source_file,
|
|
568
|
+
source_chunks=1,
|
|
569
|
+
tokens_used=self._estimate_tokens(item["summary"]),
|
|
570
|
+
model_used=self._llm.model_name,
|
|
571
|
+
is_dumb_summary=False,
|
|
572
|
+
detected_tags=sorted(set(chunk.detected_tags + tags)),
|
|
573
|
+
relevance_score=1.0,
|
|
574
|
+
)))
|
|
575
|
+
|
|
576
|
+
if results:
|
|
577
|
+
logger.info("L0 grouped: %d/%d summaries extracted successfully.", len(results), len(chunks))
|
|
578
|
+
return results
|
|
579
|
+
|
|
580
|
+
logger.warning(
|
|
581
|
+
"L0 grouped attempt %d/%d: invalid response (%d chunks).",
|
|
582
|
+
attempt, MAX_RETRY_LOOPS, len(chunks),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
except Exception as e:
|
|
586
|
+
logger.warning(
|
|
587
|
+
"L0 grouped attempt %d/%d failed: %s",
|
|
588
|
+
attempt, MAX_RETRY_LOOPS, e,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# Fallback: returns empty list, caller will perform individual summaries
|
|
592
|
+
logger.error("L0 grouped: all attempts failed for %d chunks.", len(chunks))
|
|
593
|
+
return []
|
|
594
|
+
|
|
595
|
+
# ===================================================================
|
|
596
|
+
# L1 — Summary of cluster (folder/module)
|
|
597
|
+
# ===================================================================
|
|
598
|
+
|
|
599
|
+
def summarize_l1(
|
|
600
|
+
self,
|
|
601
|
+
l0_summaries: list[SummaryResult],
|
|
602
|
+
cluster_label: str,
|
|
603
|
+
) -> SummaryResult:
|
|
604
|
+
"""Generates L1 summary from grouped L0 summaries."""
|
|
605
|
+
# Assemble block of summaries for the prompt
|
|
606
|
+
summaries_block = "\n".join(
|
|
607
|
+
f"- [{s.source_label}]: {s.summary}" for s in l0_summaries
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Consolidate tags
|
|
611
|
+
all_tags: set[str] = set()
|
|
612
|
+
for s in l0_summaries:
|
|
613
|
+
all_tags.update(s.detected_tags)
|
|
614
|
+
|
|
615
|
+
prompt = _L1_PROMPT_TEMPLATE.format(
|
|
616
|
+
cluster_label=cluster_label,
|
|
617
|
+
summaries_block=summaries_block,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
for attempt in range(1, MAX_RETRY_LOOPS + 1):
|
|
621
|
+
try:
|
|
622
|
+
raw_response = self._llm.generate(prompt, max_tokens=MAX_L1_TOKENS)
|
|
623
|
+
parsed = self._extract_json_with_fallback(raw_response)
|
|
624
|
+
|
|
625
|
+
if parsed and "summary" in parsed:
|
|
626
|
+
summary_text = parsed["summary"]
|
|
627
|
+
tags = parsed.get("tags", [])
|
|
628
|
+
if isinstance(tags, list):
|
|
629
|
+
all_tags.update(str(t).lower() for t in tags)
|
|
630
|
+
|
|
631
|
+
return SummaryResult(
|
|
632
|
+
level=ZoomLevel.L1,
|
|
633
|
+
summary=summary_text,
|
|
634
|
+
source_label=cluster_label,
|
|
635
|
+
source_chunks=len(l0_summaries),
|
|
636
|
+
tokens_used=self._estimate_tokens(summary_text),
|
|
637
|
+
model_used=self._llm.model_name,
|
|
638
|
+
is_dumb_summary=False,
|
|
639
|
+
detected_tags=sorted(all_tags),
|
|
640
|
+
relevance_score=self._calculate_relevance_from_parts(l0_summaries),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
logger.warning(
|
|
644
|
+
"L1 attempt %d/%d: invalid JSON for cluster %s",
|
|
645
|
+
attempt, MAX_RETRY_LOOPS, cluster_label,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
except Exception as e:
|
|
649
|
+
logger.warning(
|
|
650
|
+
"L1 attempt %d/%d failed for cluster %s: %s",
|
|
651
|
+
attempt, MAX_RETRY_LOOPS, cluster_label, e,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
# Dumb Summary L1
|
|
655
|
+
logger.error("L1: all attempts failed for %s — Dumb Summary.", cluster_label)
|
|
656
|
+
dumb = self._generate_dumb_summary(summaries_block, MAX_L1_TOKENS)
|
|
657
|
+
return SummaryResult(
|
|
658
|
+
level=ZoomLevel.L1,
|
|
659
|
+
summary=dumb,
|
|
660
|
+
source_label=cluster_label,
|
|
661
|
+
source_chunks=len(l0_summaries),
|
|
662
|
+
tokens_used=self._estimate_tokens(dumb),
|
|
663
|
+
model_used="dumb_fallback",
|
|
664
|
+
is_dumb_summary=True,
|
|
665
|
+
detected_tags=sorted(all_tags),
|
|
666
|
+
relevance_score=self._calculate_relevance_from_parts(l0_summaries),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def build_l1_clusters(
|
|
670
|
+
self,
|
|
671
|
+
l0_summaries: list[SummaryResult],
|
|
672
|
+
) -> dict[str, list[SummaryResult]]:
|
|
673
|
+
"""Groups L0 summaries by parent directory (natural cluster)."""
|
|
674
|
+
clusters: dict[str, list[SummaryResult]] = {}
|
|
675
|
+
|
|
676
|
+
for s in l0_summaries:
|
|
677
|
+
# Extract parent directory from source_label (relative_path)
|
|
678
|
+
label = s.source_label.replace("\\", "/")
|
|
679
|
+
if "/" in label:
|
|
680
|
+
parent = label.rsplit("/", 1)[0]
|
|
681
|
+
else:
|
|
682
|
+
parent = "<root>"
|
|
683
|
+
|
|
684
|
+
if parent not in clusters:
|
|
685
|
+
clusters[parent] = []
|
|
686
|
+
clusters[parent].append(s)
|
|
687
|
+
|
|
688
|
+
logger.debug("L1 clusters built: %d clusters from %d L0s.", len(clusters), len(l0_summaries))
|
|
689
|
+
return clusters
|
|
690
|
+
|
|
691
|
+
# ===================================================================
|
|
692
|
+
# L2 — Context Compass (global summary)
|
|
693
|
+
# ===================================================================
|
|
694
|
+
|
|
695
|
+
def summarize_l2(
|
|
696
|
+
self,
|
|
697
|
+
l1_summaries: list[SummaryResult],
|
|
698
|
+
project_name: str,
|
|
699
|
+
) -> SummaryResult:
|
|
700
|
+
"""Generates Context Compass (L2) from L1 summaries.
|
|
701
|
+
|
|
702
|
+
Applies Relevance Pruning (Selective Amnesia) before synthesis.
|
|
703
|
+
"""
|
|
704
|
+
# Pruning — remove trivial modules
|
|
705
|
+
pruned = self._prune_low_relevance(l1_summaries)
|
|
706
|
+
pruned_count = len(l1_summaries) - len(pruned)
|
|
707
|
+
if pruned_count > 0:
|
|
708
|
+
logger.info(
|
|
709
|
+
"Selective Amnesia: %d/%d modules pruned (threshold=%.2f).",
|
|
710
|
+
pruned_count, len(l1_summaries), L2_RELEVANCE_THRESHOLD,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Assemble block of summaries
|
|
714
|
+
summaries_block = "\n".join(
|
|
715
|
+
f"- [{s.source_label}] (relevance={s.relevance_score:.2f}): {s.summary}"
|
|
716
|
+
for s in pruned
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
all_tags: set[str] = set()
|
|
720
|
+
for s in pruned:
|
|
721
|
+
all_tags.update(s.detected_tags)
|
|
722
|
+
|
|
723
|
+
prompt = _L2_PROMPT_TEMPLATE.format(
|
|
724
|
+
project_name=project_name,
|
|
725
|
+
summaries_block=summaries_block,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
for attempt in range(1, MAX_RETRY_LOOPS + 1):
|
|
729
|
+
try:
|
|
730
|
+
raw_response = self._llm.generate(prompt, max_tokens=MAX_L2_TOKENS)
|
|
731
|
+
parsed = self._extract_json_with_fallback(raw_response)
|
|
732
|
+
|
|
733
|
+
if parsed and "summary" in parsed:
|
|
734
|
+
summary_text = parsed["summary"]
|
|
735
|
+
tags = parsed.get("tags", [])
|
|
736
|
+
if isinstance(tags, list):
|
|
737
|
+
all_tags.update(str(t).lower() for t in tags)
|
|
738
|
+
|
|
739
|
+
result = SummaryResult(
|
|
740
|
+
level=ZoomLevel.L2,
|
|
741
|
+
summary=summary_text,
|
|
742
|
+
source_label=project_name,
|
|
743
|
+
source_chunks=len(pruned),
|
|
744
|
+
tokens_used=self._estimate_tokens(summary_text),
|
|
745
|
+
model_used=self._llm.model_name,
|
|
746
|
+
is_dumb_summary=False,
|
|
747
|
+
detected_tags=sorted(all_tags),
|
|
748
|
+
relevance_score=1.0,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Persists Compass in SqliteStore (if available)
|
|
752
|
+
if self._store is not None:
|
|
753
|
+
self._persist_l2(project_name, result)
|
|
754
|
+
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
logger.warning(
|
|
758
|
+
"L2 attempt %d/%d: invalid JSON for project %s",
|
|
759
|
+
attempt, MAX_RETRY_LOOPS, project_name,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
except Exception as e:
|
|
763
|
+
logger.warning(
|
|
764
|
+
"L2 attempt %d/%d failed for project %s: %s",
|
|
765
|
+
attempt, MAX_RETRY_LOOPS, project_name, e,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Dumb Summary L2
|
|
769
|
+
logger.error("L2: all attempts failed for %s — Dumb Summary.", project_name)
|
|
770
|
+
dumb = self._generate_dumb_summary(summaries_block, MAX_L2_TOKENS)
|
|
771
|
+
result = SummaryResult(
|
|
772
|
+
level=ZoomLevel.L2,
|
|
773
|
+
summary=dumb,
|
|
774
|
+
source_label=project_name,
|
|
775
|
+
source_chunks=len(pruned),
|
|
776
|
+
tokens_used=self._estimate_tokens(dumb),
|
|
777
|
+
model_used="dumb_fallback",
|
|
778
|
+
is_dumb_summary=True,
|
|
779
|
+
detected_tags=sorted(all_tags),
|
|
780
|
+
relevance_score=1.0,
|
|
781
|
+
)
|
|
782
|
+
if self._store is not None:
|
|
783
|
+
self._persist_l2(project_name, result)
|
|
784
|
+
return result
|
|
785
|
+
|
|
786
|
+
def _persist_l2(self, project_name: str, result: SummaryResult) -> None:
|
|
787
|
+
"""Writes the L2 Compass in the summary field of the project in SqliteStore."""
|
|
788
|
+
try:
|
|
789
|
+
self._store.update_project(project_name, summary=result.summary)
|
|
790
|
+
logger.info("L2 Compass persisted for project %s.", project_name)
|
|
791
|
+
except Exception as e:
|
|
792
|
+
logger.error("Failed to persist L2 Compass for %s: %s", project_name, e)
|
|
793
|
+
|
|
794
|
+
# ===================================================================
|
|
795
|
+
# HEURISTIC FALLBACK & RETRY LOOP
|
|
796
|
+
# ===================================================================
|
|
797
|
+
|
|
798
|
+
def _extract_json_with_fallback(self, raw_response: str) -> Optional[dict]:
|
|
799
|
+
"""Attempts to extract JSON from LLM response with progressive fallback."""
|
|
800
|
+
if not raw_response or not raw_response.strip():
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
text = raw_response.strip()
|
|
804
|
+
|
|
805
|
+
# Removes markdown fences if present (```json ... ```)
|
|
806
|
+
if text.startswith("```"):
|
|
807
|
+
lines = text.splitlines()
|
|
808
|
+
# Removes first and last line if they are fences
|
|
809
|
+
if lines[0].startswith("```"):
|
|
810
|
+
lines = lines[1:]
|
|
811
|
+
if lines and lines[-1].strip() == "```":
|
|
812
|
+
lines = lines[:-1]
|
|
813
|
+
text = "\n".join(lines).strip()
|
|
814
|
+
|
|
815
|
+
# Attempt 1: direct json.loads
|
|
816
|
+
try:
|
|
817
|
+
return json.loads(text)
|
|
818
|
+
except (json.JSONDecodeError, ValueError):
|
|
819
|
+
pass
|
|
820
|
+
|
|
821
|
+
# Attempt 2: regex to extract embedded JSON
|
|
822
|
+
matches = _JSON_BLOCK_RE.findall(text)
|
|
823
|
+
for match in matches:
|
|
824
|
+
try:
|
|
825
|
+
return json.loads(match)
|
|
826
|
+
except (json.JSONDecodeError, ValueError):
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
# Attempt 3: simple search for outermost { ... }
|
|
830
|
+
first_brace = text.find("{")
|
|
831
|
+
last_brace = text.rfind("}")
|
|
832
|
+
if first_brace != -1 and last_brace > first_brace:
|
|
833
|
+
candidate = text[first_brace:last_brace + 1]
|
|
834
|
+
try:
|
|
835
|
+
return json.loads(candidate)
|
|
836
|
+
except (json.JSONDecodeError, ValueError):
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
logger.debug("JSON extraction failed for response: %.100s...", text)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
def _generate_dumb_summary(self, content: str, max_tokens: int) -> str:
|
|
843
|
+
"""Generates a Dumb Summary (truncated plain text) as a last resort."""
|
|
844
|
+
max_chars = max_tokens * 4
|
|
845
|
+
# Remove excess empty lines
|
|
846
|
+
lines = [line for line in content.splitlines() if line.strip()]
|
|
847
|
+
clean = "\n".join(lines)
|
|
848
|
+
|
|
849
|
+
if len(clean) <= max_chars:
|
|
850
|
+
return f"[DUMB] {clean}"
|
|
851
|
+
|
|
852
|
+
truncated = clean[:max_chars].rsplit(" ", 1)[0]
|
|
853
|
+
return f"[DUMB] {truncated}..."
|
|
854
|
+
|
|
855
|
+
# ===================================================================
|
|
856
|
+
# RELEVANCE PRUNING (Selective Amnesia)
|
|
857
|
+
# ===================================================================
|
|
858
|
+
|
|
859
|
+
def _calculate_relevance(self, summary: SummaryResult) -> float:
|
|
860
|
+
"""Calculates relevance score for L2 pruning.
|
|
861
|
+
|
|
862
|
+
Factors (normalized weights to [0.0, 1.0]):
|
|
863
|
+
- source_chunks: More chunks = larger module = more relevant (40%).
|
|
864
|
+
- high_priority_tags: Core frameworks/APIs tags (40%).
|
|
865
|
+
- is_dumb_summary: Penalizes dumb summaries (20%).
|
|
866
|
+
"""
|
|
867
|
+
score = 0.0
|
|
868
|
+
|
|
869
|
+
# Factor 1: Module size (normalized via log-ish mapping)
|
|
870
|
+
chunk_score = min(1.0, summary.source_chunks / 10.0)
|
|
871
|
+
score += chunk_score * 0.4
|
|
872
|
+
|
|
873
|
+
# Factor 2: Presence of high priority tags
|
|
874
|
+
if summary.detected_tags:
|
|
875
|
+
high_count = sum(1 for t in summary.detected_tags if t in HIGH_PRIORITY_TAGS)
|
|
876
|
+
tag_score = min(1.0, high_count / 3.0)
|
|
877
|
+
score += tag_score * 0.4
|
|
878
|
+
else:
|
|
879
|
+
score += 0.0
|
|
880
|
+
|
|
881
|
+
# Factor 3: Penalty for Dumb Summary
|
|
882
|
+
if summary.is_dumb_summary:
|
|
883
|
+
score += 0.0 # total penalty
|
|
884
|
+
else:
|
|
885
|
+
score += 0.2
|
|
886
|
+
|
|
887
|
+
return round(min(1.0, max(0.0, score)), 3)
|
|
888
|
+
|
|
889
|
+
def _calculate_relevance_from_parts(self, l0_summaries: list[SummaryResult]) -> float:
|
|
890
|
+
"""Calculates relevance of a cluster from its L0s."""
|
|
891
|
+
if not l0_summaries:
|
|
892
|
+
return 0.0
|
|
893
|
+
|
|
894
|
+
# Creates a temporary SummaryResult for the calculation
|
|
895
|
+
all_tags: set[str] = set()
|
|
896
|
+
has_dumb = False
|
|
897
|
+
for s in l0_summaries:
|
|
898
|
+
all_tags.update(s.detected_tags)
|
|
899
|
+
if s.is_dumb_summary:
|
|
900
|
+
has_dumb = True
|
|
901
|
+
|
|
902
|
+
temp = SummaryResult(
|
|
903
|
+
level=ZoomLevel.L1,
|
|
904
|
+
summary="",
|
|
905
|
+
source_label="",
|
|
906
|
+
source_chunks=len(l0_summaries),
|
|
907
|
+
detected_tags=sorted(all_tags),
|
|
908
|
+
is_dumb_summary=has_dumb,
|
|
909
|
+
)
|
|
910
|
+
return self._calculate_relevance(temp)
|
|
911
|
+
|
|
912
|
+
def _prune_low_relevance(
|
|
913
|
+
self,
|
|
914
|
+
summaries: list[SummaryResult],
|
|
915
|
+
threshold: float = L2_RELEVANCE_THRESHOLD,
|
|
916
|
+
) -> list[SummaryResult]:
|
|
917
|
+
"""Removes L1 summaries with relevance below the threshold."""
|
|
918
|
+
result: list[SummaryResult] = []
|
|
919
|
+
|
|
920
|
+
for s in summaries:
|
|
921
|
+
# Calculates relevance if not yet calculated
|
|
922
|
+
if s.relevance_score == 1.0:
|
|
923
|
+
s.relevance_score = self._calculate_relevance(s)
|
|
924
|
+
|
|
925
|
+
if s.relevance_score >= threshold:
|
|
926
|
+
result.append(s)
|
|
927
|
+
else:
|
|
928
|
+
logger.debug(
|
|
929
|
+
"Selective Amnesia: pruned '%s' (score=%.3f < threshold=%.2f)",
|
|
930
|
+
s.source_label, s.relevance_score, threshold,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Safety: never prunes all — keeps at least the top-1
|
|
934
|
+
if not result and summaries:
|
|
935
|
+
best = max(summaries, key=lambda s: s.relevance_score)
|
|
936
|
+
result.append(best)
|
|
937
|
+
logger.warning("Selective Amnesia: kept at least '%s' (score=%.3f).", best.source_label, best.relevance_score)
|
|
938
|
+
|
|
939
|
+
return result
|
|
940
|
+
|
|
941
|
+
# ===================================================================
|
|
942
|
+
# UTILITIES
|
|
943
|
+
# ===================================================================
|
|
944
|
+
|
|
945
|
+
@staticmethod
|
|
946
|
+
def _estimate_tokens(text: str) -> int:
|
|
947
|
+
"""Estimates tokens: ~4 characters per token."""
|
|
948
|
+
return max(1, len(text) // 4)
|