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.
@@ -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)