crewscore 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crewscore/__init__.py +3 -0
- crewscore/cli.py +403 -0
- crewscore/report.py +171 -0
- crewscore/scorers/__init__.py +1 -0
- crewscore/scorers/fix_patterns.py +184 -0
- crewscore/scorers/structural_analysis.py +359 -0
- crewscore/scoring.py +75 -0
- crewscore/vendor_scorecard.py +412 -0
- crewscore-0.2.0.dist-info/METADATA +311 -0
- crewscore-0.2.0.dist-info/RECORD +13 -0
- crewscore-0.2.0.dist-info/WHEEL +4 -0
- crewscore-0.2.0.dist-info/entry_points.txt +3 -0
- crewscore-0.2.0.dist-info/licenses/LICENSE +21 -0
crewscore/__init__.py
ADDED
crewscore/cli.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""CrewScore CLI — structural production-readiness scoring for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
|
|
13
|
+
from crewscore import __version__
|
|
14
|
+
from crewscore.report import render_badge_svg, render_html_report, share_text
|
|
15
|
+
from crewscore.scoring import DIMENSIONS, build_result, tier_color
|
|
16
|
+
from crewscore.scorers import structural_analysis
|
|
17
|
+
from crewscore.vendor_scorecard import assess_vendor
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
BRAND = "CrewScore"
|
|
22
|
+
HOMEPAGE = "https://crewscore.ai"
|
|
23
|
+
REPO = "https://github.com/shmindmaster/crewscore"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_score_bar(score: int) -> str:
|
|
27
|
+
filled = round(score / 10)
|
|
28
|
+
empty = 10 - filled
|
|
29
|
+
bar = "=" * filled + "-" * empty
|
|
30
|
+
|
|
31
|
+
if score >= 90:
|
|
32
|
+
color = "green"
|
|
33
|
+
status = "PASS"
|
|
34
|
+
elif score >= 70:
|
|
35
|
+
color = "yellow"
|
|
36
|
+
status = "MONITOR"
|
|
37
|
+
elif score >= 50:
|
|
38
|
+
color = "dark_orange"
|
|
39
|
+
status = "WEAK"
|
|
40
|
+
else:
|
|
41
|
+
color = "red"
|
|
42
|
+
status = "MISSING" if score == 0 else "FAILS"
|
|
43
|
+
|
|
44
|
+
return f"[{color}][{bar}] {score:>3}/100[/{color}] {status}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.version_option(version=__version__, prog_name="crewscore")
|
|
49
|
+
def main():
|
|
50
|
+
"""CrewScore — offline structural scorecard for AI agent system prompts."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
main.add_command(assess_vendor)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@main.command()
|
|
58
|
+
@click.option("--prompt", "-p", help="System prompt string to test")
|
|
59
|
+
@click.option(
|
|
60
|
+
"--prompt-file",
|
|
61
|
+
"-f",
|
|
62
|
+
type=click.Path(exists=True),
|
|
63
|
+
help="Path to system prompt file",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--json",
|
|
67
|
+
"as_json",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
help="Emit machine-readable JSON (for CI)",
|
|
70
|
+
)
|
|
71
|
+
@click.option(
|
|
72
|
+
"--threshold",
|
|
73
|
+
type=click.IntRange(0, 100),
|
|
74
|
+
default=None,
|
|
75
|
+
help="Exit non-zero if overall score is below this threshold",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--explain",
|
|
79
|
+
is_flag=True,
|
|
80
|
+
help="Show matched vs missing guardrail signals per dimension",
|
|
81
|
+
)
|
|
82
|
+
@click.option(
|
|
83
|
+
"--report",
|
|
84
|
+
type=click.Path(),
|
|
85
|
+
default=None,
|
|
86
|
+
help="Write a self-contained HTML scorecard to this path",
|
|
87
|
+
)
|
|
88
|
+
@click.option(
|
|
89
|
+
"--badge",
|
|
90
|
+
type=click.Path(),
|
|
91
|
+
default=None,
|
|
92
|
+
help="Write an SVG badge to this path",
|
|
93
|
+
)
|
|
94
|
+
def test(prompt, prompt_file, as_json, threshold, explain, report, badge):
|
|
95
|
+
"""Run structural production-readiness analysis on an agent system prompt.
|
|
96
|
+
|
|
97
|
+
This mode is offline and free: it scans the prompt text for guardrail
|
|
98
|
+
signals. It does not run live LLM adversarial attacks.
|
|
99
|
+
"""
|
|
100
|
+
system_prompt = None
|
|
101
|
+
source = "prompt"
|
|
102
|
+
|
|
103
|
+
if prompt:
|
|
104
|
+
system_prompt = prompt
|
|
105
|
+
source = "prompt"
|
|
106
|
+
elif prompt_file:
|
|
107
|
+
system_prompt = Path(prompt_file).read_text(encoding="utf-8")
|
|
108
|
+
source = str(prompt_file)
|
|
109
|
+
else:
|
|
110
|
+
console.print(
|
|
111
|
+
"[red]Error: Provide --prompt or --prompt-file[/red]",
|
|
112
|
+
err=True,
|
|
113
|
+
)
|
|
114
|
+
console.print(
|
|
115
|
+
'[dim]Example: crewscore test --prompt "You are a helpful assistant..."[/dim]',
|
|
116
|
+
err=True,
|
|
117
|
+
)
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
findings = None
|
|
121
|
+
if explain:
|
|
122
|
+
dimensions, findings = structural_analysis.analyze_with_findings(system_prompt)
|
|
123
|
+
else:
|
|
124
|
+
dimensions = structural_analysis.analyze(system_prompt)
|
|
125
|
+
result = build_result(dimensions, mode="structural", source=source)
|
|
126
|
+
|
|
127
|
+
if report:
|
|
128
|
+
report_path = Path(report)
|
|
129
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
report_path.write_text(render_html_report(result), encoding="utf-8")
|
|
131
|
+
if badge:
|
|
132
|
+
badge_path = Path(badge)
|
|
133
|
+
badge_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
badge_path.write_text(render_badge_svg(result), encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
if as_json:
|
|
137
|
+
payload = result.to_dict()
|
|
138
|
+
if findings is not None:
|
|
139
|
+
payload["findings"] = findings
|
|
140
|
+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
|
|
141
|
+
else:
|
|
142
|
+
color = tier_color(result.overall)
|
|
143
|
+
console.print()
|
|
144
|
+
console.print(
|
|
145
|
+
Panel(
|
|
146
|
+
f"[bold]{BRAND.upper()} — Structural Production Readiness Report[/bold]",
|
|
147
|
+
border_style="blue",
|
|
148
|
+
expand=False,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
console.print()
|
|
152
|
+
console.print(
|
|
153
|
+
"[dim]Mode: structural (offline prompt scan). "
|
|
154
|
+
"Not a substitute for live behavioral red-teaming.[/dim]"
|
|
155
|
+
)
|
|
156
|
+
console.print()
|
|
157
|
+
|
|
158
|
+
for label, key in DIMENSIONS:
|
|
159
|
+
score = result.dimensions.get(key, 0)
|
|
160
|
+
console.print(f" {label:<32} {render_score_bar(score)}")
|
|
161
|
+
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(f" {'-' * 54}")
|
|
164
|
+
console.print(
|
|
165
|
+
f" [{color}]OVERALL SCORE: {result.overall}/100 {result.tier}[/{color}]"
|
|
166
|
+
)
|
|
167
|
+
console.print(f" {'-' * 54}")
|
|
168
|
+
|
|
169
|
+
critical = [
|
|
170
|
+
(label, key)
|
|
171
|
+
for label, key in DIMENSIONS
|
|
172
|
+
if result.dimensions.get(key, 0) < 50
|
|
173
|
+
]
|
|
174
|
+
if critical:
|
|
175
|
+
console.print()
|
|
176
|
+
for label, key in critical:
|
|
177
|
+
score = result.dimensions[key]
|
|
178
|
+
if score == 0:
|
|
179
|
+
console.print(
|
|
180
|
+
f" [red]CRITICAL:[/red] No {label.lower()} detected in your agent."
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
console.print(
|
|
184
|
+
f" [dark_orange]WEAK:[/dark_orange] {label} is below "
|
|
185
|
+
f"production threshold ({score}/100)."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if findings is not None:
|
|
189
|
+
_render_findings(findings)
|
|
190
|
+
|
|
191
|
+
console.print()
|
|
192
|
+
console.print(f" Share: {share_text(result)}")
|
|
193
|
+
console.print()
|
|
194
|
+
console.print(
|
|
195
|
+
f" -> Run [bold]crewscore fix[/bold] to apply recommended guardrail patterns."
|
|
196
|
+
)
|
|
197
|
+
console.print(
|
|
198
|
+
" -> Re-run with [bold]--json[/bold] for CI. "
|
|
199
|
+
"Use [bold]--threshold N[/bold] to fail builds below N."
|
|
200
|
+
)
|
|
201
|
+
if report:
|
|
202
|
+
console.print(f" -> HTML report written to [bold]{report}[/bold]")
|
|
203
|
+
if badge:
|
|
204
|
+
console.print(f" -> SVG badge written to [bold]{badge}[/bold]")
|
|
205
|
+
console.print(f" -> {HOMEPAGE}")
|
|
206
|
+
console.print()
|
|
207
|
+
|
|
208
|
+
if threshold is not None and result.overall < threshold:
|
|
209
|
+
if not as_json:
|
|
210
|
+
console.print(
|
|
211
|
+
f" [red]Threshold failure: {result.overall} < {threshold}[/red]",
|
|
212
|
+
err=True,
|
|
213
|
+
)
|
|
214
|
+
sys.exit(2)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _render_findings(findings: list[dict]) -> None:
|
|
218
|
+
"""Print matched/missing findings grouped by dimension."""
|
|
219
|
+
label_by_key = {key: label for label, key in DIMENSIONS}
|
|
220
|
+
by_dim: dict[str, list[dict]] = {}
|
|
221
|
+
for f in findings:
|
|
222
|
+
by_dim.setdefault(f["dimension"], []).append(f)
|
|
223
|
+
|
|
224
|
+
console.print()
|
|
225
|
+
console.print("[bold]Findings (matched vs missing signals)[/bold]")
|
|
226
|
+
for _, key in DIMENSIONS:
|
|
227
|
+
items = by_dim.get(key, [])
|
|
228
|
+
if not items:
|
|
229
|
+
continue
|
|
230
|
+
dim_label = label_by_key.get(key, key)
|
|
231
|
+
console.print()
|
|
232
|
+
console.print(f" [bold]{dim_label}[/bold] ({key})")
|
|
233
|
+
for f in items:
|
|
234
|
+
status = f["status"]
|
|
235
|
+
reason = f.get("pattern_or_reason") or ""
|
|
236
|
+
snippet = f.get("snippet")
|
|
237
|
+
if status == "matched":
|
|
238
|
+
detail = snippet or reason
|
|
239
|
+
console.print(f" [green]matched[/green] {detail}")
|
|
240
|
+
else:
|
|
241
|
+
console.print(f" [red]missing[/red] {reason}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@main.command()
|
|
246
|
+
@click.option("--prompt", "-p", help="System prompt string to fix")
|
|
247
|
+
@click.option(
|
|
248
|
+
"--prompt-file",
|
|
249
|
+
"-f",
|
|
250
|
+
type=click.Path(exists=True),
|
|
251
|
+
help="Path to system prompt file",
|
|
252
|
+
)
|
|
253
|
+
@click.option(
|
|
254
|
+
"--apply",
|
|
255
|
+
is_flag=True,
|
|
256
|
+
help="Write the fixed prompt back to the file (in-place)",
|
|
257
|
+
)
|
|
258
|
+
@click.option(
|
|
259
|
+
"--output",
|
|
260
|
+
"-o",
|
|
261
|
+
type=click.Path(),
|
|
262
|
+
help="Write enhanced prompt to a new file",
|
|
263
|
+
)
|
|
264
|
+
@click.option(
|
|
265
|
+
"--json",
|
|
266
|
+
"as_json",
|
|
267
|
+
is_flag=True,
|
|
268
|
+
help="Emit JSON summary of applied dimensions and score delta",
|
|
269
|
+
)
|
|
270
|
+
def fix(prompt, prompt_file, apply, output, as_json):
|
|
271
|
+
"""Append recommended guardrail patterns to a system prompt."""
|
|
272
|
+
from crewscore.scorers.fix_patterns import apply_fixes, explain_fixes, generate_fixes
|
|
273
|
+
|
|
274
|
+
system_prompt = None
|
|
275
|
+
source_path = None
|
|
276
|
+
|
|
277
|
+
if prompt:
|
|
278
|
+
system_prompt = prompt
|
|
279
|
+
elif prompt_file:
|
|
280
|
+
source_path = Path(prompt_file)
|
|
281
|
+
system_prompt = source_path.read_text(encoding="utf-8")
|
|
282
|
+
else:
|
|
283
|
+
console.print(
|
|
284
|
+
"[red]Error: Provide --prompt or --prompt-file[/red]",
|
|
285
|
+
err=True,
|
|
286
|
+
)
|
|
287
|
+
console.print(
|
|
288
|
+
"[dim]Example: crewscore fix --prompt-file ./system-prompt.md --apply[/dim]",
|
|
289
|
+
err=True,
|
|
290
|
+
)
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
before = structural_analysis.analyze(system_prompt)
|
|
294
|
+
before_result = build_result(before)
|
|
295
|
+
fixes = generate_fixes(before)
|
|
296
|
+
|
|
297
|
+
honesty_note = (
|
|
298
|
+
"Templates must be paired with runtime gates "
|
|
299
|
+
"(tool allowlists, human approval hooks, logging, and policy enforcement)"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not fixes:
|
|
303
|
+
if as_json:
|
|
304
|
+
click.echo(
|
|
305
|
+
json.dumps(
|
|
306
|
+
{
|
|
307
|
+
"fixes_applied": [],
|
|
308
|
+
"before": before_result.to_dict(),
|
|
309
|
+
"after": before_result.to_dict(),
|
|
310
|
+
"message": "No fixes needed",
|
|
311
|
+
"note": honesty_note,
|
|
312
|
+
},
|
|
313
|
+
indent=2,
|
|
314
|
+
sort_keys=True,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
console.print()
|
|
319
|
+
console.print(
|
|
320
|
+
" [green]No fixes needed — structural score is already strong.[/green]"
|
|
321
|
+
)
|
|
322
|
+
console.print()
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
enhanced = apply_fixes(system_prompt, fixes)
|
|
326
|
+
after = structural_analysis.analyze(enhanced)
|
|
327
|
+
after_result = build_result(after)
|
|
328
|
+
|
|
329
|
+
if apply and source_path:
|
|
330
|
+
source_path.write_text(enhanced, encoding="utf-8")
|
|
331
|
+
elif output:
|
|
332
|
+
Path(output).write_text(enhanced, encoding="utf-8")
|
|
333
|
+
|
|
334
|
+
if as_json:
|
|
335
|
+
click.echo(
|
|
336
|
+
json.dumps(
|
|
337
|
+
{
|
|
338
|
+
"fixes_applied": list(fixes.keys()),
|
|
339
|
+
"before": before_result.to_dict(),
|
|
340
|
+
"after": after_result.to_dict(),
|
|
341
|
+
"written": bool(apply and source_path) or bool(output),
|
|
342
|
+
"path": str(source_path)
|
|
343
|
+
if apply and source_path
|
|
344
|
+
else (output or None),
|
|
345
|
+
"note": honesty_note,
|
|
346
|
+
},
|
|
347
|
+
indent=2,
|
|
348
|
+
sort_keys=True,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
console.print()
|
|
354
|
+
console.print(
|
|
355
|
+
Panel(
|
|
356
|
+
f"[bold]{BRAND.upper()} — Applying Fixes[/bold]",
|
|
357
|
+
border_style="green",
|
|
358
|
+
expand=False,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
console.print()
|
|
362
|
+
console.print(explain_fixes(fixes))
|
|
363
|
+
console.print()
|
|
364
|
+
console.print(
|
|
365
|
+
"[yellow]Honesty note:[/yellow] These are prompt templates — "
|
|
366
|
+
"they must be paired with runtime gates (tool allowlists, human "
|
|
367
|
+
"approval hooks, logging, and policy enforcement) to have real effect."
|
|
368
|
+
)
|
|
369
|
+
console.print()
|
|
370
|
+
|
|
371
|
+
if apply and source_path:
|
|
372
|
+
console.print(f" [green]Fixes applied in-place to {source_path}[/green]")
|
|
373
|
+
console.print()
|
|
374
|
+
console.print(
|
|
375
|
+
f" Score: [red]{before_result.overall}/100[/red] -> "
|
|
376
|
+
f"[green]{after_result.overall}/100[/green] "
|
|
377
|
+
f"(+{after_result.overall - before_result.overall})"
|
|
378
|
+
)
|
|
379
|
+
console.print()
|
|
380
|
+
elif output:
|
|
381
|
+
console.print(f" [green]Enhanced prompt written to {output}[/green]")
|
|
382
|
+
console.print()
|
|
383
|
+
console.print(
|
|
384
|
+
f" Score: [red]{before_result.overall}/100[/red] -> "
|
|
385
|
+
f"[green]{after_result.overall}/100[/green] "
|
|
386
|
+
f"(+{after_result.overall - before_result.overall})"
|
|
387
|
+
)
|
|
388
|
+
console.print()
|
|
389
|
+
else:
|
|
390
|
+
console.print("[dim]--- Enhanced System Prompt ---[/dim]")
|
|
391
|
+
console.print()
|
|
392
|
+
console.print(enhanced)
|
|
393
|
+
console.print()
|
|
394
|
+
console.print("[dim]--- End ---[/dim]")
|
|
395
|
+
console.print()
|
|
396
|
+
console.print(
|
|
397
|
+
"[dim]Use --apply to write in-place, or --output <file> to save to a new file.[/dim]"
|
|
398
|
+
)
|
|
399
|
+
console.print()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
main()
|
crewscore/report.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Self-contained HTML report, SVG badge, and share text for CrewScore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from html import escape
|
|
7
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
8
|
+
|
|
9
|
+
from crewscore import __version__
|
|
10
|
+
from crewscore.scoring import DIMENSIONS, ScoreResult
|
|
11
|
+
|
|
12
|
+
HOMEPAGE = "https://crewscore.ai"
|
|
13
|
+
|
|
14
|
+
# Tier colors aligned with index.html score classes
|
|
15
|
+
_TIER_HEX = {
|
|
16
|
+
"green": "#10b981",
|
|
17
|
+
"yellow": "#eab308",
|
|
18
|
+
"dark_orange": "#f59e0b",
|
|
19
|
+
"orange": "#f59e0b",
|
|
20
|
+
"red": "#ef4444",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _score_color_hex(overall: int) -> str:
|
|
25
|
+
if overall >= 90:
|
|
26
|
+
return _TIER_HEX["green"]
|
|
27
|
+
if overall >= 70:
|
|
28
|
+
return _TIER_HEX["yellow"]
|
|
29
|
+
if overall >= 50:
|
|
30
|
+
return _TIER_HEX["dark_orange"]
|
|
31
|
+
return _TIER_HEX["red"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bar_color(score: int) -> str:
|
|
35
|
+
return _score_color_hex(score)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def share_text(result: ScoreResult) -> str:
|
|
39
|
+
"""One-line share copy with overall score and product URL."""
|
|
40
|
+
return (
|
|
41
|
+
f"My AI agent scored {result.overall}/100 on CrewScore "
|
|
42
|
+
f"({result.tier}) — structural production-readiness scan. "
|
|
43
|
+
f"{HOMEPAGE}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_badge_svg(result: ScoreResult) -> str:
|
|
48
|
+
"""Shields-style SVG badge: CrewScore | {score}/100 colored by tier."""
|
|
49
|
+
label = "CrewScore"
|
|
50
|
+
value = f"{result.overall}/100"
|
|
51
|
+
color = _score_color_hex(result.overall)
|
|
52
|
+
|
|
53
|
+
# Approximate widths for monospace-ish badge layout
|
|
54
|
+
label_w = 78
|
|
55
|
+
value_w = 54
|
|
56
|
+
total_w = label_w + value_w
|
|
57
|
+
label_x = label_w / 2
|
|
58
|
+
value_x = label_w + value_w / 2
|
|
59
|
+
|
|
60
|
+
label_esc = xml_escape(label)
|
|
61
|
+
value_esc = xml_escape(value)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" '
|
|
65
|
+
f'role="img" aria-label="{label_esc}: {value_esc}">'
|
|
66
|
+
f"<title>{label_esc}: {value_esc}</title>"
|
|
67
|
+
f'<linearGradient id="s" x2="0" y2="100%">'
|
|
68
|
+
f'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
|
|
69
|
+
f'<stop offset="1" stop-opacity=".1"/>'
|
|
70
|
+
f"</linearGradient>"
|
|
71
|
+
f'<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>'
|
|
72
|
+
f'<g clip-path="url(#r)">'
|
|
73
|
+
f'<rect width="{label_w}" height="20" fill="#555"/>'
|
|
74
|
+
f'<rect x="{label_w}" width="{value_w}" height="20" fill="{color}"/>'
|
|
75
|
+
f'<rect width="{total_w}" height="20" fill="url(#s)"/>'
|
|
76
|
+
f"</g>"
|
|
77
|
+
f'<g fill="#fff" text-anchor="middle" '
|
|
78
|
+
f'font-family="Verdana,Geneva,DejaVu Sans,sans-serif" '
|
|
79
|
+
f'font-size="11">'
|
|
80
|
+
f'<text x="{label_x}" y="14">{label_esc}</text>'
|
|
81
|
+
f'<text x="{value_x}" y="14">{value_esc}</text>'
|
|
82
|
+
f"</g>"
|
|
83
|
+
f"</svg>"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_html_report(
|
|
88
|
+
result: ScoreResult,
|
|
89
|
+
*,
|
|
90
|
+
generated_at: str | None = None,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Self-contained dark HTML scorecard (inline CSS, no scripts/CDN)."""
|
|
93
|
+
if generated_at is None:
|
|
94
|
+
generated_at = (
|
|
95
|
+
datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
color = _score_color_hex(result.overall)
|
|
99
|
+
overall = result.overall
|
|
100
|
+
tier = escape(result.tier)
|
|
101
|
+
mode = escape(result.mode)
|
|
102
|
+
source = escape(result.source)
|
|
103
|
+
version = escape(__version__)
|
|
104
|
+
ts = escape(generated_at)
|
|
105
|
+
|
|
106
|
+
rows: list[str] = []
|
|
107
|
+
for label, key in DIMENSIONS:
|
|
108
|
+
score = result.dimensions.get(key, 0)
|
|
109
|
+
bar_color = _bar_color(score)
|
|
110
|
+
rows.append(
|
|
111
|
+
f'<div class="dim-row">'
|
|
112
|
+
f'<span class="dim-label">{escape(label)}</span>'
|
|
113
|
+
f'<div class="dim-bar">'
|
|
114
|
+
f'<div class="dim-fill" style="width:{score}%;background:{bar_color}"></div>'
|
|
115
|
+
f"</div>"
|
|
116
|
+
f'<span class="dim-score">{score}/100</span>'
|
|
117
|
+
f"</div>"
|
|
118
|
+
)
|
|
119
|
+
dim_html = "\n".join(rows)
|
|
120
|
+
|
|
121
|
+
return f"""<!DOCTYPE html>
|
|
122
|
+
<html lang="en">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="UTF-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
126
|
+
<title>CrewScore — {overall}/100</title>
|
|
127
|
+
<style>
|
|
128
|
+
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
129
|
+
body{{font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;
|
|
130
|
+
background:#0f0f1a;color:#e2e8f0;min-height:100vh;padding:2rem 1rem;
|
|
131
|
+
display:flex;flex-direction:column;align-items:center}}
|
|
132
|
+
.container{{max-width:640px;width:100%}}
|
|
133
|
+
h1{{font-size:1.75rem;color:#fff;text-align:center;margin-bottom:0.25rem}}
|
|
134
|
+
.subtitle{{text-align:center;color:#94a3b8;font-size:0.85rem;margin-bottom:1.5rem}}
|
|
135
|
+
.card{{background:#1a1f2e;border:1px solid #334155;border-radius:12px;padding:1.5rem}}
|
|
136
|
+
.score-big{{font-size:3rem;font-weight:bold;text-align:center;color:{color};margin:0.5rem 0}}
|
|
137
|
+
.tier{{text-align:center;font-size:1.05rem;font-weight:bold;color:{color};margin-bottom:1rem}}
|
|
138
|
+
.meta{{text-align:center;font-size:0.75rem;color:#64748b;margin-bottom:1.25rem}}
|
|
139
|
+
.dim-row{{display:flex;align-items:center;gap:0.5rem;margin:0.45rem 0;font-size:0.75rem}}
|
|
140
|
+
.dim-label{{width:200px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
|
|
141
|
+
.dim-bar{{flex:1;height:8px;background:#1e293b;border-radius:4px;overflow:hidden}}
|
|
142
|
+
.dim-fill{{height:100%;border-radius:4px}}
|
|
143
|
+
.dim-score{{width:55px;text-align:right;color:#64748b}}
|
|
144
|
+
.disclaimer{{margin-top:1.25rem;padding:0.75rem;background:#0f0f1a;border:1px solid #334155;
|
|
145
|
+
border-radius:8px;font-size:0.75rem;color:#94a3b8;line-height:1.45}}
|
|
146
|
+
.footer{{margin-top:1.25rem;text-align:center;font-size:0.7rem;color:#475569;line-height:1.5}}
|
|
147
|
+
.footer a{{color:#3b82f6;text-decoration:none}}
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<div class="container">
|
|
152
|
+
<h1>CrewScore</h1>
|
|
153
|
+
<p class="subtitle">Structural Production Readiness Report</p>
|
|
154
|
+
<div class="card">
|
|
155
|
+
<div class="score-big">{overall}/100</div>
|
|
156
|
+
<div class="tier">{tier}</div>
|
|
157
|
+
<div class="meta">Mode: {mode} · Source: {source}</div>
|
|
158
|
+
{dim_html}
|
|
159
|
+
<div class="disclaimer">
|
|
160
|
+
<strong>Structural scan only</strong> — offline pattern match on prompt text.
|
|
161
|
+
Not a substitute for live behavioral red-teaming or runtime proof of safety.
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="footer">
|
|
165
|
+
CrewScore v{version} · Generated {ts}<br>
|
|
166
|
+
<a href="{HOMEPAGE}">crewscore.ai</a>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</body>
|
|
170
|
+
</html>
|
|
171
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scorer modules for each evaluation dimension."""
|