scamscript 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.
- scamscript/__init__.py +3 -0
- scamscript/__main__.py +4 -0
- scamscript/analysis.py +138 -0
- scamscript/cli.py +521 -0
- scamscript/client.py +207 -0
- scamscript/config.py +55 -0
- scamscript/ingest.py +85 -0
- scamscript/models.py +169 -0
- scamscript/prompts.py +90 -0
- scamscript/redact.py +62 -0
- scamscript/render.py +232 -0
- scamscript/report.py +196 -0
- scamscript/store.py +187 -0
- scamscript/verify.py +103 -0
- scamscript-0.2.0.dist-info/METADATA +278 -0
- scamscript-0.2.0.dist-info/RECORD +19 -0
- scamscript-0.2.0.dist-info/WHEEL +4 -0
- scamscript-0.2.0.dist-info/entry_points.txt +2 -0
- scamscript-0.2.0.dist-info/licenses/LICENSE +21 -0
scamscript/render.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Rich rendering of breakdowns, prompts, and summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Group, RenderableType
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from scamscript.ingest import ScriptFile
|
|
12
|
+
from scamscript.models import ScriptBreakdown
|
|
13
|
+
from scamscript.verify import Issue, summarize
|
|
14
|
+
|
|
15
|
+
SECTION_LABELS = {
|
|
16
|
+
"persona": "PERSONA",
|
|
17
|
+
"opening_hook": "OPENING HOOK",
|
|
18
|
+
"trust_building_moves": "TRUST BUILDING",
|
|
19
|
+
"urgency_tactics": "URGENCY TACTICS",
|
|
20
|
+
"escalation_ladder": "ESCALATION LADDER",
|
|
21
|
+
"target_demographics": "TARGET DEMOGRAPHICS",
|
|
22
|
+
"red_flags": "RED FLAGS",
|
|
23
|
+
"defense_summary": "DEFENSE SUMMARY",
|
|
24
|
+
"quote_check": "QUOTE CHECK",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
SECTION_COLORS = {
|
|
28
|
+
"attack_type": "bold white on dark_red",
|
|
29
|
+
"persona": "magenta",
|
|
30
|
+
"opening_hook": "italic cyan",
|
|
31
|
+
"trust_building_moves": "yellow",
|
|
32
|
+
"urgency_tactics": "bold orange3",
|
|
33
|
+
"escalation_ladder": "dark_orange3",
|
|
34
|
+
"target_demographics": "blue",
|
|
35
|
+
"red_flags": "bold red",
|
|
36
|
+
"defense_summary": "bold green",
|
|
37
|
+
"quote_check": "dim white",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
MARKERS = {
|
|
41
|
+
"trust_building_moves": "•",
|
|
42
|
+
"urgency_tactics": "!",
|
|
43
|
+
"escalation_ladder": "→",
|
|
44
|
+
"red_flags": "✗",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ATTACK_COLORS = {
|
|
48
|
+
"vishing": "red",
|
|
49
|
+
"smishing": "magenta",
|
|
50
|
+
"romance": "bright_magenta",
|
|
51
|
+
"tech support": "blue",
|
|
52
|
+
"government": "bright_red",
|
|
53
|
+
"refund": "orange3",
|
|
54
|
+
"crypto": "yellow",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def border_for(breakdown: ScriptBreakdown) -> str:
|
|
59
|
+
score = breakdown.sophistication_score
|
|
60
|
+
if score >= 8:
|
|
61
|
+
return "red"
|
|
62
|
+
if score >= 5:
|
|
63
|
+
return "yellow"
|
|
64
|
+
return "green"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def attack_color(attack_type: str) -> str:
|
|
68
|
+
lowered = attack_type.lower()
|
|
69
|
+
for needle, color in ATTACK_COLORS.items():
|
|
70
|
+
if needle in lowered:
|
|
71
|
+
return color
|
|
72
|
+
return "red"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _heading(breakdown_key: str) -> Text:
|
|
76
|
+
return Text(SECTION_LABELS[breakdown_key], style=SECTION_COLORS[breakdown_key])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _items(values: list[str], marker: str, color: str) -> Text:
|
|
80
|
+
body = Text()
|
|
81
|
+
for index, value in enumerate(values, start=1):
|
|
82
|
+
prefix = f"{index}." if marker == "→" else marker
|
|
83
|
+
body.append(f" {prefix} ", style=f"bold {color}")
|
|
84
|
+
body.append(value)
|
|
85
|
+
if index < len(values):
|
|
86
|
+
body.append("\n")
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _section(key: str, breakdown: ScriptBreakdown) -> list[RenderableType]:
|
|
91
|
+
value = getattr(breakdown, key)
|
|
92
|
+
parts: list[RenderableType] = [_heading(key)]
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
if not value:
|
|
95
|
+
parts.append(Text(" (none recorded)", style="dim"))
|
|
96
|
+
else:
|
|
97
|
+
parts.append(_items(value, MARKERS[key], SECTION_COLORS[key]))
|
|
98
|
+
else:
|
|
99
|
+
style = "italic" if key == "opening_hook" else ""
|
|
100
|
+
parts.append(Text(f" {value}", style=style))
|
|
101
|
+
parts.append(Text(""))
|
|
102
|
+
return parts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_breakdown(
|
|
106
|
+
breakdown: ScriptBreakdown,
|
|
107
|
+
name: str,
|
|
108
|
+
attempts: int = 1,
|
|
109
|
+
issues: list[Issue] | None = None,
|
|
110
|
+
) -> Panel:
|
|
111
|
+
renderables: list[RenderableType] = []
|
|
112
|
+
|
|
113
|
+
attack = Text()
|
|
114
|
+
attack.append("ATTACK TYPE ", style="dim")
|
|
115
|
+
attack.append(
|
|
116
|
+
f" {breakdown.attack_type.upper()} ",
|
|
117
|
+
style=SECTION_COLORS["attack_type"],
|
|
118
|
+
)
|
|
119
|
+
attack.append(" ")
|
|
120
|
+
attack.append("SOPHISTICATION ", style="dim")
|
|
121
|
+
attack.append(
|
|
122
|
+
f"{breakdown.sophistication_score}/10",
|
|
123
|
+
style=f"bold {border_for(breakdown)}",
|
|
124
|
+
)
|
|
125
|
+
renderables.append(attack)
|
|
126
|
+
renderables.append(Text(""))
|
|
127
|
+
|
|
128
|
+
for key in (
|
|
129
|
+
"persona",
|
|
130
|
+
"opening_hook",
|
|
131
|
+
"trust_building_moves",
|
|
132
|
+
"urgency_tactics",
|
|
133
|
+
"escalation_ladder",
|
|
134
|
+
"target_demographics",
|
|
135
|
+
"red_flags",
|
|
136
|
+
):
|
|
137
|
+
renderables.extend(_section(key, breakdown))
|
|
138
|
+
|
|
139
|
+
if issues is not None:
|
|
140
|
+
renderables.extend(_quote_section(issues))
|
|
141
|
+
|
|
142
|
+
renderables.extend(_section("defense_summary", breakdown))
|
|
143
|
+
|
|
144
|
+
subtitle_parts = [f"{breakdown.flag_count} red flags"]
|
|
145
|
+
counts = summarize(issues or [])
|
|
146
|
+
if counts["checked"]:
|
|
147
|
+
subtitle_parts.append(f"{counts['verbatim']}/{counts['checked']} quotes verbatim")
|
|
148
|
+
if counts["missing"]:
|
|
149
|
+
subtitle_parts.append(f"{counts['missing']} missing")
|
|
150
|
+
if attempts > 1:
|
|
151
|
+
subtitle_parts.append(f"repaired on attempt {attempts}")
|
|
152
|
+
subtitle = " · ".join(subtitle_parts)
|
|
153
|
+
|
|
154
|
+
return Panel(
|
|
155
|
+
Group(*renderables),
|
|
156
|
+
title=f"[bold]{name}[/bold]",
|
|
157
|
+
subtitle=f"[dim]{subtitle}[/dim]",
|
|
158
|
+
border_style=border_for(breakdown),
|
|
159
|
+
padding=(0, 1),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _quote_section(issues: list[Issue]) -> list[RenderableType]:
|
|
164
|
+
parts: list[RenderableType] = [_heading("quote_check")]
|
|
165
|
+
bad = [issue for issue in issues if not issue.ok]
|
|
166
|
+
if not issues:
|
|
167
|
+
parts.append(Text(" (no quoted claims to check)", style="dim"))
|
|
168
|
+
elif not bad:
|
|
169
|
+
parts.append(Text(" all quoted claims found in the source", style="green"))
|
|
170
|
+
else:
|
|
171
|
+
body = Text()
|
|
172
|
+
for index, issue in enumerate(bad, start=1):
|
|
173
|
+
marker = "≈" if issue.status == "close" else "✗"
|
|
174
|
+
style = "yellow" if issue.status == "close" else "red"
|
|
175
|
+
body.append(f" {marker} ", style=f"bold {style}")
|
|
176
|
+
body.append(f"{issue.field}: ", style="dim")
|
|
177
|
+
body.append(issue.quote, style=style)
|
|
178
|
+
if index < len(bad):
|
|
179
|
+
body.append("\n")
|
|
180
|
+
parts.append(body)
|
|
181
|
+
parts.append(Text(""))
|
|
182
|
+
return parts
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def render_failure(name: str, error: str, attempts: int) -> Panel:
|
|
186
|
+
body = Text()
|
|
187
|
+
body.append("FAILED after ", style="bold red")
|
|
188
|
+
body.append(f"{attempts} attempt(s)\n\n")
|
|
189
|
+
body.append(error, style="red")
|
|
190
|
+
return Panel(body, title=f"[bold]{name}[/bold]", border_style="red", padding=(0, 1))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def render_prompt(source: ScriptFile, messages: list[dict[str, str]]) -> Panel:
|
|
194
|
+
body = Text()
|
|
195
|
+
for message in messages:
|
|
196
|
+
body.append(f"<{message['role']}>\n", style="bold cyan")
|
|
197
|
+
body.append(message["content"] + "\n\n", style="")
|
|
198
|
+
header = Text(
|
|
199
|
+
f"{len(messages)} message(s) · {sum(len(m['content']) for m in messages)} chars",
|
|
200
|
+
style="dim",
|
|
201
|
+
)
|
|
202
|
+
return Panel(
|
|
203
|
+
Group(header, Text(""), body),
|
|
204
|
+
title=f"[bold]dry run: {source.name}[/bold]",
|
|
205
|
+
border_style="cyan",
|
|
206
|
+
padding=(0, 1),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_summary_table(rows: list[tuple[str, ScriptBreakdown | None, str | None]]) -> Table:
|
|
211
|
+
table = Table(title="Run summary", header_style="bold", expand=True)
|
|
212
|
+
table.add_column("Script", overflow="fold", ratio=3)
|
|
213
|
+
table.add_column("Attack type", ratio=2)
|
|
214
|
+
table.add_column("Score", justify="right")
|
|
215
|
+
table.add_column("Flags", justify="right")
|
|
216
|
+
table.add_column("Status")
|
|
217
|
+
for name, breakdown, error in rows:
|
|
218
|
+
if breakdown is None:
|
|
219
|
+
table.add_row(name, "-", "-", "-", f"[red]{error or 'failed'}[/red]")
|
|
220
|
+
continue
|
|
221
|
+
table.add_row(
|
|
222
|
+
name,
|
|
223
|
+
f"[{attack_color(breakdown.attack_type)}]{breakdown.attack_type}[/]",
|
|
224
|
+
f"{breakdown.sophistication_score}/10",
|
|
225
|
+
str(breakdown.flag_count),
|
|
226
|
+
"[green]ok[/green]",
|
|
227
|
+
)
|
|
228
|
+
return table
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def render_report(markdown: str) -> Markdown:
|
|
232
|
+
return Markdown(markdown, hyperlinks=True)
|
scamscript/report.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Aggregated markdown reporting, live or replayed from logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from scamscript.models import ScriptBreakdown
|
|
11
|
+
from scamscript.store import results_of, utc_now
|
|
12
|
+
|
|
13
|
+
SECTIONS = (
|
|
14
|
+
("persona", "Persona adopted"),
|
|
15
|
+
("opening_hook", "Opening hook"),
|
|
16
|
+
("trust_building_moves", "Trust-building moves"),
|
|
17
|
+
("urgency_tactics", "Urgency tactics"),
|
|
18
|
+
("escalation_ladder", "Escalation ladder"),
|
|
19
|
+
("target_demographics", "Target demographics"),
|
|
20
|
+
("red_flags", "Red flags"),
|
|
21
|
+
("defense_summary", "Defense summary"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class ReportEntry:
|
|
27
|
+
name: str
|
|
28
|
+
source: str
|
|
29
|
+
breakdown: ScriptBreakdown | None = None
|
|
30
|
+
error: str | None = None
|
|
31
|
+
duration: float = 0.0
|
|
32
|
+
verification: dict[str, int] | None = None
|
|
33
|
+
unverified: list[dict[str, str]] | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ok(self) -> bool:
|
|
37
|
+
return self.breakdown is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def entry_from_outcome(outcome: Any) -> ReportEntry:
|
|
41
|
+
return ReportEntry(
|
|
42
|
+
name=outcome.name,
|
|
43
|
+
source=str(getattr(outcome, "source", outcome.name)),
|
|
44
|
+
breakdown=outcome.breakdown,
|
|
45
|
+
error=outcome.error,
|
|
46
|
+
duration=getattr(outcome, "duration", 0.0),
|
|
47
|
+
verification=getattr(outcome, "verification", None),
|
|
48
|
+
unverified=[
|
|
49
|
+
{"field": issue.field, "quote": issue.quote, "status": issue.status}
|
|
50
|
+
for issue in getattr(outcome, "issues", None) or []
|
|
51
|
+
if not issue.ok
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
57
|
+
entries: list[ReportEntry] = []
|
|
58
|
+
for record in results_of(run):
|
|
59
|
+
payload = record.get("breakdown")
|
|
60
|
+
breakdown: ScriptBreakdown | None = None
|
|
61
|
+
if isinstance(payload, dict):
|
|
62
|
+
try:
|
|
63
|
+
breakdown = ScriptBreakdown.model_validate(payload)
|
|
64
|
+
except ValidationError:
|
|
65
|
+
breakdown = None
|
|
66
|
+
entries.append(
|
|
67
|
+
ReportEntry(
|
|
68
|
+
name=record.get("name") or record.get("source") or "unknown",
|
|
69
|
+
source=record.get("source") or "",
|
|
70
|
+
breakdown=breakdown,
|
|
71
|
+
error=None if breakdown else record.get("error") or "no breakdown stored",
|
|
72
|
+
duration=float(record.get("duration_s") or 0.0),
|
|
73
|
+
verification=record.get("verification") or None,
|
|
74
|
+
unverified=record.get("unverified") or None,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return entries
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rank(entries: list[ReportEntry]) -> list[ReportEntry]:
|
|
81
|
+
return sorted(
|
|
82
|
+
entries,
|
|
83
|
+
key=lambda entry: (
|
|
84
|
+
-(entry.breakdown.sophistication_score if entry.breakdown else -1),
|
|
85
|
+
-(entry.breakdown.flag_count if entry.breakdown else 0),
|
|
86
|
+
entry.name.lower(),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_markdown(
|
|
92
|
+
entries: list[ReportEntry],
|
|
93
|
+
run_id: str,
|
|
94
|
+
command: str = "batch",
|
|
95
|
+
model: str | None = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
ranked = rank(entries)
|
|
98
|
+
ok = [entry for entry in ranked if entry.ok]
|
|
99
|
+
failed = [entry for entry in ranked if not entry.ok]
|
|
100
|
+
average = (
|
|
101
|
+
sum(entry.breakdown.sophistication_score for entry in ok) / len(ok) if ok else 0.0
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
lines.append("# Scam script breakdown report")
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append(f"- Generated: {utc_now()}")
|
|
108
|
+
lines.append(f"- Run: `{run_id}`")
|
|
109
|
+
lines.append(f"- Command: `{command}`")
|
|
110
|
+
if model:
|
|
111
|
+
lines.append(f"- Model: `{model}`")
|
|
112
|
+
lines.append(f"- Scripts analyzed: {len(entries)}")
|
|
113
|
+
lines.append(f"- Successful: {len(ok)}")
|
|
114
|
+
lines.append(f"- Failed: {len(failed)}")
|
|
115
|
+
unverified_total = sum(len(entry.unverified or []) for entry in ok)
|
|
116
|
+
if unverified_total:
|
|
117
|
+
lines.append(f"- Unverified quotes: {unverified_total}")
|
|
118
|
+
if ok:
|
|
119
|
+
lines.append(f"- Mean sophistication: {average:.1f}/10")
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
if ok:
|
|
123
|
+
lines.append("## Ranking by sophistication")
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.append("| Rank | Script | Attack type | Sophistication | Red flags | Unverified |")
|
|
126
|
+
lines.append("| ---: | --- | --- | ---: | ---: | ---: |")
|
|
127
|
+
for position, entry in enumerate(ok, start=1):
|
|
128
|
+
lines.append(
|
|
129
|
+
f"| {position} | {_cell(entry.name)} | {_cell(entry.breakdown.attack_type)} | "
|
|
130
|
+
f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
|
|
131
|
+
f"{len(entry.unverified or [])} |"
|
|
132
|
+
)
|
|
133
|
+
lines.append("")
|
|
134
|
+
for position, entry in enumerate(ok, start=1):
|
|
135
|
+
lines.extend(_detail_section(position, entry))
|
|
136
|
+
else:
|
|
137
|
+
lines.append("_No successful analyses in this run._")
|
|
138
|
+
lines.append("")
|
|
139
|
+
|
|
140
|
+
if failed:
|
|
141
|
+
lines.append("## Failures")
|
|
142
|
+
lines.append("")
|
|
143
|
+
for entry in failed:
|
|
144
|
+
lines.append(f"- **{_cell(entry.name)}** (`{_cell(entry.source)}`): {_cell(entry.error or 'unknown error')}")
|
|
145
|
+
lines.append("")
|
|
146
|
+
|
|
147
|
+
lines.append("---")
|
|
148
|
+
lines.append("")
|
|
149
|
+
lines.append(
|
|
150
|
+
"_Generated by scamscript for security research and fraud-awareness training only. "
|
|
151
|
+
"Breakdowns are model-produced readings of captured scripts, not legal advice._"
|
|
152
|
+
)
|
|
153
|
+
lines.append("")
|
|
154
|
+
return "\n".join(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
158
|
+
breakdown = entry.breakdown
|
|
159
|
+
assert breakdown is not None
|
|
160
|
+
lines = [f"## {position}. {entry.name}", ""]
|
|
161
|
+
lines.append(f"- **Attack type:** {breakdown.attack_type}")
|
|
162
|
+
lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
|
|
163
|
+
lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
|
|
164
|
+
counts = entry.verification or {}
|
|
165
|
+
if counts.get("checked"):
|
|
166
|
+
lines.append(
|
|
167
|
+
f"- **Quote check:** {counts.get('verbatim', 0)}/{counts['checked']} verbatim, "
|
|
168
|
+
f"{counts.get('missing', 0)} missing"
|
|
169
|
+
)
|
|
170
|
+
if entry.source:
|
|
171
|
+
lines.append(f"- **Source:** `{entry.source}`")
|
|
172
|
+
lines.append("")
|
|
173
|
+
if entry.unverified:
|
|
174
|
+
lines.append("### Unverified quotes")
|
|
175
|
+
lines.append("")
|
|
176
|
+
for item in entry.unverified:
|
|
177
|
+
mark = "close" if item.get("status") == "close" else "missing"
|
|
178
|
+
lines.append(f"- _{mark}_ [{(item.get('field') or '').replace('_', ' ')}] \"{item.get('quote', '')}\"")
|
|
179
|
+
lines.append("")
|
|
180
|
+
for field, label in SECTIONS:
|
|
181
|
+
value = getattr(breakdown, field)
|
|
182
|
+
lines.append(f"### {label}")
|
|
183
|
+
lines.append("")
|
|
184
|
+
if isinstance(value, list):
|
|
185
|
+
if value:
|
|
186
|
+
lines.extend(f"{index}. {item}" for index, item in enumerate(value, start=1))
|
|
187
|
+
else:
|
|
188
|
+
lines.append("_None recorded._")
|
|
189
|
+
else:
|
|
190
|
+
lines.append(value or "_None recorded._")
|
|
191
|
+
lines.append("")
|
|
192
|
+
return lines
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _cell(value: str) -> str:
|
|
196
|
+
return (value or "").replace("|", "\\|").replace("\n", " ").strip()
|
scamscript/store.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Append-only JSONL request log under the scamscript home."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
RUN_ID_LENGTH = 12
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def utc_now() -> str:
|
|
17
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def new_run_id() -> str:
|
|
21
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
22
|
+
return f"{stamp}-{uuid.uuid4().hex[:RUN_ID_LENGTH]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunLogger:
|
|
26
|
+
"""Writes one JSONL file per run; never raises on log failure."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, logs_dir: str, command: str, run_id: str | None = None) -> None:
|
|
29
|
+
self.run_id = run_id or new_run_id()
|
|
30
|
+
self.logs_dir = Path(logs_dir)
|
|
31
|
+
self.command = command
|
|
32
|
+
self.path = self.logs_dir / f"{self.run_id}.jsonl"
|
|
33
|
+
self._started = False
|
|
34
|
+
|
|
35
|
+
def _write(self, record: dict[str, Any]) -> None:
|
|
36
|
+
record.setdefault("ts", utc_now())
|
|
37
|
+
record.setdefault("run_id", self.run_id)
|
|
38
|
+
try:
|
|
39
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
41
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def run_start(self, **fields: Any) -> None:
|
|
46
|
+
if self._started:
|
|
47
|
+
return
|
|
48
|
+
self._started = True
|
|
49
|
+
self._write({"event": "run_start", "command": self.command, **fields})
|
|
50
|
+
|
|
51
|
+
def request(self, **fields: Any) -> None:
|
|
52
|
+
self._write({"event": "request", **fields})
|
|
53
|
+
|
|
54
|
+
def result(self, **fields: Any) -> None:
|
|
55
|
+
self._write({"event": "result", **fields})
|
|
56
|
+
|
|
57
|
+
def error(self, **fields: Any) -> None:
|
|
58
|
+
self._write({"event": "error", **fields})
|
|
59
|
+
|
|
60
|
+
def run_end(self, **fields: Any) -> None:
|
|
61
|
+
self._write({"event": "run_end", **fields})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_records(path: Path) -> list[dict[str, Any]]:
|
|
65
|
+
records: list[dict[str, Any]] = []
|
|
66
|
+
try:
|
|
67
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
68
|
+
except OSError:
|
|
69
|
+
return records
|
|
70
|
+
for line in lines:
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
record = json.loads(line)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
continue
|
|
78
|
+
if isinstance(record, dict):
|
|
79
|
+
records.append(record)
|
|
80
|
+
return records
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def discover_runs(logs_dir: str) -> list[dict[str, Any]]:
|
|
84
|
+
"""Summarize every run file, newest first."""
|
|
85
|
+
directory = Path(logs_dir).expanduser()
|
|
86
|
+
if not directory.is_dir():
|
|
87
|
+
return []
|
|
88
|
+
runs: list[dict[str, Any]] = []
|
|
89
|
+
for path in sorted(directory.glob("*.jsonl"), key=_mtime, reverse=True):
|
|
90
|
+
records = read_records(path)
|
|
91
|
+
if not records:
|
|
92
|
+
continue
|
|
93
|
+
runs.append(summarize_run(path, records))
|
|
94
|
+
return runs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def summarize_run(path: Path, records: list[dict[str, Any]]) -> dict[str, Any]:
|
|
98
|
+
start = _first(records, "run_start")
|
|
99
|
+
end = _first(records, "run_end")
|
|
100
|
+
results = [r for r in records if r.get("event") == "result"]
|
|
101
|
+
errors = [r for r in records if r.get("event") == "error"]
|
|
102
|
+
sources = [r.get("source") for r in results + errors if r.get("source")]
|
|
103
|
+
return {
|
|
104
|
+
"run_id": start.get("run_id") if start else path.stem,
|
|
105
|
+
"path": str(path),
|
|
106
|
+
"command": start.get("command", "unknown") if start else "unknown",
|
|
107
|
+
"started": start.get("ts") if start else None,
|
|
108
|
+
"finished": end.get("ts") if end else None,
|
|
109
|
+
"status": (end or {}).get("status", "incomplete"),
|
|
110
|
+
"results": len(results),
|
|
111
|
+
"failures": len(errors),
|
|
112
|
+
"sources": sources,
|
|
113
|
+
"dry_run": bool(start.get("dry_run")) if start else False,
|
|
114
|
+
"model": (start or {}).get("model"),
|
|
115
|
+
"base_url": (start or {}).get("base_url"),
|
|
116
|
+
"records": records,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_run(logs_dir: str, run_id: str) -> dict[str, Any] | None:
|
|
121
|
+
path = Path(logs_dir).expanduser() / f"{run_id}.jsonl"
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return None
|
|
124
|
+
records = read_records(path)
|
|
125
|
+
if not records:
|
|
126
|
+
return None
|
|
127
|
+
return summarize_run(path, records)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def results_of(run: dict[str, Any]) -> list[dict[str, Any]]:
|
|
131
|
+
return [r for r in run.get("records", []) if r.get("event") == "result"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _first(records: list[dict[str, Any]], event: str) -> dict[str, Any] | None:
|
|
135
|
+
for record in records:
|
|
136
|
+
if record.get("event") == event:
|
|
137
|
+
return record
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _mtime(path: Path) -> float:
|
|
142
|
+
try:
|
|
143
|
+
return path.stat().st_mtime
|
|
144
|
+
except OSError:
|
|
145
|
+
return 0.0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ensure_home(home: str) -> None:
|
|
149
|
+
try:
|
|
150
|
+
os.makedirs(os.path.join(home, "logs"), exist_ok=True)
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def prune(
|
|
156
|
+
logs_dir: str,
|
|
157
|
+
keep_days: int | None = 30,
|
|
158
|
+
keep: int | None = 200,
|
|
159
|
+
) -> tuple[int, int, int]:
|
|
160
|
+
"""Delete old run logs. Returns (deleted, bytes_freed, kept)."""
|
|
161
|
+
directory = Path(logs_dir).expanduser()
|
|
162
|
+
if not directory.is_dir():
|
|
163
|
+
return 0, 0, 0
|
|
164
|
+
now = time.time()
|
|
165
|
+
files = sorted(
|
|
166
|
+
(path for path in directory.glob("*.jsonl") if path.is_file()),
|
|
167
|
+
key=_mtime,
|
|
168
|
+
reverse=True,
|
|
169
|
+
)
|
|
170
|
+
deleted = 0
|
|
171
|
+
freed = 0
|
|
172
|
+
kept = 0
|
|
173
|
+
for index, path in enumerate(files):
|
|
174
|
+
too_many = keep is not None and index >= keep
|
|
175
|
+
stale = keep_days is not None and (now - _mtime(path)) > keep_days * 86400
|
|
176
|
+
if not too_many and not stale:
|
|
177
|
+
kept += 1
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
size = path.stat().st_size
|
|
181
|
+
path.unlink()
|
|
182
|
+
except OSError:
|
|
183
|
+
kept += 1
|
|
184
|
+
continue
|
|
185
|
+
deleted += 1
|
|
186
|
+
freed += size
|
|
187
|
+
return deleted, freed, kept
|