rodunia/llm-research-app
0
1"""Generate human-readable claim extraction examples from real runs.2 3Creates Markdown documentation showing:4- Original generated text excerpts with block structure5- Extracted claims with offsets, triggers, claim_kind, block_kind6- Verification that all offsets are exact substrings7 8Usage:9 python scripts/make_claim_examples.py --n 3 --out docs/claim_extraction_examples.md10 11 # With custom materials12 python scripts/make_claim_examples.py --prefer-materials digital_ad,faq,blog_post_promo13 14 # From specific claims directory15 python scripts/make_claim_examples.py --claims-dir analysis/claims --outputs-dir outputs16"""17 18import json19import random20import argparse21from pathlib import Path22from typing import List, Dict, Any, Optional, Tuple23import csv24 25 26def classify_text_kind(text: str) -> str:27 """Classify text as 'prompt', 'output', or 'unknown'.28 29 Uses deterministic heuristics to detect prompt/template language.30 31 Args:32 text: Text to classify33 34 Returns:35 One of: 'prompt', 'output', 'unknown'36 """37 # Check first 500 chars for prompt markers38 header = text[:500].lower()39 40 # Prompt markers (high confidence)41 prompt_markers = [42 "you are an elite content marketing strategist",43 "you are writing",44 "your mission:",45 "compliance framework",46 "authorized claims",47 "prohibited claims",48 "mandatory disclaimers",49 "output format",50 "before writing, mentally confirm",51 "hard rules:",52 "use only the information provided below",53 "do not invent or infer"54 ]55 56 for marker in prompt_markers:57 if marker in header:58 return "prompt"59 60 # Output markers (typical generated content)61 output_markers = [62 "experience the",63 "discover",64 "introducing the",65 "## ", # Markdown headings in blog posts66 "headline:",67 "primary text:",68 "description:",69 "**q", # FAQ questions70 ]71 72 has_output_markers = any(marker in header for marker in output_markers)73 74 # If it has output markers and no prompt markers, it's likely output75 if has_output_markers:76 return "output"77 78 return "unknown"79 80 81def find_output_text(run_id: str, outputs_dir: Path) -> Optional[Path]:82 """Find output text file for a run_id using discovery strategy.83 84 Prioritizes actual generated outputs over prompts/templates.85 86 Args:87 run_id: Run identifier88 outputs_dir: Root outputs directory89 90 Returns:91 Path to output file or None if not found92 """93 # Strategy A: Look for per_run.json artifacts (most reliable)94 per_run_json = Path("analysis/per_run.json")95 if per_run_json.exists():96 try:97 with open(per_run_json, 'r') as f:98 per_run_data = json.load(f)99 for record in per_run_data:100 if record.get('run_id') == run_id:101 artifacts = record.get('artifacts', {})102 output_path = artifacts.get('output_path')103 if output_path and Path(output_path).exists():104 text = Path(output_path).read_text(encoding='utf-8')105 if classify_text_kind(text) == "output":106 return Path(output_path)107 except (json.JSONDecodeError, KeyError):108 pass109 110 # Strategy B: Direct lookup with _output.txt suffix (common pattern)111 output_patterns = [112 outputs_dir / f"{run_id}_output.txt",113 outputs_dir / f"{run_id}.txt",114 ]115 116 for path in output_patterns:117 if path.exists():118 text = path.read_text(encoding='utf-8')119 kind = classify_text_kind(text)120 if kind == "output":121 return path122 elif kind == "prompt":123 continue # Skip prompts, keep searching124 125 # Strategy C: Search known output directories126 output_dirs = [127 outputs_dir,128 outputs_dir / "comprehensive_test" / "test_b_materials",129 Path("results/outputs"),130 Path("results/text"),131 ]132 133 for out_dir in output_dirs:134 if not out_dir.exists():135 continue136 137 # Look for files matching run_id pattern138 for pattern in [f"{run_id}_output.txt", f"{run_id}.txt", f"*{run_id[:12]}*.txt"]:139 matches = list(out_dir.glob(pattern))140 for match in matches:141 # Skip prompt files142 if "prompt" in match.name.lower():143 continue144 145 text = match.read_text(encoding='utf-8')146 kind = classify_text_kind(text)147 if kind == "output":148 return match149 150 # Strategy D: Bounded recursive search (cap at 200 files)151 all_txt_files = list(outputs_dir.glob("**/*.txt"))[:200]152 153 for file_path in all_txt_files:154 # Skip obvious prompt files155 if "prompt" in file_path.name.lower():156 continue157 158 # Check if filename contains run_id159 if run_id[:12] in file_path.name or run_id in file_path.name:160 text = file_path.read_text(encoding='utf-8')161 kind = classify_text_kind(text)162 if kind == "output":163 return file_path164 165 return None166 167 168def load_or_generate_claims(169 run_id: str,170 output_path: Optional[Path],171 claims_dir: Path172) -> List[Dict[str, Any]]:173 """Load existing claims or generate from output text.174 175 Args:176 run_id: Run identifier177 output_path: Path to output text (if found)178 claims_dir: Directory containing claim JSONs179 180 Returns:181 List of claim records182 """183 # Try loading existing claims184 claims_file = claims_dir / f"{run_id}.json"185 if claims_file.exists():186 with open(claims_file, 'r', encoding='utf-8') as f:187 claims = json.load(f)188 # Filter to ensure we have v2.0 claims with block_kind189 if claims and all('block_kind' in c for c in claims):190 return claims191 192 # Generate claims if output text exists193 if output_path and output_path.exists():194 # Import claim extractor195 import sys196 sys.path.insert(0, '.')197 from analysis.claim_extractor import extract_claim_candidates198 199 full_text = output_path.read_text(encoding='utf-8')200 201 # Infer material type from path or use unknown202 material_type = "unknown"203 if "digital_ad" in str(output_path) or "ad" in str(output_path):204 material_type = "digital_ad.j2"205 elif "faq" in str(output_path):206 material_type = "faq.j2"207 elif "blog" in str(output_path):208 material_type = "blog_post_promo.j2"209 210 run_metadata = {211 "run_id": run_id,212 "product_id": "unknown",213 "material_type": material_type,214 "engine": "unknown",215 "temperature": 0.6,216 "time_of_day": "unknown",217 "repetition_id": 1218 }219 220 claims = extract_claim_candidates(full_text, run_metadata, include_meta=False)221 return claims222 223 return []224 225 226def select_example_runs(227 outputs_dir: Path,228 claims_dir: Path,229 n: int,230 prefer_materials: List[str],231 seed: int,232 require_block_kinds: bool233) -> List[Tuple[str, Path, List[Dict[str, Any]], str]]:234 """Select representative runs for examples.235 236 Args:237 outputs_dir: Outputs directory238 claims_dir: Claims directory239 n: Number of examples240 prefer_materials: Preferred material types241 seed: Random seed242 require_block_kinds: If True, only select v2.0 claims243 244 Returns:245 List of (run_id, output_path, claims, material_type) tuples246 """247 random.seed(seed)248 249 # Scan outputs directory for _output.txt files (skip prompts)250 output_files = [251 f for f in outputs_dir.glob("*.txt")252 if "prompt" not in f.name.lower()253 ]254 255 # Also check for comprehensive test outputs (skip prompts)256 comprehensive_outputs = [257 f for f in outputs_dir.glob("comprehensive_test/**/*.txt")258 if "prompt" not in f.name.lower()259 ]260 261 all_outputs = output_files + comprehensive_outputs262 random.shuffle(all_outputs)263 264 selected = []265 materials_found = set()266 267 for output_path in all_outputs:268 if len(selected) >= n:269 break270 271 # Verify this is an output, not a prompt272 text = output_path.read_text(encoding='utf-8')273 text_kind = classify_text_kind(text)274 if text_kind == "prompt":275 continue # Skip prompts276 277 # Infer material type from path278 path_str = str(output_path).lower()279 material_type = None280 281 if any(m in path_str for m in ["digital_ad", "ad_output"]):282 material_type = "digital_ad"283 elif "faq" in path_str:284 material_type = "faq"285 elif "blog" in path_str:286 material_type = "blog_post"287 288 # Skip if we already have this material type289 if material_type and material_type in materials_found:290 continue291 292 # Try to extract run_id from filename293 filename = output_path.stem294 run_id = filename.replace("_output", "").replace("_prompt", "")295 296 # Load or generate claims297 claims = load_or_generate_claims(run_id, output_path, claims_dir)298 299 # Filter by requirements300 if require_block_kinds and claims:301 if not all('block_kind' in c and 'claim_kind' in c for c in claims):302 continue303 304 # Skip if no claims305 if not claims:306 continue307 308 # Add to selected309 if material_type:310 selected.append((run_id, output_path, claims, material_type))311 materials_found.add(material_type)312 313 return selected314 315 316def extract_claim_aware_excerpt(317 full_text: str,318 claims: List[Dict[str, Any]],319 max_chars: int320) -> str:321 """Extract excerpt using claim offsets to show relevant context.322 323 Args:324 full_text: Full text325 claims: List of claim records with char_span326 max_chars: Maximum characters327 328 Returns:329 Excerpt showing claims in context330 """331 if len(full_text) <= max_chars:332 return full_text333 334 # Find claim span range335 if claims:336 char_spans = [c.get('char_span', (0, 0)) for c in claims if c.get('char_span')]337 if char_spans:338 min_start = min(s[0] for s in char_spans)339 max_end = max(s[1] for s in char_spans)340 341 # Expand window by ±200 chars342 window_start = max(0, min_start - 200)343 window_end = min(len(full_text), max_end + 200)344 345 # If window is still too large, truncate346 if window_end - window_start <= max_chars:347 return full_text[window_start:window_end]348 349 # Fallback: show beginning and end350 half = max_chars // 2 - 50351 return full_text[:half] + "\n\n[... middle section omitted ...]\n\n" + full_text[-half:]352 353 354def truncate_text(text: str, max_chars: int) -> str:355 """Truncate text to max_chars with ellipsis if needed.356 357 Args:358 text: Input text359 max_chars: Maximum characters360 361 Returns:362 Truncated text363 """364 if len(text) <= max_chars:365 return text366 367 # Show beginning and end368 half = max_chars // 2 - 50369 return text[:half] + "\n\n[... middle section omitted ...]\n\n" + text[-half:]370 371 372def format_claim_table_row(claim: Dict[str, Any]) -> str:373 """Format a claim as a Markdown table row.374 375 Args:376 claim: Claim record377 378 Returns:379 Markdown table row380 """381 claim_id = claim.get('claim_id', 'N/A')382 claim_kind = claim.get('claim_kind', 'N/A')383 block_kind = claim.get('block_kind', 'N/A')384 triggers = ', '.join(claim.get('trigger_types', []))385 char_span = claim.get('char_span', (0, 0))386 sentence = claim.get('sentence', '').replace('|', '\\|').replace('\n', ' ')[:80]387 388 return f"| `{claim_id[:20]}...` | {claim_kind} | {block_kind} | {triggers} | {char_span} | {sentence}... |"389 390 391def verify_offsets(392 claims: List[Dict[str, Any]],393 full_text: str394) -> Tuple[int, List[str]]:395 """Verify that all claim char_spans are exact substrings.396 397 Args:398 claims: List of claim records399 full_text: Original full text400 401 Returns:402 (num_verified, warnings) tuple403 """404 verified = 0405 warnings = []406 407 for claim in claims:408 sentence = claim.get('sentence', '')409 char_span = claim.get('char_span')410 if not char_span:411 warnings.append(f"Claim {claim.get('claim_id')} missing char_span")412 continue413 414 start, end = char_span415 if start >= len(full_text) or end > len(full_text):416 warnings.append(f"Claim {claim.get('claim_id')} has out-of-bounds char_span: {char_span}")417 continue418 419 extracted = full_text[start:end]420 if extracted == sentence:421 verified += 1422 else:423 warnings.append(424 f"Claim {claim.get('claim_id')} char_span mismatch:\n"425 f" Expected: {sentence[:50]}...\n"426 f" Got: {extracted[:50]}..."427 )428 429 return verified, warnings430 431 432def generate_markdown_example(433 run_id: str,434 output_path: Optional[Path],435 claims: List[Dict[str, Any]],436 material_type: str,437 max_excerpt_chars: int,438 max_claims: int,439 example_num: int440) -> str:441 """Generate Markdown section for one example.442 443 Args:444 run_id: Run identifier445 output_path: Path to output text (or None if not found)446 claims: List of claim records447 material_type: Material type name448 max_excerpt_chars: Max chars for text excerpt449 max_claims: Max claims to show450 example_num: Example number (1, 2, 3, ...)451 452 Returns:453 Markdown string454 """455 md = []456 md.append(f"## Example {example_num} — {material_type.replace('_', ' ').title()}")457 458 # Extract metadata from first claim459 if claims:460 product = claims[0].get('product', 'unknown')461 engine = claims[0].get('engine', 'unknown')462 extractor_version = claims[0].get('extractor_version', 'unknown')463 md.append(f"**Run ID:** `{run_id}` ")464 md.append(f"**Product:** {product} | **Engine:** {engine} ")465 md.append(f"**Extractor:** {extractor_version}")466 else:467 md.append(f"**Run ID:** `{run_id}`")468 469 md.append("")470 471 # Text excerpt472 if output_path and output_path.exists():473 full_text = output_path.read_text(encoding='utf-8')474 475 # Verify this is actually output, not a prompt476 text_kind = classify_text_kind(full_text)477 if text_kind == "prompt":478 md.append("### Generated Text Excerpt")479 md.append("")480 md.append("⚠️ **WARNING:** Located file appears to be a prompt/template, not generated output.")481 md.append("Showing extracted claims only (offsets may not match).")482 md.append("")483 else:484 # Use claim-aware excerpt to show relevant context485 excerpt = extract_claim_aware_excerpt(full_text, claims, max_excerpt_chars)486 487 md.append("### Generated Text Excerpt (Verbatim Model Output)")488 md.append("")489 md.append("```")490 md.append(excerpt)491 md.append("```")492 md.append("")493 494 # Verify offsets495 verified, warnings = verify_offsets(claims, full_text)496 if warnings:497 md.append("**Offset Verification:**")498 md.append(f"- {verified}/{len(claims)} claims verified")499 if warnings:500 md.append(f"- {len(warnings)} warnings (see debug output)")501 md.append("")502 else:503 md.append("### Generated Text Excerpt")504 md.append("")505 md.append("⚠️ **Generated output text file not found for this run_id.**")506 md.append("")507 md.append("_Showing extracted claims only (from analysis/claims/*.json)._")508 md.append("")509 510 # Claims table511 md.append("### Extracted Claims (Verbatim)")512 md.append("")513 514 if claims:515 # Show up to max_claims516 display_claims = claims[:max_claims]517 518 md.append("| Claim ID | Claim Kind | Block Kind | Triggers | Char Span | Sentence |")519 md.append("|----------|------------|------------|----------|-----------|----------|")520 521 for claim in display_claims:522 md.append(format_claim_table_row(claim))523 524 md.append("")525 526 # Summary stats527 product_claims = sum(1 for c in claims if c.get('claim_kind') == 'product_claim')528 disclaimer_claims = sum(1 for c in claims if c.get('claim_kind') == 'disclaimer')529 meta_claims = sum(1 for c in claims if c.get('claim_kind') == 'meta')530 531 md.append("**Summary:**")532 md.append(f"- Total extracted claims: {len(claims)}")533 md.append(f"- Product claims: {product_claims}")534 md.append(f"- Disclaimer claims: {disclaimer_claims}")535 md.append(f"- Meta claims: {meta_claims}")536 if len(claims) > max_claims:537 md.append(f"- _(Showing {max_claims} of {len(claims)} claims)_")538 md.append("- **Note:** All sentences are exact substrings (offset-traceable)")539 else:540 md.append("_No claims extracted_")541 542 md.append("")543 md.append("---")544 md.append("")545 546 return '\n'.join(md)547 548 549def write_json_preview(550 run_id: str,551 claims: List[Dict[str, Any]],552 out_dir: Path,553 max_claims: int = 2554) -> Optional[Path]:555 """Write JSON preview of claims for technical appendix.556 557 Args:558 run_id: Run identifier559 claims: List of claim records560 out_dir: Output directory (docs/examples/)561 max_claims: Max claims to include562 563 Returns:564 Path to JSON file or None565 """566 if not claims:567 return None568 569 out_dir.mkdir(parents=True, exist_ok=True)570 json_file = out_dir / f"run_{run_id[:12]}_claims_preview.json"571 572 preview_claims = claims[:max_claims]573 with open(json_file, 'w', encoding='utf-8') as f:574 json.dump(preview_claims, f, indent=2, ensure_ascii=False)575 576 return json_file577 578 579def main():580 """Main entry point."""581 parser = argparse.ArgumentParser(582 description="Generate claim extraction examples from real runs"583 )584 parser.add_argument(585 '--claims-dir',586 default='analysis/claims',587 help='Directory containing claim JSONs'588 )589 parser.add_argument(590 '--outputs-dir',591 default='outputs',592 help='Directory containing output text files'593 )594 parser.add_argument(595 '--n',596 type=int,597 default=3,598 help='Number of examples to generate'599 )600 parser.add_argument(601 '--prefer-materials',602 default='digital_ad,faq,blog_post',603 help='Comma-separated material types to prefer'604 )605 parser.add_argument(606 '--out',607 default='docs/claim_extraction_examples.md',608 help='Output Markdown file'609 )610 parser.add_argument(611 '--seed',612 type=int,613 default=42,614 help='Random seed for deterministic selection'615 )616 parser.add_argument(617 '--max-excerpt-chars',618 type=int,619 default=900,620 help='Max characters for text excerpt'621 )622 parser.add_argument(623 '--max-claims',624 type=int,625 default=6,626 help='Max claims to show per example'627 )628 parser.add_argument(629 '--require-block-kinds',630 action='store_true',631 default=True,632 help='Require v2.0 claims with block_kind/claim_kind'633 )634 parser.add_argument(635 '--write-json-previews',636 action='store_true',637 help='Write JSON previews to docs/examples/'638 )639 640 args = parser.parse_args()641 642 # Parse preferred materials643 prefer_materials = [m.strip() for m in args.prefer_materials.split(',')]644 645 # Setup paths646 claims_dir = Path(args.claims_dir)647 outputs_dir = Path(args.outputs_dir)648 out_file = Path(args.out)649 650 print("Claim Extraction Example Generator")651 print("=" * 60)652 print(f"Claims directory: {claims_dir}")653 print(f"Outputs directory: {outputs_dir}")654 print(f"Preferred materials: {prefer_materials}")655 print(f"Output file: {out_file}")656 print()657 658 # Create claims dir if it doesn't exist (for on-the-fly generation)659 claims_dir.mkdir(parents=True, exist_ok=True)660 661 # Select examples662 print("Selecting example runs...")663 selected = select_example_runs(664 outputs_dir=outputs_dir,665 claims_dir=claims_dir,666 n=args.n,667 prefer_materials=prefer_materials,668 seed=args.seed,669 require_block_kinds=args.require_block_kinds670 )671 672 if not selected:673 print("ERROR: No suitable examples found!")674 print(" - Check that outputs_dir contains .txt files")675 print(" - Or generate claims first: python -m analysis.evaluate")676 return 1677 678 print(f"✓ Selected {len(selected)} examples")679 print()680 681 # Self-checks682 print("Self-checks:")683 for run_id, output_path, claims, material_type in selected:684 print(f" - Run {run_id[:12]}: {material_type}")685 print(f" Output text: {'✓ found' if output_path and output_path.exists() else '✗ not found'}")686 if claims:687 extractor_version = claims[0].get('extractor_version', 'unknown')688 print(f" Extractor version: {extractor_version}")689 print(f" Claims count: {len(claims)}")690 691 # Verify offsets if text available692 if output_path and output_path.exists():693 full_text = output_path.read_text(encoding='utf-8')694 verified, warnings = verify_offsets(claims, full_text)695 if warnings:696 print(f" ⚠ Offset warnings: {len(warnings)}")697 for warning in warnings[:2]: # Show first 2698 print(f" {warning.split(chr(10))[0]}")699 else:700 print(f" ✓ All {verified} offsets verified")701 print()702 703 # Generate Markdown704 print("Generating Markdown examples...")705 out_file.parent.mkdir(parents=True, exist_ok=True)706 707 with open(out_file, 'w', encoding='utf-8') as f:708 # Header709 f.write("# Claim Extraction Examples\n\n")710 f.write("Real examples from the LLM research pipeline, showing structure-aware claim extraction (v2.0).\n\n")711 f.write("**All excerpts below are verbatim segments of model-generated outputs (not prompts).** \n")712 f.write("Claims are exact substrings; offsets are shown for traceability.\n\n")713 f.write("**Features demonstrated:**\n")714 f.write("- Block-aware parsing (headlines, Q/A, disclaimers)\n")715 f.write("- Claim kind tagging (product_claim vs disclaimer)\n")716 f.write("- Anchor-based trigger detection (numeric, guarantee, medical, financial, comparative)\n")717 f.write("- Exact char_span offsets (all sentences are verifiable substrings)\n\n")718 f.write("---\n\n")719 720 # Examples721 for i, (run_id, output_path, claims, material_type) in enumerate(selected, 1):722 example_md = generate_markdown_example(723 run_id=run_id,724 output_path=output_path,725 claims=claims,726 material_type=material_type,727 max_excerpt_chars=args.max_excerpt_chars,728 max_claims=args.max_claims,729 example_num=i730 )731 f.write(example_md)732 733 # Optional JSON preview734 if args.write_json_previews and claims:735 json_path = write_json_preview(736 run_id=run_id,737 claims=claims,738 out_dir=out_file.parent / "examples",739 max_claims=2740 )741 if json_path:742 print(f" ✓ JSON preview: {json_path}")743 744 print(f"✓ Generated examples: {out_file}")745 print()746 747 # Final sanity report748 print("=" * 60)749 print("SANITY REPORT")750 print("=" * 60)751 print()752 753 all_passed = True754 for i, (run_id, output_path, claims, material_type) in enumerate(selected, 1):755 print(f"Example {i} — {material_type}")756 print(f" Run ID: {run_id[:20]}...")757 print(f" Output path: {output_path if output_path else 'NOT FOUND'}")758 759 if output_path and output_path.exists():760 full_text = output_path.read_text(encoding='utf-8')761 text_kind = classify_text_kind(full_text)762 print(f" Text kind: {text_kind} {'✓ (expected: output)' if text_kind == 'output' else '⚠ WARNING'}")763 764 if text_kind == "prompt":765 print(f" ❌ FAILED: Found prompt instead of output!")766 all_passed = False767 768 # Check extractor version769 if claims:770 extractor_version = claims[0].get('extractor_version', 'unknown')771 print(f" Extractor version: {extractor_version} {'✓' if extractor_version == 'v2.0' else '⚠'}")772 773 # Verify offsets774 verified, warnings = verify_offsets(claims, full_text)775 match_rate = verified / len(claims) if claims else 0776 print(f" Offset match rate: {verified}/{len(claims)} ({match_rate:.1%})")777 778 if match_rate < 0.98:779 print(f" ⚠ WARNING: Match rate below 98%!")780 if warnings:781 print(f" First mismatch: {warnings[0][:80]}...")782 all_passed = False783 else:784 print(f" ✓ All offsets verified")785 else:786 print(f" ⚠ WARNING: Output file not found")787 all_passed = False788 789 print()790 791 print("=" * 60)792 if all_passed:793 print("✅ ALL SANITY CHECKS PASSED")794 else:795 print("⚠ SOME SANITY CHECKS FAILED - Review warnings above")796 print("=" * 60)797 print()798 799 print("✅ Done! Examples ready for documentation.")800 return 0 if all_passed else 1801 802 803if __name__ == "__main__":804 import sys805 sys.exit(main())806 