Files
bi-agents/.agents/skills/bmad-agent-builder/scripts/generate-html-report.py
Cassel 647cbec54f docs: update all documentation and add AI tooling configs
- Rewrite README.md with current architecture, features and stack
- Update docs/API.md with all current endpoints (corporate, BI, client 360)
- Update docs/ARCHITECTURE.md with cache, modular queries, services, ETL
- Update docs/GUIA-USUARIO.md for all roles (admin, corporate, agente)
- Add docs/INDEX.md documentation index
- Add PROJETO.md comprehensive project reference
- Add BI-CCC-Implementation-Guide.md
- Include AI agent configs (.claude, .agents, .gemini, _bmad)
- Add netbird VPN configuration
- Add status report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:29:03 -04:00

1003 lines
41 KiB
Python

# /// 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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quality Report: SKILL_NAME_PLACEHOLDER</title>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d; --border: #30363d;
--text: #e6edf3; --text-muted: #8b949e; --text-dim: #6e7681;
--critical: #f85149; --high: #f0883e; --medium: #d29922; --low: #58a6ff;
--strength: #3fb950; --suggestion: #a371f7; --info: #8b949e;
--accent: #58a6ff; --accent-hover: #79c0ff;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff; --surface: #f6f8fa; --surface2: #eaeef2; --border: #d0d7de;
--text: #1f2328; --text-muted: #656d76; --text-dim: #8c959f;
--critical: #cf222e; --high: #bc4c00; --medium: #9a6700; --low: #0969da;
--strength: #1a7f37; --suggestion: #8250df; --info: #656d76;
--accent: #0969da; --accent-hover: #0550ae;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; padding: 2rem; max-width: 960px; margin: 0 auto; padding-bottom: 6rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1.5rem; }
.badge { display: inline-flex; align-items: center; padding: 0.15rem 0.5rem; border-radius: 2rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; border: 2px solid transparent; transition: all 0.15s; user-select: none; }
.badge:hover { filter: brightness(1.2); }
.badge.active { border-color: currentColor; }
.badge-critical { background: color-mix(in srgb, var(--critical) 20%, transparent); color: var(--critical); }
.badge-high { background: color-mix(in srgb, var(--high) 20%, transparent); color: var(--high); }
.badge-medium { background: color-mix(in srgb, var(--medium) 20%, transparent); color: var(--medium); }
.badge-low { background: color-mix(in srgb, var(--low) 20%, transparent); color: var(--low); }
.badge-strength { background: color-mix(in srgb, var(--strength) 20%, transparent); color: var(--strength); }
.badge-suggestion, .badge-note { background: color-mix(in srgb, var(--suggestion) 20%, transparent); color: var(--suggestion); }
.badge-high-opportunity { background: color-mix(in srgb, var(--high) 20%, transparent); color: var(--high); }
.badge-medium-opportunity { background: color-mix(in srgb, var(--medium) 20%, transparent); color: var(--medium); }
.badge-low-opportunity { background: color-mix(in srgb, var(--low) 20%, transparent); color: var(--low); }
.badge-info { background: color-mix(in srgb, var(--info) 20%, transparent); color: var(--info); }
.grade { font-size: 2rem; font-weight: 700; }
.grade-Excellent { color: var(--strength); }
.grade-Good { color: var(--low); }
.grade-Fair { color: var(--medium); }
.grade-Poor { color: var(--critical); }
.summary-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem 2rem; margin: 1rem 0; align-items: baseline; }
.summary-grid dt { color: var(--text-muted); font-size: 0.85rem; }
.summary-grid dd { font-size: 0.95rem; }
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
.section { border: 1px solid var(--border); border-radius: 0.5rem; margin: 0.75rem 0; overflow: hidden; }
.section-header { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--surface); cursor: pointer; user-select: none; }
.section-header:hover { background: var(--surface2); }
.section-header .arrow { font-size: 0.7rem; transition: transform 0.15s; color: var(--text-muted); width: 1rem; }
.section-header.open .arrow { transform: rotate(90deg); }
.section-header .label { font-weight: 600; flex: 1; }
.section-header .count { font-size: 0.8rem; color: var(--text-muted); }
.section-body { display: none; }
.section-body.open { display: block; }
.item { display: flex; gap: 0.75rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); align-items: flex-start; }
.item:hover { background: var(--surface); }
.item-check { margin-top: 0.2rem; accent-color: var(--accent); flex-shrink: 0; }
.item-body { flex: 1; min-width: 0; }
.item-title { font-weight: 600; font-size: 0.9rem; }
.item-file { font-family: var(--mono); font-size: 0.75rem; color: var(--text-muted); }
.item-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.25rem; }
.item-action { font-size: 0.85rem; margin-top: 0.25rem; }
.item-action strong { color: var(--strength); }
.item-impact { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.2rem; font-style: italic; }
.item-actions { flex-shrink: 0; display: flex; gap: 0.25rem; }
.copy-btn { background: none; border: 1px solid var(--border); border-radius: 0.25rem; padding: 0.2rem 0.4rem; cursor: pointer; color: var(--text-muted); font-size: 0.75rem; transition: all 0.15s; }
.copy-btn:hover { border-color: var(--accent); color: var(--accent); }
.copy-btn.copied { border-color: var(--strength); color: var(--strength); }
.journey { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
.journey h4 { font-size: 0.9rem; text-transform: capitalize; }
.journey p { font-size: 0.85rem; color: var(--text-muted); margin: 0.25rem 0; }
.journey ul { font-size: 0.85rem; padding-left: 1.25rem; margin: 0.25rem 0; }
.journey .friction { color: var(--high); }
.journey .bright { color: var(--strength); }
.assessment { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
.assessment table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; }
.assessment th, .assessment td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--border); }
.assessment th { color: var(--text-muted); font-weight: 600; }
.sticky-footer { position: fixed; bottom: 0; left: 0; right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 0.75rem 2rem; display: flex; align-items: center; justify-content: center; gap: 1rem; z-index: 100; transition: transform 0.2s; }
.sticky-footer.hidden { transform: translateY(100%); }
.gen-btn { background: var(--accent); color: #fff; border: none; padding: 0.5rem 1.25rem; border-radius: 0.375rem; cursor: pointer; font-weight: 600; font-size: 0.9rem; }
.gen-btn:hover { background: var(--accent-hover); }
.sel-count { font-size: 0.9rem; color: var(--text-muted); }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.visible { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1.5rem; width: 90%; max-width: 700px; max-height: 80vh; overflow-y: auto; }
.modal h3 { margin-bottom: 0.75rem; }
.modal pre { background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; padding: 1rem; font-family: var(--mono); font-size: 0.8rem; white-space: pre-wrap; word-wrap: break-word; max-height: 50vh; overflow-y: auto; }
.modal-actions { display: flex; gap: 0.75rem; margin-top: 1rem; justify-content: flex-end; }
.modal-actions button { padding: 0.4rem 1rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.85rem; }
.modal-close { background: var(--surface2); border: 1px solid var(--border); color: var(--text); }
.modal-copy { background: var(--accent); border: none; color: #fff; font-weight: 600; }
.empty-msg { color: var(--text-dim); font-size: 0.85rem; padding: 1rem; font-style: italic; }
</style>
</head>
<body>
<h1>Quality Report: <span id="skill-name"></span></h1>
<div class="subtitle" id="subtitle"></div>
<div id="exec-summary"></div>
<div class="filters" id="filters"></div>
<div id="sections"></div>
<div class="sticky-footer hidden" id="footer">
<span class="sel-count"><span id="sel-count">0</span> selected</span>
<button class="gen-btn" onclick="showBatchPrompt()">Generate Prompt</button>
</div>
<div class="modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
<div class="modal">
<h3 id="modal-title">Generated Prompt</h3>
<pre id="modal-content"></pre>
<div class="modal-actions">
<button class="modal-close" onclick="closeModal()">Close</button>
<button class="modal-copy" onclick="copyModal()">Copy to Clipboard</button>
</div>
</div>
</div>
<script>
const DATA = JSON.parse(document.getElementById('report-data').textContent);
const selected = new Set();
function init() {
const m = DATA.meta;
const es = DATA.executive_summary;
document.getElementById('skill-name').textContent = m.skill_name;
document.getElementById('subtitle').textContent = `${m.skill_path} \u2022 ${m.timestamp.split('T')[0]} \u2022 ${m.scanner_count} scanners`;
// Executive summary
let html = `<div class="grade grade-${es.grade}">${es.grade}</div>`;
html += `<dl class="summary-grid">`;
html += `<dt>Issues</dt><dd>${es.total_issues} total \u2014 ${es.counts.critical} critical, ${es.counts.high} high, ${es.counts.medium} medium, ${es.counts.low} low</dd>`;
if (es.enhancement_count) html += `<dt>Enhancements</dt><dd>${es.enhancement_count} opportunities identified</dd>`;
if (es.strength_count) html += `<dt>Strengths</dt><dd>${es.strength_count} noted</dd>`;
if (es.craft_assessment) html += `<dt>Craft</dt><dd>${esc(es.craft_assessment)}</dd>`;
if (es.overall_cohesion) html += `<dt>Cohesion</dt><dd>${esc(es.overall_cohesion)}</dd>`;
html += `</dl>`;
document.getElementById('exec-summary').innerHTML = html;
// Severity filters
renderFilters();
// Sections
renderSections();
}
// --- Severity filters ---
const activeFilters = new Set(['critical','high','medium','low','high-opportunity','medium-opportunity','low-opportunity','strength','suggestion','note','info']);
function renderFilters() {
const counts = {};
DATA.items.forEach(i => { counts[i.severity] = (counts[i.severity]||0) + 1; });
const order = ['critical','high','medium','low','high-opportunity','medium-opportunity','low-opportunity','strength','suggestion','note'];
let html = '';
order.forEach(s => {
if (!counts[s]) return;
const active = activeFilters.has(s) ? 'active' : '';
html += `<span class="badge badge-${s} ${active}" data-sev="${s}" onclick="toggleFilter('${s}')">${s.replace('-',' ')} ${counts[s]}</span>`;
});
document.getElementById('filters').innerHTML = html;
}
function toggleFilter(sev) {
if (activeFilters.has(sev)) activeFilters.delete(sev); else activeFilters.add(sev);
renderFilters();
renderSections();
}
// --- Sections ---
function renderSections() {
const groups = {};
const sectionOrder = ['structural','structure-capabilities','prompt-craft','cohesion','efficiency','quality','scripts','script-opportunities','creative'];
DATA.items.forEach(i => {
if (!activeFilters.has(i.severity)) return;
const s = i.section;
if (!groups[s]) groups[s] = [];
groups[s].push(i);
});
// Truly broken (always first, always open)
const broken = DATA.items.filter(i => i.type === 'issue' && (i.severity === 'critical' || i.severity === 'high'));
const brokenIds = new Set(broken.map(i => i.id));
// Strengths
const strengths = DATA.items.filter(i => i.type === 'strength' && activeFilters.has(i.severity));
let html = '';
if (broken.length) {
html += renderSection('truly-broken', `Truly Broken / Missing (${broken.length})`, broken, true);
}
if (strengths.length) {
html += renderSection('strengths', `Strengths (${strengths.length})`, strengths, false);
}
sectionOrder.forEach(sec => {
// Exclude strengths (shown above) and items already in Truly Broken
const items = (groups[sec] || []).filter(i => i.type !== 'strength' && !brokenIds.has(i.id));
if (!items.length) return;
const label = DATA.section_labels[sec] || sec;
html += renderSection(sec, `${label} (${items.length})`, items, false);
});
// User journeys
if (DATA.journeys.length) {
html += renderJourneysSection();
}
// Assessments
if (Object.keys(DATA.assessments).length) {
html += renderAssessmentsSection();
}
document.getElementById('sections').innerHTML = html;
}
function renderSection(id, label, items, startOpen) {
const openCls = startOpen ? 'open' : '';
let html = `<div class="section"><div class="section-header ${openCls}" onclick="toggleSection(this)">`;
html += `<span class="arrow">\u25B6</span><span class="label">${label}</span>`;
html += `</div><div class="section-body ${openCls}">`;
items.forEach(i => { html += renderItem(i); });
html += `</div></div>`;
return html;
}
function renderItem(item) {
const isStrength = item.type === 'strength';
const chk = item.selectable ? `<input type="checkbox" class="item-check" data-id="${item.id}" ${selected.has(item.id)?'checked':''} onchange="toggleSelect('${item.id}', this.checked)">` : '';
const sev = `<span class="badge badge-${item.severity}">${item.severity.replace('-',' ')}</span>`;
const file = item.file ? `<span class="item-file">${esc(item.file)}${item.line ? ':'+item.line : ''}</span>` : '';
const desc = item.description && item.description !== item.title ? `<div class="item-desc">${esc(item.description)}</div>` : '';
// Suppress action/impact for strengths — "N/A" is noise
const actionText = item.action && !isStrength && item.action !== 'N/A' ? item.action : '';
const action = actionText ? `<div class="item-action"><strong>${item.action_type === 'fix' ? 'Fix' : item.action_type === 'create-script' ? 'Script' : 'Suggestion'}:</strong> ${esc(actionText)}</div>` : '';
const impactText = item.impact && !isStrength && item.impact !== 'N/A' ? item.impact : '';
const impact = impactText ? `<div class="item-impact">Impact: ${esc(impactText)}</div>` : '';
const copyBtn = item.selectable ? `<button class="copy-btn" onclick="copySinglePrompt('${item.id}')" title="Copy prompt for this item">\u2398</button>` : '';
return `<div class="item">${chk}<div class="item-body">${sev} ${file}<div class="item-title">${esc(item.title)}</div>${desc}${action}${impact}</div><div class="item-actions">${copyBtn}</div></div>`;
}
function renderJourneysSection() {
let html = `<div class="section"><div class="section-header" onclick="toggleSection(this)">`;
html += `<span class="arrow">\u25B6</span><span class="label">User Journeys (${DATA.journeys.length})</span>`;
html += `</div><div class="section-body">`;
DATA.journeys.forEach(j => {
html += `<div class="journey"><h4>${esc(j.archetype)}</h4>`;
html += `<p>${esc(j.journey_summary)}</p>`;
if (j.friction_points && j.friction_points.length) {
html += `<ul class="friction">`;
j.friction_points.forEach(fp => { html += `<li>${esc(fp)}</li>`; });
html += `</ul>`;
}
if (j.bright_spots && j.bright_spots.length) {
html += `<ul class="bright">`;
j.bright_spots.forEach(bs => { html += `<li>${esc(bs)}</li>`; });
html += `</ul>`;
}
html += `</div>`;
});
html += `</div></div>`;
return html;
}
function renderAssessmentsSection() {
let html = `<div class="section"><div class="section-header" onclick="toggleSection(this)">`;
html += `<span class="arrow">\u25B6</span><span class="label">Assessments & Analysis</span>`;
html += `</div><div class="section-body">`;
const ca = DATA.assessments.cohesion_analysis;
if (ca) {
html += `<div class="assessment"><h4>Cohesion Analysis</h4><table><tr><th>Dimension</th><th>Score</th><th>Notes</th></tr>`;
Object.entries(ca).forEach(([dim, val]) => {
if (typeof val === 'object' && val.score) {
html += `<tr><td>${esc(dim.replace(/_/g, ' '))}</td><td>${esc(val.score)}</td><td>${esc(val.notes || '')}</td></tr>`;
}
});
html += `</table></div>`;
}
const aa = DATA.assessments.autonomous_assessment;
if (aa) {
html += `<div class="assessment"><h4>Autonomous Readiness</h4><table>`;
html += `<tr><td>Overall Potential</td><td>${esc(aa.potential||aa.overall_potential||'')}</td></tr>`;
html += `<tr><td>HITL Points</td><td>${aa.hitl_points||aa.hitl_interaction_points||0}</td></tr>`;
html += `<tr><td>Auto-Resolvable</td><td>${aa.auto_resolvable||0}</td></tr>`;
html += `<tr><td>Needs Input</td><td>${aa.needs_input||0}</td></tr>`;
if (aa.notes) html += `<tr><td>Notes</td><td>${esc(aa.notes)}</td></tr>`;
html += `</table></div>`;
}
const ti = DATA.assessments.top_insights;
if (ti && ti.length) {
html += `<div class="assessment"><h4>Top Insights</h4>`;
ti.forEach(t => {
const tiTitle = t.title || t.insight || '';
const tiDetail = t.detail || t.why_it_matters || '';
const tiAction = t.action || t.suggestion || '';
html += `<div style="margin:0.5rem 0"><strong>${esc(tiTitle)}</strong>`;
if (tiDetail) html += `<br><em>Context:</em> ${esc(tiDetail)}`;
if (tiAction) html += `<br><em>Suggestion:</em> ${esc(tiAction)}`;
html += `</div>`;
});
html += `</div>`;
}
html += `</div></div>`;
return html;
}
// --- Interactions ---
function toggleSection(el) {
el.classList.toggle('open');
el.nextElementSibling.classList.toggle('open');
}
function toggleSelect(id, checked) {
if (checked) selected.add(id); else selected.delete(id);
document.getElementById('sel-count').textContent = selected.size;
document.getElementById('footer').classList.toggle('hidden', selected.size === 0);
}
// --- Prompt Generation ---
function itemById(id) { return DATA.items.find(i => i.id === id); }
function buildPromptForItem(item) {
let p = '';
const sev = item.severity.replace('-', ' ').toUpperCase();
const loc = item.file ? `${item.file}${item.line ? ':'+item.line : ''}` : '';
p += `**[${sev}] ${item.title}**\n`;
if (loc) p += `- File: ${loc}\n`;
if (item.description && item.description !== item.title) p += `- Context: ${item.description}\n`;
if (item.action) {
const label = item.action_type === 'fix' ? 'Fix' : item.action_type === 'create-script' ? 'Create script' : 'Suggestion';
p += `- ${label}: ${item.action}\n`;
}
if (item.impact) p += `- Impact: ${item.impact}\n`;
return p;
}
function buildPrompt(ids) {
const items = ids.map(itemById).filter(Boolean);
const fixes = items.filter(i => i.action_type === 'fix');
const scripts = items.filter(i => i.action_type === 'create-script');
const enhancements = items.filter(i => i.action_type === 'enhance' || i.action_type === 'refactor');
let prompt = `## Task: Quality Improvements for ${DATA.meta.skill_name}\nSkill path: ${DATA.meta.skill_path}\n\n`;
if (fixes.length) {
prompt += `### Fix These Issues (${fixes.length})\n\n`;
fixes.forEach((item, i) => { prompt += `${i+1}. ${buildPromptForItem(item)}\n`; });
}
if (scripts.length) {
prompt += `### Create These Scripts (${scripts.length})\n\n`;
scripts.forEach((item, i) => { prompt += `${i+1}. ${buildPromptForItem(item)}\n`; });
}
if (enhancements.length) {
prompt += `### Implement These Enhancements (${enhancements.length})\n\n`;
enhancements.forEach((item, i) => { prompt += `${i+1}. ${buildPromptForItem(item)}\n`; });
}
return prompt.trim();
}
function copySinglePrompt(id) {
const item = itemById(id);
if (!item) return;
let prompt = `## Task: Quality Fix for ${DATA.meta.skill_name}\nSkill path: ${DATA.meta.skill_path}\n\n`;
prompt += buildPromptForItem(item);
navigator.clipboard.writeText(prompt).then(() => {
const btn = document.querySelector(`[onclick="copySinglePrompt('${id}')"]`);
if (btn) { btn.classList.add('copied'); btn.textContent = '\u2713'; setTimeout(() => { btn.classList.remove('copied'); btn.textContent = '\u2398'; }, 1500); }
});
}
function showBatchPrompt() {
const prompt = buildPrompt([...selected]);
document.getElementById('modal-content').textContent = prompt;
document.getElementById('modal').classList.add('visible');
}
function closeModal() { document.getElementById('modal').classList.remove('visible'); }
function copyModal() {
const text = document.getElementById('modal-content').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.modal-copy');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy to Clipboard'; }, 1500);
});
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
init();
</script>
</body>
</html>"""
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'<script id="report-data" type="application/json">{data_json}</script>'
# Insert before the main <script> tag
html = HTML_TEMPLATE.replace('<script>\nconst DATA', f'{data_tag}\n<script>\nconst DATA')
html = html.replace('SKILL_NAME_PLACEHOLDER', report_data['meta']['skill_name'])
return html
# =============================================================================
# CLI
# =============================================================================
def main() -> int:
parser = argparse.ArgumentParser(
description='Generate interactive HTML quality report from scanner JSON files',
)
parser.add_argument(
'report_dir',
type=Path,
help='Directory containing *-temp.json and *-prepass.json files',
)
parser.add_argument(
'--skill-path',
help='Path to the skill being scanned (auto-detected from JSON if omitted)',
)
parser.add_argument(
'--open',
action='store_true',
help='Open the HTML report in the default browser',
)
parser.add_argument(
'--output', '-o',
type=Path,
help='Output HTML file path (default: {report_dir}/quality-report.html)',
)
args = parser.parse_args()
if not args.report_dir.is_dir():
print(f'Error: {args.report_dir} is not a directory', file=sys.stderr)
return 2
report_data = load_report_data(args.report_dir, args.skill_path)
if not report_data['items']:
print('Warning: No scanner data found in directory', file=sys.stderr)
html = generate_html(report_data)
output_path = args.output or (args.report_dir / 'quality-report.html')
output_path.write_text(html, encoding='utf-8')
print(json.dumps({
'html_report': str(output_path),
'items': len(report_data['items']),
'issues': report_data['executive_summary']['total_issues'],
'grade': report_data['executive_summary']['grade'],
}))
if args.open:
system = platform.system()
if system == 'Darwin':
subprocess.run(['open', str(output_path)])
elif system == 'Linux':
subprocess.run(['xdg-open', str(output_path)])
elif system == 'Windows':
subprocess.run(['start', str(output_path)], shell=True)
return 0
if __name__ == '__main__':
sys.exit(main())