# /// script # requires-python = ">=3.9" # /// #!/usr/bin/env python3 """ Generate an interactive HTML quality report from scanner temp JSON files. Reads all *-temp.json and *-prepass.json files from a quality scan output directory, normalizes findings into a unified data model, and produces a self-contained HTML report with: - Collapsible sections with severity filter badges - Per-item copy-prompt buttons - Multi-select batch prompt generator - Executive summary with severity counts Usage: python3 generate-html-report.py {quality-report-dir} [--open] [--skill-path /path/to/skill] The --skill-path is embedded in the prompt context so generated prompts reference the correct location. If omitted, it is read from the first temp JSON that contains a skill_path field. """ from __future__ import annotations import argparse import json import platform import subprocess import sys from datetime import datetime, timezone from pathlib import Path # ============================================================================= # Normalization — diverse scanner JSONs → unified item model # ============================================================================= SEVERITY_RANK = { 'critical': 0, 'high': 1, 'medium': 2, 'low': 3, 'high-opportunity': 1, 'medium-opportunity': 2, 'low-opportunity': 3, 'note': 4, 'strength': 5, 'suggestion': 4, 'info': 5, } # Map scanner names to report sections SCANNER_SECTIONS = { 'workflow-integrity': 'structural', 'structure': 'structure-capabilities', 'prompt-craft': 'prompt-craft', 'execution-efficiency': 'efficiency', 'skill-cohesion': 'cohesion', 'agent-cohesion': 'cohesion', 'path-standards': 'quality', 'scripts': 'scripts', 'script-opportunities': 'script-opportunities', 'enhancement-opportunities': 'creative', } SECTION_LABELS = { 'structural': 'Structural', 'structure-capabilities': 'Structure & Capabilities', 'prompt-craft': 'Prompt Craft', 'efficiency': 'Efficiency', 'cohesion': 'Cohesion', 'quality': 'Path & Script Standards', 'scripts': 'Scripts', 'script-opportunities': 'Script Opportunities', 'creative': 'Creative & Enhancements', } def _coalesce(*values) -> str: """Return the first truthy string value, or empty string.""" for v in values: if v and isinstance(v, str) and v.strip() and v.strip() not in ('N/A', 'n/a', 'None'): return v.strip() return '' def _norm_severity(sev: str) -> str: """Normalize severity to lowercase, handle variants.""" if not sev: return 'low' s = sev.strip().lower() # Map common variants return { 'high-opportunity': 'high-opportunity', 'medium-opportunity': 'medium-opportunity', 'low-opportunity': 'low-opportunity', }.get(s, s) def normalize_finding(f: dict, scanner: str, idx: int) -> dict: """ Normalize a single finding/issue dict into the unified item model. Handles all known field name variants across scanners: Title: issue | title | description (fallback) Desc: description | rationale | observation | insight | scenario | current_behavior | current_pattern | context | nuance Action: fix | recommendation | suggestion | suggested_approach | efficient_alternative | script_alternative File: file | location | current_location Line: line | lines Cat: category | dimension Impact: user_impact | impact | estimated_savings | estimated_token_savings """ sev = _norm_severity(f.get('severity', 'low')) section = SCANNER_SECTIONS.get(scanner, 'other') # Determine item type from severity if sev in ('strength', 'note') or f.get('category') == 'strength': item_type = 'strength' action_type = 'none' selectable = False elif sev.endswith('-opportunity'): item_type = 'enhancement' action_type = 'enhance' selectable = True elif f.get('category') == 'suggestion' or sev == 'suggestion': item_type = 'suggestion' action_type = 'refactor' selectable = True else: item_type = 'issue' action_type = 'fix' selectable = True # --- Title: prefer 'title', fall back to old field names --- title = _coalesce( f.get('title'), f.get('issue'), _truncate(f.get('scenario', ''), 150), _truncate(f.get('current_behavior', ''), 150), _truncate(f.get('description', ''), 150), f.get('observation', ''), ) if not title: title = f.get('id', 'Finding') # --- Detail/description: prefer 'detail', fall back to old field names --- description = _coalesce(f.get('detail')) if not description: # Backward compat: coalesce old field names desc_candidates = [] for key in ('description', 'rationale', 'observation', 'insight', 'scenario', 'current_behavior', 'current_pattern', 'context', 'nuance', 'assessment'): v = f.get(key) if v and isinstance(v, str) and v.strip() and v != title: desc_candidates.append(v.strip()) description = ' '.join(desc_candidates) if desc_candidates else '' # --- Action: prefer 'action', fall back to old field names --- action = _coalesce( f.get('action'), f.get('fix'), f.get('recommendation'), f.get('suggestion'), f.get('suggested_approach'), f.get('efficient_alternative'), f.get('script_alternative'), ) # --- File reference --- file_ref = _coalesce( f.get('file'), f.get('location'), f.get('current_location'), ) # --- Line reference --- line = f.get('line') if line is None: lines_str = f.get('lines') if lines_str: line = str(lines_str) # --- Category --- category = _coalesce( f.get('category'), f.get('dimension'), ) # --- Impact (backward compat only - new schema folds into detail) --- impact = _coalesce( f.get('user_impact'), f.get('impact'), f.get('estimated_savings'), str(f.get('estimated_token_savings', '')) if f.get('estimated_token_savings') else '', ) # --- Extra fields for specific scanners --- extra = {} if scanner == 'script-opportunities': action_type = 'create-script' for k in ('determinism_confidence', 'implementation_complexity', 'language', 'could_be_prepass', 'reusable_across_skills'): if k in f: extra[k] = f[k] # Use scanner-provided id if available item_id = f.get('id', f'{scanner}-{idx:03d}') return { 'id': item_id, 'scanner': scanner, 'section': section, 'type': item_type, 'severity': sev, 'rank': SEVERITY_RANK.get(sev, 3), 'category': category, 'file': file_ref, 'line': line, 'title': title, 'description': description, 'action': action, 'impact': impact, 'extra': extra, 'selectable': selectable, 'action_type': action_type, } def _truncate(text: str, max_len: int) -> str: """Truncate text to max_len, breaking at sentence boundary if possible.""" if not text: return '' text = text.strip() if len(text) <= max_len: return text # Try to break at sentence boundary for end in ('. ', '.\n', ' — ', '; '): pos = text.find(end) if 0 < pos < max_len: return text[:pos + 1].strip() return text[:max_len].strip() + '...' def normalize_scanner(data: dict) -> tuple[list[dict], dict]: """ Normalize a full scanner JSON into (items, meta). Returns list of normalized items + dict of meta/assessment data. Handles all known scanner output variants. """ scanner = data.get('scanner', 'unknown') items = [] meta = {} # New schema: findings[]. Backward compat: issues[] or findings[] findings = data.get('findings') or data.get('issues') or [] for idx, f in enumerate(findings): items.append(normalize_finding(f, scanner, idx)) # Backward compat: opportunities[] (execution-efficiency had separate array) for idx, opp in enumerate(data.get('opportunities', []), start=len(findings)): opp_item = normalize_finding(opp, scanner, idx) opp_item['type'] = 'enhancement' opp_item['action_type'] = 'enhance' opp_item['selectable'] = True items.append(opp_item) # Backward compat: strengths[] (old cohesion scanners — plain strings) for idx, s in enumerate(data.get('strengths', [])): text = s if isinstance(s, str) else (s.get('title', '') if isinstance(s, dict) else str(s)) desc = '' if isinstance(s, str) else (s.get('description', s.get('detail', '')) if isinstance(s, dict) else '') items.append({ 'id': f'{scanner}-str-{idx:03d}', 'scanner': scanner, 'section': SCANNER_SECTIONS.get(scanner, 'cohesion'), 'type': 'strength', 'severity': 'strength', 'rank': 5, 'category': 'strength', 'file': '', 'line': None, 'title': text, 'description': desc, 'action': '', 'impact': '', 'extra': {}, 'selectable': False, 'action_type': 'none', }) # Backward compat: creative_suggestions[] (old cohesion scanners) for idx, cs in enumerate(data.get('creative_suggestions', [])): if isinstance(cs, str): cs_title, cs_desc = cs, '' else: cs_title = _coalesce(cs.get('title'), cs.get('idea'), '') cs_desc = _coalesce(cs.get('description'), cs.get('detail'), cs.get('rationale'), '') items.append({ 'id': cs.get('id', f'{scanner}-cs-{idx:03d}') if isinstance(cs, dict) else f'{scanner}-cs-{idx:03d}', 'scanner': scanner, 'section': SCANNER_SECTIONS.get(scanner, 'cohesion'), 'type': 'suggestion', 'severity': 'suggestion', 'rank': 4, 'category': cs.get('type', 'suggestion') if isinstance(cs, dict) else 'suggestion', 'file': '', 'line': None, 'title': cs_title, 'description': cs_desc, 'action': cs_title, 'impact': cs.get('estimated_impact', '') if isinstance(cs, dict) else '', 'extra': {}, 'selectable': True, 'action_type': 'refactor', }) # New schema: assessments{} contains all structured analysis # Backward compat: also collect from top-level keys if 'assessments' in data: meta.update(data['assessments']) # Backward compat: collect meta from top-level keys skip_keys = {'scanner', 'script', 'version', 'skill_path', 'agent_path', 'timestamp', 'scan_date', 'status', 'issues', 'findings', 'strengths', 'creative_suggestions', 'opportunities', 'assessments'} for key, val in data.items(): if key not in skip_keys and key not in meta: meta[key] = val return items, meta def build_journeys(data: dict) -> list[dict]: """ Extract user journey data from enhancement-opportunities scanner. Handles two formats: - Array of objects: [{archetype, journey_summary, friction_points, bright_spots}] - Object keyed by persona: {first_timer: {entry_friction, mid_flow_resilience, exit_satisfaction}} """ journeys_raw = data.get('user_journeys') if not journeys_raw: return [] # Format 1: already a list — normalize field names if isinstance(journeys_raw, list): normalized = [] for j in journeys_raw: if isinstance(j, dict): normalized.append({ 'archetype': j.get('archetype', 'unknown'), 'journey_summary': j.get('summary', j.get('journey_summary', '')), 'friction_points': j.get('friction_points', []), 'bright_spots': j.get('bright_spots', []), }) else: normalized.append(j) return normalized # Format 2: object keyed by persona name if isinstance(journeys_raw, dict): result = [] for persona, details in journeys_raw.items(): if isinstance(details, dict): # Convert the dict-based format to the expected format journey = { 'archetype': persona.replace('_', ' ').title(), 'journey_summary': '', 'friction_points': [], 'bright_spots': [], } # Map known sub-keys to friction/bright spots for key, val in details.items(): if isinstance(val, str): # Heuristic: negative-sounding keys → friction, positive → bright if any(neg in key.lower() for neg in ('friction', 'issue', 'problem', 'gap', 'pain')): journey['friction_points'].append(val) elif any(pos in key.lower() for pos in ('bright', 'strength', 'satisfaction', 'delight')): journey['bright_spots'].append(val) else: # Neutral keys — include as summary parts if journey['journey_summary']: journey['journey_summary'] += f' | {key}: {val}' else: journey['journey_summary'] = f'{key}: {val}' elif isinstance(val, list): for item in val: if isinstance(item, str): journey['friction_points'].append(item) # Build summary from all fields if not yet set if not journey['journey_summary']: parts = [] for k, v in details.items(): if isinstance(v, str): parts.append(f'**{k.replace("_", " ").title()}:** {v}') journey['journey_summary'] = ' | '.join(parts) if parts else str(details) result.append(journey) elif isinstance(details, str): result.append({ 'archetype': persona.replace('_', ' ').title(), 'journey_summary': details, 'friction_points': [], 'bright_spots': [], }) return result return [] # ============================================================================= # Report Data Assembly # ============================================================================= def load_report_data(report_dir: Path, skill_path: str | None) -> dict: """Load all temp/prepass JSONs and assemble normalized report data.""" all_items = [] all_meta = {} journeys = [] detected_skill_path = skill_path # Read all JSON files json_files = sorted(report_dir.glob('*.json')) for jf in json_files: try: data = json.loads(jf.read_text(encoding='utf-8')) except (json.JSONDecodeError, OSError): continue if not isinstance(data, dict): continue scanner = data.get('scanner', jf.stem.replace('-temp', '').replace('-prepass', '')) # Detect skill path from scanner data if not detected_skill_path: detected_skill_path = data.get('skill_path') or data.get('agent_path') # Only normalize temp files (not prepass) if '-temp' in jf.name or jf.name in ('path-standards-temp.json', 'scripts-temp.json'): items, meta = normalize_scanner(data) all_items.extend(items) all_meta[scanner] = meta if scanner == 'enhancement-opportunities': journeys = build_journeys(data) elif '-prepass' in jf.name: all_meta[f'prepass-{scanner}'] = data # Sort items: severity rank first, then section all_items.sort(key=lambda x: (x['rank'], x['section'])) # Build severity counts counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} for item in all_items: if item['type'] == 'issue' and item['severity'] in counts: counts[item['severity']] += 1 enhancement_count = sum(1 for i in all_items if i['type'] == 'enhancement') strength_count = sum(1 for i in all_items if i['type'] == 'strength') total_issues = sum(counts.values()) # Quality grade if counts['critical'] > 0: grade = 'Poor' elif counts['high'] > 2: grade = 'Fair' elif counts['high'] > 0 or counts['medium'] > 5: grade = 'Good' else: grade = 'Excellent' # Extract assessments for display assessments = {} for scanner_key, meta in all_meta.items(): for akey in ('cohesion_analysis', 'autonomous_assessment', 'skill_understanding', 'agent_identity', 'skill_identity', 'prompt_health', 'skillmd_assessment', 'top_insights'): if akey in meta: assessments[akey] = meta[akey] if 'summary' in meta: s = meta['summary'] if 'craft_assessment' in s: assessments['craft_assessment'] = s['craft_assessment'] if 'overall_cohesion' in s: assessments['overall_cohesion'] = s['overall_cohesion'] # Skill name from path sp = detected_skill_path or str(report_dir) skill_name = Path(sp).name return { 'meta': { 'skill_name': skill_name, 'skill_path': detected_skill_path or '', 'timestamp': datetime.now(timezone.utc).isoformat(), 'scanner_count': len([f for f in json_files if '-temp' in f.name]), 'report_dir': str(report_dir), }, 'executive_summary': { 'total_issues': total_issues, 'counts': counts, 'enhancement_count': enhancement_count, 'strength_count': strength_count, 'grade': grade, 'craft_assessment': assessments.get('craft_assessment', ''), 'overall_cohesion': assessments.get('overall_cohesion', ''), }, 'items': all_items, 'journeys': journeys, 'assessments': assessments, 'section_labels': SECTION_LABELS, } # ============================================================================= # HTML Generation # ============================================================================= HTML_TEMPLATE = r""" Quality Report: SKILL_NAME_PLACEHOLDER

Quality Report:

""" def generate_html(report_data: dict) -> str: """Inject report data into the HTML template.""" data_json = json.dumps(report_data, indent=None, ensure_ascii=False) # Embed the JSON as a script tag before the main script data_tag = f'' # Insert before the main