Coverage for little_loops / output_parsing.py: 8%

211 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

1"""Output parsing utilities for little-loops. 

2 

3Provides parsing functions for Claude CLI command outputs, 

4used by both issue_manager (ll-auto) and worker_pool (ll-parallel). 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import re 

11from typing import Any 

12 

13# Regex patterns for standardized output parsing 

14# Support #, ##, and ### headers with flexible spacing and optional formatting 

15# Handles: ## VERDICT, ###VERDICT, ## **VERDICT**, ## VERDICT 

16SECTION_PATTERN = re.compile( 

17 r"^#{1,3}\s*\**(\w+)\**\s*$", 

18 re.MULTILINE, 

19) 

20TABLE_ROW_PATTERN = re.compile(r"\|\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|") 

21STATUS_PATTERN = re.compile(r"^- (\w+): (\w+)", re.MULTILINE) 

22 

23# Valid verdicts for ready-issue 

24VALID_VERDICTS = ("READY", "CORRECTED", "NOT_READY", "NEEDS_REVIEW", "CLOSE", "BLOCKED") 

25 

26 

27def extract_tagged_json(raw: str, tag: str) -> tuple[list | dict | None, str | None]: 

28 """Extract and parse a tagged JSON line from LLM output. 

29 

30 Scans for the last line starting with ``tag:`` and parses the JSON. On 

31 failure attempts bounded structural repair (balancing trailing ``]``/``}``). 

32 

33 Args: 

34 raw: Full LLM output text to search 

35 tag: Tag prefix (without colon), e.g. ``"ASSUMPTIONS_JSON"`` 

36 

37 Returns: 

38 ``(data, None)`` on clean parse, ``(data, warning)`` on successful 

39 repair, ``(None, error_msg)`` on unrecoverable failure. Never swallows 

40 — callers must surface the error when ``data is None``. 

41 """ 

42 prefix = tag + ":" 

43 found: str | None = None 

44 for line in reversed(raw.split("\n")): 

45 stripped = line.strip() 

46 if stripped.startswith(prefix): 

47 found = stripped[len(prefix):].strip() 

48 break 

49 

50 if found is None: 

51 return None, f"No {tag}: line found in output" 

52 

53 try: 

54 return json.loads(found), None 

55 except (json.JSONDecodeError, ValueError): 

56 pass 

57 

58 # Attempt repair: balance unmatched square brackets and curly braces. 

59 # Close inner braces before outer brackets (inner-to-outer order). 

60 open_sq = found.count("[") - found.count("]") 

61 open_cu = found.count("{") - found.count("}") 

62 if open_sq > 0 or open_cu > 0: 

63 repaired = found + ("}" * open_cu) + ("]" * open_sq) 

64 try: 

65 warning = f"Repaired malformed {tag}: JSON (added {open_cu} '}}', {open_sq} ']')" 

66 return json.loads(repaired), warning 

67 except (json.JSONDecodeError, ValueError): 

68 pass 

69 

70 tail = found[-200:] if len(found) > 200 else found 

71 return None, f"Unrecoverable JSON parse failure for {tag}: — tail: {tail!r}" 

72 

73 

74def _clean_verdict_content(content: str) -> str: 

75 """Clean verdict content by removing common formatting artifacts. 

76 

77 Handles: 

78 - Code block markers (``` and `) 

79 - Markdown bold/italic (** and *) 

80 - Template brackets ([]) 

81 - Leading/trailing whitespace 

82 - Colons after verdict 

83 

84 Args: 

85 content: Raw verdict content from output 

86 

87 Returns: 

88 Cleaned content ready for verdict extraction 

89 """ 

90 # Remove code fence markers (``` or ```) 

91 content = re.sub(r"^```\w*\s*", "", content) 

92 content = re.sub(r"\s*```$", "", content) 

93 # Remove inline code backticks 

94 content = content.replace("`", "") 

95 # Remove markdown bold/italic 

96 content = content.replace("**", "").replace("*", "") 

97 # Remove template brackets 

98 content = content.strip("[]") 

99 return content.strip() 

100 

101 

102def _extract_verdict_from_text(text: str) -> str | None: 

103 """Extract a valid verdict from arbitrary text. 

104 

105 Searches for valid verdict keywords in the text, handling various 

