-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprocessor.py
More file actions
759 lines (634 loc) · 29.9 KB
/
Copy pathprocessor.py
File metadata and controls
759 lines (634 loc) · 29.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
"""Article processing module - adapted from legacy quantcli."""
import re
import ast
import logging
import tempfile
import pdfplumber
import spacy
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional
from .llm import LLMHandler
logger = logging.getLogger(__name__)
import shutil
import subprocess
import sys
def _find_mineru() -> Optional[str]:
"""Find the mineru binary on PATH or in the current venv."""
path = shutil.which("mineru")
if path:
return path
# Check inside the venv's bin directory (Claude Code shell may lack venv activation)
venv_bin = Path(sys.prefix) / "bin" / "mineru"
if venv_bin.is_file():
return str(venv_bin)
return None
_MINERU_PATH = _find_mineru()
MINERU_AVAILABLE = _MINERU_PATH is not None
class PDFLoader:
"""Handles loading and extracting text from PDF files."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
def load_pdf(self, pdf_path: str) -> str:
"""Load text from a PDF file."""
self.logger.info(f"Loading PDF: {pdf_path}")
text = ""
try:
with pdfplumber.open(pdf_path) as pdf:
for page_number, page in enumerate(pdf.pages, start=1):
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
self.logger.debug(f"Extracted text from page {page_number}")
self.logger.info("PDF loaded successfully")
except FileNotFoundError:
self.logger.error(f"PDF file not found: {pdf_path}")
except Exception as e:
self.logger.error(f"Failed to load PDF: {e}")
return text
class TextPreprocessor:
"""Handles preprocessing of extracted text."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
self.url_pattern = re.compile(r'https?://\S+')
self.phrase_pattern = re.compile(r'Electronic copy available at: .*', re.IGNORECASE)
self.number_pattern = re.compile(r'^\d+\s*$', re.MULTILINE)
self.multinew_pattern = re.compile(r'\n+')
self.header_footer_pattern = re.compile(
r'^\s*(Author|Title|Abstract)\s*$',
re.MULTILINE | re.IGNORECASE
)
def preprocess_text(self, text: str) -> str:
"""Preprocess text by removing unnecessary elements."""
self.logger.info("Starting text preprocessing")
try:
original_length = len(text)
text = self.url_pattern.sub('', text)
text = self.phrase_pattern.sub('', text)
text = self.number_pattern.sub('', text)
text = self.multinew_pattern.sub('\n', text)
text = self.header_footer_pattern.sub('', text)
text = text.strip()
processed_length = len(text)
self.logger.info(
f"Text preprocessed: {original_length} -> {processed_length} characters"
)
return text
except Exception as e:
self.logger.error(f"Failed to preprocess text: {e}")
return ""
class HeadingDetector:
"""Detects headings in text using NLP."""
def __init__(self, model: str = "en_core_web_sm"):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
try:
self.nlp = spacy.load(model)
self.logger.info(f"SpaCy model '{model}' loaded successfully")
except Exception as e:
self.logger.error(f"Failed to load SpaCy model '{model}': {e}")
raise
def detect_headings(self, text: str) -> List[str]:
"""Detect potential headings using NLP."""
self.logger.info("Starting heading detection")
headings = []
try:
doc = self.nlp(text)
for sent in doc.sents:
sent_text = sent.text.strip()
# Simple heuristic: headings are short and title-cased
if 2 <= len(sent_text.split()) <= 10 and sent_text.istitle():
headings.append(sent_text)
self.logger.info(f"Detected {len(headings)} headings")
except Exception as e:
self.logger.error(f"Failed to detect headings: {e}")
return headings
class SectionSplitter:
"""Splits text into sections based on detected headings."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
def split_into_sections(self, text: str, headings: List[str]) -> Dict[str, str]:
"""Split text into sections based on headings."""
self.logger.info("Starting section splitting")
sections = defaultdict(str)
current_section = "Introduction"
lines = text.split('\n')
for line_number, line in enumerate(lines, start=1):
line = line.strip()
if line in headings:
current_section = line
self.logger.debug(f"Line {line_number}: New section - {current_section}")
else:
sections[current_section] += line + " "
self.logger.info(f"Split text into {len(sections)} sections")
return sections
class CodeValidator:
"""Validates Python code syntax."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
def validate_code(self, code: str) -> bool:
"""
Validate Python code syntax.
Args:
code: Python code string to validate
Returns:
True if code is syntactically valid, False otherwise
"""
try:
ast.parse(code)
return True
except SyntaxError as e:
self.logger.debug(f"Syntax error in code: {e}")
return False
except Exception as e:
self.logger.error(f"Validation error: {e}")
return False
class KeywordAnalyzer:
"""Analyzes text sections to categorize sentences based on keywords."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
self.risk_management_keywords = {
"drawdown", "volatility", "reduce", "limit", "risk", "risk-adjusted",
"maximal drawdown", "market volatility", "bear markets", "stability",
"sidestep", "reduce drawdown", "stop-loss", "position sizing", "hedging",
"max loss", "capital at risk", "leverage", "margin", "var", "value at risk",
"portfolio protection", "tail risk", "max exposure"
}
self.trading_signal_keywords = {
"buy", "sell", "signal", "indicator", "trend", "sma", "moving average",
"momentum", "rsi", "macd", "bollinger bands", "rachev ratio", "stay long",
"exit", "market timing", "yield curve", "recession", "unemployment",
"housing starts", "treasuries", "economic indicator",
"ema", "atr", "adx", "stochastic", "vwap", "volume weighted",
"crossover", "cross above", "cross below", "golden cross", "death cross",
"overbought", "oversold", "divergence", "breakout", "breakdown",
"mean reversion", "pairs trading", "factor", "alpha", "beta", "sharpe",
"long entry", "short entry", "entry condition", "exit condition",
"go long", "go short", "open position", "close position"
}
self.strategy_parameter_keywords = {
"period", "threshold", "parameter", "lookback", "rebalance",
"weight", "allocation", "window", "lag", "decay", "half-life",
"z-score", "standard deviation", "percentile", "quantile",
"top decile", "bottom decile", "holding period", "frequency",
"daily", "weekly", "monthly", "intraday", "minute", "hourly"
}
# Pattern to match sentences with numbers near indicator names
self._param_pattern = re.compile(
r'(?:\d+[- ]?(?:day|period|bar|minute|hour|week|month))|'
r'(?:(?:period|lookback|window)\s*(?:of|=|:)?\s*\d+)',
re.IGNORECASE
)
self.irrelevant_patterns = [
re.compile(r'figure \d+', re.IGNORECASE),
re.compile(r'\[\d+\]'),
re.compile(r'\(.*?\)'),
re.compile(r'chart', re.IGNORECASE),
re.compile(r'\bfigure\b', re.IGNORECASE),
re.compile(r'performance chart', re.IGNORECASE),
re.compile(r'\d{4}-\d{4}'),
re.compile(r'^\s*$')
]
def keyword_analysis(self, sections: Dict[str, str]) -> Dict[str, List[str]]:
"""Categorize sentences into trading signals and risk management."""
self.logger.info("Starting keyword analysis")
keyword_map = defaultdict(list)
processed_sentences = set()
for section, content in sections.items():
for sent in content.split('. '):
sent_text = sent.lower().strip()
if any(pattern.search(sent_text) for pattern in self.irrelevant_patterns):
continue
if sent_text in processed_sentences:
continue
processed_sentences.add(sent_text)
if any(kw in sent_text for kw in self.trading_signal_keywords):
keyword_map['trading_signal'].append(sent.strip())
if any(kw in sent_text for kw in self.risk_management_keywords):
keyword_map['risk_management'].append(sent.strip())
if (any(kw in sent_text for kw in self.strategy_parameter_keywords)
or self._param_pattern.search(sent_text)):
keyword_map['strategy_parameters'].append(sent.strip())
# Remove duplicates and sort
for category, sentences in keyword_map.items():
unique_sentences = sorted(set(sentences), key=lambda x: len(x))
keyword_map[category] = unique_sentences
self.logger.info("Keyword analysis completed")
return keyword_map
class MinerULoader:
"""Converts PDF to structured markdown using MinerU (magic-pdf)."""
def __init__(self):
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
@staticmethod
def _unload_ollama_models() -> None:
"""Ask Ollama to unload all resident models to free GPU VRAM."""
import urllib.request
import json as _json
try:
resp = urllib.request.urlopen(
"http://localhost:11434/api/ps", timeout=3
)
data = _json.loads(resp.read())
for model in data.get("models", []):
name = model.get("name")
if not name:
continue
req = urllib.request.Request(
"http://localhost:11434/api/generate",
data=_json.dumps(
{"model": name, "keep_alive": 0}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
def load_and_split(self, pdf_path: str) -> Dict[str, str]:
"""Run MinerU CLI on *pdf_path*, return sections dict.
Returns:
Dict mapping section headings to their text content,
with LaTeX equations preserved as ``$...$`` / ``$$...$$``.
"""
self.logger.info(f"Loading PDF via MinerU: {pdf_path}")
pdf_path = str(Path(pdf_path).resolve())
if not Path(pdf_path).exists():
self.logger.error(f"PDF file not found: {pdf_path}")
return {}
# Free GPU VRAM — MinerU's VLM needs the GPU that Ollama may occupy
self.logger.info("Unloading Ollama models to free GPU for MinerU")
self._unload_ollama_models()
try:
with tempfile.TemporaryDirectory() as tmp_dir:
result = subprocess.run(
[_MINERU_PATH, "-p", pdf_path, "-o", tmp_dir, "-m", "auto"],
capture_output=True, text=True, timeout=600,
)
if result.returncode != 0:
self.logger.error(f"MinerU CLI failed: {result.stderr[:500]}")
return {}
# MinerU outputs to <tmp_dir>/<stem>/hybrid_auto/<stem>.md
stem = Path(pdf_path).stem
md_candidates = list(Path(tmp_dir).rglob(f"*.md"))
if not md_candidates:
self.logger.error("MinerU produced no markdown output")
return {}
md_content = md_candidates[0].read_text(encoding="utf-8")
except subprocess.TimeoutExpired:
self.logger.error("MinerU timed out after 600s")
return {}
except Exception as e:
self.logger.error(f"MinerU pipeline failed: {e}")
return {}
sections = self._parse_markdown_sections(md_content)
self.logger.info(f"MinerU extracted {len(sections)} sections")
return sections
@staticmethod
def _parse_markdown_sections(md_content: str) -> Dict[str, str]:
"""Split markdown on heading lines into a sections dict.
Handles any heading level (``#``, ``##``, ``###``, etc.).
Text before the first heading is stored under ``"Introduction"``.
LaTeX equations (``$...$``, ``$$...$$``) are preserved verbatim.
Args:
md_content: Raw markdown string from MinerU.
Returns:
Dict mapping heading text to section body.
"""
if not md_content or not md_content.strip():
return {}
heading_re = re.compile(r'^(#{1,6})\s+(.+)$')
sections: Dict[str, str] = {}
current_heading = "Introduction"
current_lines: list[str] = []
for line in md_content.splitlines():
m = heading_re.match(line.strip())
if m:
# Store previous section
body = "\n".join(current_lines).strip()
if body:
sections[current_heading] = body
current_heading = m.group(2).strip()
current_lines = []
else:
current_lines.append(line)
# Store last section
body = "\n".join(current_lines).strip()
if body:
sections[current_heading] = body
return sections
class ArticleProcessor:
"""Main processor for article extraction and code generation."""
def __init__(self, config, max_refine_attempts: int = 6, max_fidelity_attempts: int = 3):
self.config = config
self.logger = logging.getLogger(f"quantcoder.{self.__class__.__name__}")
pdf_backend = getattr(getattr(config, "tools", None), "pdf_backend", "auto")
self._use_mineru = self._resolve_pdf_backend(pdf_backend)
if self._use_mineru:
self.logger.info("Using MinerU PDF backend")
self._mineru_loader = MinerULoader()
else:
self.logger.info("Using pdfplumber PDF backend")
# Legacy pdfplumber pipeline (always available for fallback)
self.pdf_loader = PDFLoader()
self.preprocessor = TextPreprocessor()
self.heading_detector = HeadingDetector()
self.section_splitter = SectionSplitter()
self.keyword_analyzer = KeywordAnalyzer()
self.llm_handler = LLMHandler(config)
self.max_refine_attempts = max_refine_attempts
self.max_fidelity_attempts = max_fidelity_attempts
@staticmethod
def _resolve_pdf_backend(pdf_backend: str) -> bool:
"""Determine whether to use MinerU based on config value.
Args:
pdf_backend: ``"auto"``, ``"mineru"``, or ``"pdfplumber"``.
Returns:
True if MinerU should be used, False for pdfplumber.
Raises:
ImportError: If ``"mineru"`` is requested but not installed.
"""
if pdf_backend == "pdfplumber":
return False
if pdf_backend == "mineru":
if not MINERU_AVAILABLE:
raise ImportError(
"MinerU is not installed. Install with: pip install 'quantcoder-cli[mineru]'"
)
return True
# "auto" — use MinerU if available
return MINERU_AVAILABLE
def extract_sections(self, pdf_path: str) -> Dict[str, str]:
"""Extract paper sections from PDF (no keyword filtering).
Uses MinerU when configured, with automatic fallback to pdfplumber
if MinerU returns empty results.
Returns:
Dict mapping section names to their full text.
"""
if self._use_mineru:
self.logger.info(f"Extracting sections via MinerU: {pdf_path}")
sections = self._mineru_loader.load_and_split(pdf_path)
if sections:
return sections
self.logger.warning(
"MinerU returned empty results — falling back to pdfplumber"
)
return self._extract_sections_pdfplumber(pdf_path)
def _extract_sections_pdfplumber(self, pdf_path: str) -> Dict[str, str]:
"""Extract sections using the legacy pdfplumber + SpaCy pipeline."""
self.logger.info(f"Extracting sections via pdfplumber: {pdf_path}")
raw_text = self.pdf_loader.load_pdf(pdf_path)
if not raw_text:
self.logger.error("No text extracted from PDF")
return {}
preprocessed_text = self.preprocessor.preprocess_text(raw_text)
if not preprocessed_text:
self.logger.error("Preprocessing failed")
return {}
headings = self.heading_detector.detect_headings(preprocessed_text)
if not headings:
self.logger.warning("No headings detected. Using default sectioning")
sections = self.section_splitter.split_into_sections(preprocessed_text, headings)
return dict(sections)
def extract_structure(self, pdf_path: str) -> Dict[str, List[str]]:
"""Extract structured data from PDF (legacy keyword-filtered path)."""
self.logger.info(f"Starting extraction for PDF: {pdf_path}")
raw_text = self.pdf_loader.load_pdf(pdf_path)
if not raw_text:
self.logger.error("No text extracted from PDF")
return {}
preprocessed_text = self.preprocessor.preprocess_text(raw_text)
if not preprocessed_text:
self.logger.error("Preprocessing failed")
return {}
headings = self.heading_detector.detect_headings(preprocessed_text)
if not headings:
self.logger.warning("No headings detected. Using default sectioning")
sections = self.section_splitter.split_into_sections(preprocessed_text, headings)
keyword_analysis = self.keyword_analyzer.keyword_analysis(sections)
return keyword_analysis
def generate_summary(self, extracted_data: Dict[str, List[str]]) -> Optional[str]:
"""Generate summary from extracted data (legacy single-pass)."""
return self.llm_handler.generate_summary(extracted_data)
def generate_two_pass_summary(self, pdf_path: str) -> Optional[str]:
"""Two-pass LLM summarization: extract then interpret.
Falls back to the legacy keyword-filtered path if either LLM pass fails.
"""
self.logger.info("Starting two-pass summarization pipeline")
# Step 1 — get full sections (no keyword filter)
sections = self.extract_sections(pdf_path)
if not sections:
self.logger.warning("No sections extracted, falling back to legacy path")
return self._legacy_summarize(pdf_path)
# Step 2 — Pass 1: extract verbatim quotes
extractions = self.llm_handler.extract_key_passages(sections)
if not extractions:
self.logger.warning("Pass 1 failed, falling back to legacy path")
return self._legacy_summarize(pdf_path)
# Step 3 — Pass 2: interpret into strategy spec
summary = self.llm_handler.interpret_strategy(extractions)
if not summary:
self.logger.warning("Pass 2 failed, falling back to legacy path")
return self._legacy_summarize(pdf_path)
self.logger.info("Two-pass summarization complete")
return summary
def _legacy_summarize(self, pdf_path: str) -> Optional[str]:
"""Legacy single-pass summarization via KeywordAnalyzer + rigid template."""
self.logger.info("Using legacy summarization path")
extracted_data = self.extract_structure(pdf_path)
if not extracted_data:
return None
return self.llm_handler.generate_summary(extracted_data)
def extract_structure_and_generate_code(self, pdf_path: str) -> Dict:
"""Extract structure and generate QuantConnect code."""
self.logger.info("Starting extraction and code generation")
# Use two-pass pipeline (with automatic legacy fallback)
summary = self.generate_two_pass_summary(pdf_path)
if not summary:
self.logger.error("Failed to generate summary")
summary = "Summary could not be generated."
qc_code = self.generate_code_from_summary(summary)
if qc_code is None:
qc_code = "QuantConnect code could not be generated successfully."
return {"summary": summary, "code": qc_code}
def generate_code_from_summary(self, summary_text: str) -> Optional[str]:
"""Generate QuantConnect code from a pre-existing summary.
Uses a two-stage pipeline:
Stage 1 — generate QC framework with method stubs for novel math.
Stage 2 — fill stub methods with mathematical implementations.
Falls back to single-shot ``generate_qc_code()`` if Stage 1 fails.
Falls back to the Stage 1 framework if Stage 2 fails syntax checks.
After both stages, runs the fidelity assessment loop (unchanged).
Args:
summary_text: The strategy summary text
Returns:
Generated QuantConnect code or None
"""
self.logger.info("Generating code from summary text")
if not summary_text:
self.logger.error("Empty summary provided")
return None
# -- Phase 1: two-stage code generation + syntax validation -----------
# Stage 1: framework with stubs (fall back to single-shot)
qc_code = self.llm_handler.generate_qc_framework(summary_text)
if not qc_code:
self.logger.warning("Stage 1 failed, falling back to single-shot generate_qc_code")
qc_code = self.llm_handler.generate_qc_code(summary_text)
# Syntax validation loop on Stage 1 output
attempt = 0
while qc_code and not self._validate_code(qc_code) and attempt < self.max_refine_attempts:
self.logger.info(f"Syntax refine attempt {attempt + 1}")
qc_code = self.llm_handler.refine_code(qc_code)
if qc_code and self._validate_code(qc_code):
self.logger.info("Refined code is syntactically valid")
break
attempt += 1
if not qc_code or not self._validate_code(qc_code):
self.logger.error("Failed to generate syntactically valid code")
return "QuantConnect code could not be generated successfully."
# Stage 2: fill mathematical core (only if stubs detected)
framework_code = qc_code # save as fallback anchor
if self._has_stub_methods(framework_code):
self.logger.info("Stubs detected — running Stage 2 (fill mathematical core)")
filled_code = self.llm_handler.fill_mathematical_core(
summary_text, framework_code
)
if filled_code:
# Syntax validation loop on Stage 2 output
s2_attempt = 0
while (
not self._validate_code(filled_code)
and s2_attempt < self.max_refine_attempts
):
self.logger.info(
f"Stage 2 syntax refine attempt {s2_attempt + 1}"
)
refined = self.llm_handler.refine_code(filled_code)
if refined and self._validate_code(refined):
filled_code = refined
break
elif refined:
filled_code = refined
s2_attempt += 1
if self._validate_code(filled_code):
self.logger.info("Stage 2 code is syntactically valid")
qc_code = filled_code
else:
self.logger.warning(
"Stage 2 code failed syntax validation — "
"keeping Stage 1 framework"
)
else:
self.logger.warning(
"Stage 2 returned no code — keeping Stage 1 framework"
)
else:
self.logger.info("No stubs detected — skipping Stage 2")
# -- Phase 1.5: QC API linting ----------------------------------------
from .qc_linter import lint_qc_code
lint_result = lint_qc_code(qc_code)
if lint_result.had_fixes and self._validate_code(lint_result.code):
self.logger.info(
"QC linter applied %d auto-fixes",
sum(1 for i in lint_result.issues if i.fixed),
)
qc_code = lint_result.code
for hint in lint_result.unfixable_hints:
self.logger.warning("QC linter warning: %s", hint)
# -- Phase 2: fidelity assessment loop (unchanged) --------------------
for fidelity_attempt in range(self.max_fidelity_attempts):
self.logger.info(f"Fidelity assessment attempt {fidelity_attempt + 1}")
assessment = self.llm_handler.assess_fidelity(summary_text, qc_code)
if assessment.get("faithful") and assessment.get("score", 0) >= 3:
self.logger.info(
f"Code passed fidelity check (score={assessment['score']})"
)
return qc_code
self.logger.warning(
f"Fidelity check failed (score={assessment.get('score', 0)}, "
f"issues={assessment.get('issues', [])})"
)
# Regenerate with critique
new_code = self.llm_handler.regenerate_with_critique(
summary_text, qc_code, assessment
)
if not new_code:
self.logger.warning("Regeneration returned no code, keeping previous version")
continue
# Mini syntax validation loop on the new code
syntax_attempt = 0
while not self._validate_code(new_code) and syntax_attempt < 3:
self.logger.info(f"Syntax refine on regenerated code, attempt {syntax_attempt + 1}")
refined = self.llm_handler.refine_code(new_code)
if refined and self._validate_code(refined):
new_code = refined
break
elif refined:
new_code = refined
syntax_attempt += 1
if self._validate_code(new_code):
# Lint regenerated code too
regen_lint = lint_qc_code(new_code)
if regen_lint.had_fixes and self._validate_code(regen_lint.code):
new_code = regen_lint.code
qc_code = new_code
else:
self.logger.warning("Regenerated code failed syntax validation, keeping previous version")
# Graceful degradation: fidelity never passed, keep last valid code
self.logger.warning(
f"Fidelity assessment did not pass after {self.max_fidelity_attempts} "
f"attempts — returning last syntactically valid code"
)
return qc_code
def _validate_code(self, code: str) -> bool:
"""Validate code syntax."""
try:
ast.parse(code)
return True
except SyntaxError as e:
self.logger.error(f"Syntax error in code: {e}")
return False
except Exception as e:
self.logger.error(f"Validation error: {e}")
return False
@staticmethod
def _has_stub_methods(code: str) -> bool:
"""Detect whether *code* contains method stubs (def + docstring + pass).
A stub is a ``def`` whose body consists of only an optional docstring
followed by ``pass``. We use a simple line-based heuristic:
scan for ``pass`` lines and look backward for a preceding ``def``
with only blank lines, comments, or docstring delimiters between.
False positives are harmless (Stage 2 preserves non-stub code).
"""
if not code:
return False
lines = code.splitlines()
for i, line in enumerate(lines):
stripped = line.strip()
if stripped != "pass":
continue
# Walk backward from this ``pass`` looking for a ``def``
in_docstring = False
j = i - 1
while j >= 0:
prev = lines[j].strip()
# toggle docstring state on triple-quote lines
if prev.startswith('"""') or prev.startswith("'''"):
if prev.count('"""') == 2 or prev.count("'''") == 2:
# single-line docstring — keep walking
j -= 1
continue
in_docstring = not in_docstring
j -= 1
continue
if in_docstring:
j -= 1
continue
# skip blank lines and comments
if prev == "" or prev.startswith("#"):
j -= 1
continue
# the first real statement should be a def
if prev.startswith("def ") and prev.endswith(":"):
return True
# anything else means this pass is not a stub
break
return False