106 formats like "READY", "The verdict is READY", "NOT_READY", etc. 

107 

108 Args: 

109 text: Text that may contain a verdict 

110 

111 Returns: 

112 Valid verdict string or None if not found 

113 """ 

114 text_upper = text.upper() 

115 

116 # Check each valid verdict (check NOT_READY before READY to avoid partial match) 

117 # Order matters: check longer/compound verdicts first 

118 for verdict in ("NOT_READY", "NEEDS_REVIEW", "CORRECTED", "BLOCKED", "READY", "CLOSE"): 

119 # Match verdict as a word boundary (not part of another word) 

120 # Handle both underscore and space variants 

121 patterns = [ 

122 rf"\b{verdict}\b", 

123 rf"\b{verdict.replace('_', ' ')}\b", # NOT READY, NEEDS REVIEW 

124 rf"\b{verdict.replace('_', '-')}\b", # NOT-READY, NEEDS-REVIEW 

125 ] 

126 for pattern in patterns: 

127 if re.search(pattern, text_upper): 

128 # Normalize to underscore format 

129 return verdict 

130 

131 # Try common Claude phrasings that map to verdicts 

132 # Note: Using re.IGNORECASE since patterns are lowercase 

133 phrasing_map = [ 

134 # Patterns for READY 

135 (r"\bissue\s+is\s+ready\b", "READY"), 

136 (r"\bready\s+for\s+implementation\b", "READY"), 

137 (r"\bimplementation[\s-]ready\b", "READY"), 

138 (r"\bapproved\s+for\s+implementation\b", "READY"), 

139 (r"\bproceed\s+(to|with)\s+implementation\b", "READY"), 

140 # Patterns for CLOSE 

141 (r"\bshould\s+be\s+closed\b", "CLOSE"), 

142 (r"\bclose\s+this\s+issue\b", "CLOSE"), 

143 (r"\bmark\s+as\s+closed\b", "CLOSE"), 

144 (r"\balready\s+fixed\b", "CLOSE"), 

145 (r"\binvalid\s+reference\b", "CLOSE"), 

146 (r"\bmove.*to.*completed\b", "CLOSE"), # "move this issue to the completed directory" 

147 (r"\bclosure\s+status\b", "CLOSE"), # "closure status" 

148 # Patterns for NOT_READY 

149 (r"\bnot\s+ready\b", "NOT_READY"), # General "not ready" pattern 

150 (r"\bneeds?\s+more\s+work\b", "NOT_READY"), 

151 (r"\brequires?\s+clarification\b", "NOT_READY"), 

152 (r"\bmissing\s+information\b", "NOT_READY"), 

153 # Patterns for CORRECTED 

154 (r"\bcorrections?\s+made\b", "CORRECTED"), 

155 (r"\bupdated?\s+and\s+ready\b", "CORRECTED"), 

156 (r"\bfixed?\s+and\s+ready\b", "CORRECTED"), 

157 ] 

158 

159 for pattern, verdict in phrasing_map: 

160 if re.search(pattern, text, re.IGNORECASE): 

161 return verdict 

162 

163 return None 

164 

165 

166def parse_sections(output: str) -> dict[str, str]: 

167 """Parse output into sections by ## SECTION_NAME headers. 

168 

169 The standardized slash command output format uses ## SECTION_NAME 

170 headers (uppercase with underscores) to delimit sections. 

171 

172 Args: 

173 output: The stdout from a slash command 

174 

175 Returns: 

176 dict mapping section names to their content 

177 """ 

178 sections: dict[str, str] = {} 

179 current_section = "PREAMBLE" 

180 current_content: list[str] = [] 

181 

182 for line in output.split("\n"): 

183 match = SECTION_PATTERN.match(line) 

184 if match: 

185 # Save previous section 

186 sections[current_section] = "\n".join(current_content).strip() 

187 current_section = match.group(1) 

188 current_content = [] 

189 else: 

190 current_content.append(line) 

191 

192 # Save final section 

193 sections[current_section] = "\n".join(current_content).strip() 

194 return sections 

195 

196 

197def parse_validation_table(section_content: str) -> dict[str, dict[str, str]]: 

198 """Parse a validation table from section content. 

199 

200 Expects format: 

201 | Check | Status | Details | 

202 |-------|--------|---------| 

203 | Format | PASS | ... | 

204 

205 Args: 

206 section_content: Content of the VALIDATION section 

207 

208 Returns: 

209 dict mapping check names to {status, details} 

210 """ 

211 results: dict[str, dict[str, str]] = {} 

212 for match in TABLE_ROW_PATTERN.finditer(section_content): 

213 check_name = match.group(1) 

214 # Skip header row indicators 

215 if check_name.lower() in ("check", "---", ""): 

216 continue 

217 results[check_name] = { 

218 "status": match.group(2).upper(), 

219 "details": match.group(3).strip(), 

220 } 

221 return results 

222 

223 

224def parse_status_lines(section_content: str) -> dict[str, str]: 

225 """Parse status lines from section content. 

226 

227 Expects format: 

228 - tests: PASS 

229 - lint: PASS 

230 

231 Args: 

232 section_content: Content of a section with status lines 

233 

234 Returns: 

235 dict mapping item names to status values 

236 """ 

237 results: dict[str, str] = {} 

238 for match in STATUS_PATTERN.finditer(section_content): 

239 results[match.group(1)] = match.group(2).upper() 

240 return results 

241 

242 

243def parse_ready_issue_output(output: str) -> dict[str, Any]: 

244 """Extract verdict and concerns from ready-issue output. 

245 

246 The ready-issue command outputs structured sections with a VERDICT 

247 section containing READY, CORRECTED, NOT_READY, NEEDS_REVIEW, or CLOSE. 

248 

249 Supports both old format (VERDICT: READY) and new standardized format 

250 (## VERDICT\\nREADY) for backwards compatibility. 

251 

252 Args: 

253 output: The stdout from the ready-issue command 

254 

255 Returns: 

256 dict with keys: 

257 - verdict: str ("READY", "CORRECTED", "NOT_READY", "NEEDS_REVIEW", 

258 "CLOSE", "BLOCKED", or "UNKNOWN") 

259 - concerns: list[str] of concern messages 

260 - is_ready: bool indicating if issue is ready for implementation 

261 - was_corrected: bool indicating if corrections were made 

262 - should_close: bool indicating if issue should be closed 

263 - close_reason: str|None (e.g., "already_fixed", "invalid_ref") 

264 - close_status: str|None (e.g., "Closed - Already Fixed") 

265 - corrections: list[str] of corrections made 

266 - validated_file_path: str|None path to the file that was validated 

267 - sections: dict of parsed sections (if standardized format) 

268 - validation: dict of validation results (if standardized format) 

269 """ 

270 # Try new standardized format first 

271 sections = parse_sections(output) 

272 verdict = "UNKNOWN" 

273 concerns: list[str] = [] 

274 corrections: list[str] = [] 

275 validation: dict[str, dict[str, str]] = {} 

276 close_reason: str | None = None 

277 close_status: str | None = None 

278 validated_file_path: str | None = None 

279 

280 # Strategy 1: Check for VERDICT section (new format with # or ## header) 

281 if "VERDICT" in sections: 

282 verdict_section = sections["VERDICT"].strip() 

283 

284 # Try each non-empty line until we find a verdict 

285 for line in verdict_section.split("\n"): 

286 line = line.strip() 

287 if not line: 

288 continue 

289 

290 # Clean the line of formatting artifacts 

291 cleaned = _clean_verdict_content(line) 

292 if not cleaned: 

293 continue 

294 

295 # Try to extract verdict from cleaned line 

296 extracted = _extract_verdict_from_text(cleaned) 

297 if extracted: 

298 verdict = extracted 

299 break 

300 

301 # Strategy 2: Old format (VERDICT: READY) anywhere in output 

302 if verdict == "UNKNOWN": 

303 verdict_match = re.search( 

304 r"VERDICT:\s*(READY|CORRECTED|NOT[_\s-]?READY|NEEDS[_\s-]?REVIEW|CLOSE|BLOCKED)", 

305 output, 

306 re.IGNORECASE, 

307 ) 

308 if verdict_match: 

309 verdict = verdict_match.group(1).upper().replace(" ", "_").replace("-", "_") 

310 

311 # Strategy 3: Look for verdict keywords near "verdict" mentions 

312 if verdict == "UNKNOWN": 

313 # Find lines containing "verdict" and check for verdict keywords 

314 for line in output.split("\n"): 

315 if "verdict" in line.lower(): 

316 extracted = _extract_verdict_from_text(line) 

317 if extracted: 

318 verdict = extracted 

319 break 

320 

321 # Strategy 4: Scan entire output for standalone verdict keywords 

322 # (last resort - may have false positives but better than UNKNOWN) 

323 if verdict == "UNKNOWN": 

324 extracted = _extract_verdict_from_text(output) 

325 if extracted: 

326 verdict = extracted 

327 

328 # Strategy 5: Clean the entire output and retry extraction 

329 # Handles cases where formatting artifacts (bold, backticks) break word boundaries 

330 if verdict == "UNKNOWN": 

331 cleaned_output = _clean_verdict_content(output) 

332 extracted = _extract_verdict_from_text(cleaned_output) 

333 if extracted: 

334 verdict = extracted 

335 

336 # Parse CORRECTIONS_MADE section if present (moved before Strategy 6) 

337 if "CORRECTIONS_MADE" in sections: 

338 corrections_content = sections["CORRECTIONS_MADE"] 

339 for line in corrections_content.split("\n"): 

340 line = line.strip() 

341 if line.startswith("- ") and line != "- None": 

342 corrections.append(line[2:]) 

343 

344 # Strategy 6: Infer from READY_FOR section 

345 # If "READY_FOR" section exists with "Implementation: Yes", infer verdict 

346 if verdict == "UNKNOWN" and "READY_FOR" in sections: 

347 ready_for_content = sections["READY_FOR"] 

348 # Check for "implementation" + "yes" pattern (handles bold markers, colons, etc.) 

349 # Handles: "Implementation: Yes", "**Implementation:** Yes", etc. 

350 if re.search(r"implementation[\s:\*]*yes", ready_for_content, re.IGNORECASE): 

351 # If corrections were made, verdict is CORRECTED; otherwise READY 

352 verdict = "CORRECTED" if corrections else "READY" 

353 

354 # Parse CONCERNS section (new format) 

355 if "CONCERNS" in sections: 

356 concern_content = sections["CONCERNS"] 

357 for line in concern_content.split("\n"): 

358 line = line.strip() 

359 if line.startswith("- ") and line != "- None": 

360 concerns.append(line[2:]) # Remove "- " prefix 

361 

362 # Fall back to old concern detection 

363 if not concerns: 

364 for line in output.split("\n"): 

365 line_stripped = line.strip() 

366 if any( 

367 indicator in line_stripped 

368 for indicator in ["WARNING", "Concern:", "Issue:", "Missing:"] 

369 ): 

370 concerns.append(line_stripped) 

371 

372 # Parse CLOSE_REASON section if present (for CLOSE verdict) 

373 if "CLOSE_REASON" in sections: 

374 close_reason_content = sections["CLOSE_REASON"] 

375 # Look for "- Reason: <value>" line 

376 for line in close_reason_content.split("\n"): 

377 # Strip whitespace and bold markers (**) that Claude sometimes adds 

378 line = line.strip().replace("**", "") 

379 if line.lower().startswith("- reason:"): 

380 reason_value = line.split(":", 1)[1].strip().lower() 

381 # Also strip backticks that may wrap the value 

382 close_reason = reason_value.strip("`").strip() 

383 break 

384 # Also handle "Reason: <value>" without dash 

385 if line.lower().startswith("reason:"): 

386 reason_value = line.split(":", 1)[1].strip().lower() 

387 close_reason = reason_value.strip("`").strip() 

388 break 

389 

390 # Parse CLOSE_STATUS section if present 

391 if "CLOSE_STATUS" in sections: 

392 close_status_content = sections["CLOSE_STATUS"].strip() 

393 # Take first non-empty line as the status 

394 for line in close_status_content.split("\n"): 

395 line = line.strip() 

396 if line and not line.startswith("#"): 

397 close_status = line 

398 break 

399 

400 # Parse VALIDATED_FILE section if present (for path validation) 

401 if "VALIDATED_FILE" in sections: 

402 validated_file_content = sections["VALIDATED_FILE"].strip() 

403 # Take first non-empty line as the file path 

404 for line in validated_file_content.split("\n"): 

405 line = line.strip() 

406 # Skip empty lines, comments, and template placeholders 

407 if line and not line.startswith("#") and not line.startswith("["): 

408 # Strip markdown backticks that Claude sometimes wraps paths in 

409 validated_file_path = line.strip("`") 

410 break 

411 

412 # Parse VALIDATION section if present 

413 if "VALIDATION" in sections: 

414 validation = parse_validation_table(sections["VALIDATION"]) 

415 

416 # Determine flags based on verdict 

417 is_ready = verdict in ("READY", "CORRECTED") 

418 was_corrected = verdict == "CORRECTED" or len(corrections) > 0 

419 should_close = verdict == "CLOSE" 

420 is_blocked = verdict == "BLOCKED" 

421 

422 return { 

423 "verdict": verdict, 

424 "concerns": concerns, 

425 "is_ready": is_ready, 

426 "was_corrected": was_corrected, 

427 "should_close": should_close, 

428 "is_blocked": is_blocked, 

429 "close_reason": close_reason, 

430 "close_status": close_status, 

431 "corrections": corrections, 

432 "validated_file_path": validated_file_path, 

433 "sections": sections, 

434 "validation": validation, 

435 } 

436 

437 

438def parse_manage_issue_output(output: str) -> dict[str, Any]: 

439 """Extract structured data from manage-issue output. 

440 

441 The manage-issue command outputs structured sections with metadata, 

442 files changed, commits, verification results, and final status. 

443 

444 Args: 

445 output: The stdout from the manage-issue command 

446 

447 Returns: 

448 dict with keys: 

449 - status: str ("COMPLETED", "FAILED", "BLOCKED", or "UNKNOWN") 

450 - files_changed: list[str] of modified files 

451 - files_created: list[str] of created files 

452 - commits: list[str] of commit hashes/messages 

453 - verification: dict of verification results 

454 - ooda_impact: dict of OODA impact status 

455 - sections: dict of all parsed sections 

456 """ 

457 sections = parse_sections(output) 

458 status = "UNKNOWN" 

459 files_changed: list[str] = [] 

460 files_created: list[str] = [] 

461 commits: list[str] = [] 

462 verification: dict[str, str] = {} 

463 ooda_impact: dict[str, str] = {} 

464 

465 # Parse RESULT section for status 

466 if "RESULT" in sections: 

467 status_match = re.search(r"Status:\s*(\w+)", sections["RESULT"]) 

468 if status_match: 

469 status = status_match.group(1).upper() 

470 

471 # Parse FILES_CHANGED section 

472 if "FILES_CHANGED" in sections: 

473 for line in sections["FILES_CHANGED"].split("\n"): 

474 line = line.strip() 

475 if line.startswith("- ") and line != "- None": 

476 files_changed.append(line[2:]) 

477 

478 # Parse FILES_CREATED section 

479 if "FILES_CREATED" in sections: 

480 for line in sections["FILES_CREATED"].split("\n"): 

481 line = line.strip() 

482 if line.startswith("- ") and line != "- None": 

483 files_created.append(line[2:]) 

484 

485 # Parse COMMITS section 

486 if "COMMITS" in sections: 

487 for line in sections["COMMITS"].split("\n"): 

488 line = line.strip() 

489 if line.startswith("- ") and line != "- None": 

490 commits.append(line[2:]) 

491 

492 # Parse VERIFICATION section 

493 if "VERIFICATION" in sections: 

494 verification = parse_status_lines(sections["VERIFICATION"]) 

495 

496 # Parse OODA_IMPACT section 

497 if "OODA_IMPACT" in sections: 

498 for line in sections["OODA_IMPACT"].split("\n"): 

499 line = line.strip() 

500 if line.startswith("- "): 

501 parts = line[2:].split(":", 1) 

502 if len(parts) == 2: 

503 ooda_impact[parts[0].strip()] = parts[1].strip().upper() 

504 

505 return { 

506 "status": status, 

507 "files_changed": files_changed, 

508 "files_created": files_created, 

509 "commits": commits, 

510 "verification": verification, 

511 "ooda_impact": ooda_impact, 

512 "sections": sections, 

513 }