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/__init__.py
ADDED
scamscript/__main__.py
ADDED
scamscript/analysis.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Single-script analysis loop with one repair retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from scamscript import prompts, redact, verify
|
|
11
|
+
from scamscript.client import ChatClient, ClientError, TruncatedError
|
|
12
|
+
from scamscript.ingest import ScriptFile
|
|
13
|
+
from scamscript.models import BreakdownError, ScriptBreakdown, parse_breakdown
|
|
14
|
+
from scamscript.store import RunLogger
|
|
15
|
+
|
|
16
|
+
MAX_ATTEMPTS = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class Outcome:
|
|
21
|
+
source: Path
|
|
22
|
+
name: str
|
|
23
|
+
breakdown: ScriptBreakdown | None = None
|
|
24
|
+
raw: str = ""
|
|
25
|
+
attempts: int = 0
|
|
26
|
+
error: str | None = None
|
|
27
|
+
duration: float = 0.0
|
|
28
|
+
redactions: dict[str, int] | None = None
|
|
29
|
+
issues: list[verify.Issue] | None = None
|
|
30
|
+
verification: dict[str, int] | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
return self.breakdown is not None
|
|
35
|
+
|
|
36
|
+
def as_record(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"event": "result",
|
|
39
|
+
"source": str(self.source),
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"ok": self.ok,
|
|
42
|
+
"attempts": self.attempts,
|
|
43
|
+
"duration_s": round(self.duration, 3),
|
|
44
|
+
"breakdown": self.breakdown.model_dump() if self.breakdown else None,
|
|
45
|
+
"error": self.error,
|
|
46
|
+
"redactions": self.redactions or {},
|
|
47
|
+
"verification": self.verification or {},
|
|
48
|
+
"unverified": [
|
|
49
|
+
{"field": issue.field, "quote": issue.quote, "status": issue.status}
|
|
50
|
+
for issue in self.issues or []
|
|
51
|
+
if not issue.ok
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def analyze_script(
|
|
57
|
+
client: ChatClient,
|
|
58
|
+
script: ScriptFile,
|
|
59
|
+
logger: RunLogger,
|
|
60
|
+
*,
|
|
61
|
+
redact_identifiers: bool = False,
|
|
62
|
+
verify_quotes: bool = True,
|
|
63
|
+
) -> Outcome:
|
|
64
|
+
outcome = Outcome(source=script.path, name=script.name)
|
|
65
|
+
started = time.monotonic()
|
|
66
|
+
|
|
67
|
+
text = script.text
|
|
68
|
+
if redact_identifiers:
|
|
69
|
+
text, counts = redact.redact(text)
|
|
70
|
+
outcome.redactions = counts
|
|
71
|
+
|
|
72
|
+
messages = prompts.build_messages(script.name, text)
|
|
73
|
+
last_error = ""
|
|
74
|
+
|
|
75
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
76
|
+
outcome.attempts = attempt
|
|
77
|
+
logger.request(
|
|
78
|
+
source=str(script.path),
|
|
79
|
+
attempt=attempt,
|
|
80
|
+
model=client.model,
|
|
81
|
+
base_url=client.base_url,
|
|
82
|
+
message_count=len(messages),
|
|
83
|
+
prompt_chars=sum(len(m["content"]) for m in messages),
|
|
84
|
+
max_tokens=client.max_tokens,
|
|
85
|
+
redactions=outcome.redactions or {},
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
raw = client.complete(messages)
|
|
89
|
+
except TruncatedError as exc:
|
|
90
|
+
last_error = str(exc)
|
|
91
|
+
outcome.error = f"reply truncated: {last_error}"
|
|
92
|
+
outcome.raw = exc.partial
|
|
93
|
+
logger.error(
|
|
94
|
+
source=str(script.path),
|
|
95
|
+
attempt=attempt,
|
|
96
|
+
error=outcome.error,
|
|
97
|
+
partial=_maybe_redact(exc.partial, redact_identifiers),
|
|
98
|
+
)
|
|
99
|
+
if attempt < MAX_ATTEMPTS:
|
|
100
|
+
messages = prompts.fix_messages(
|
|
101
|
+
script.name, text, "reply was cut off before the JSON closed", exc.partial
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
except ClientError as exc:
|
|
105
|
+
outcome.error = str(exc)
|
|
106
|
+
logger.error(source=str(script.path), attempt=attempt, error=str(exc))
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
outcome.raw = raw
|
|
110
|
+
visible = _maybe_redact(raw, redact_identifiers)
|
|
111
|
+
try:
|
|
112
|
+
outcome.breakdown = parse_breakdown(visible)
|
|
113
|
+
outcome.error = None
|
|
114
|
+
if verify_quotes:
|
|
115
|
+
outcome.issues = verify.verify_breakdown(outcome.breakdown, text)
|
|
116
|
+
outcome.verification = verify.summarize(outcome.issues)
|
|
117
|
+
break
|
|
118
|
+
except BreakdownError as exc:
|
|
119
|
+
last_error = str(exc)
|
|
120
|
+
outcome.error = f"invalid model JSON: {last_error}"
|
|
121
|
+
logger.error(
|
|
122
|
+
source=str(script.path),
|
|
123
|
+
attempt=attempt,
|
|
124
|
+
error=outcome.error,
|
|
125
|
+
raw=visible[:2000],
|
|
126
|
+
)
|
|
127
|
+
if attempt < MAX_ATTEMPTS:
|
|
128
|
+
messages = prompts.fix_messages(script.name, text, last_error, raw)
|
|
129
|
+
|
|
130
|
+
outcome.duration = time.monotonic() - started
|
|
131
|
+
logger.result(**outcome.as_record())
|
|
132
|
+
return outcome
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _maybe_redact(text: str, enabled: bool) -> str:
|
|
136
|
+
if not enabled:
|
|
137
|
+
return text
|
|
138
|
+
return redact.redact(text)[0]
|
scamscript/cli.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from scamscript import __version__, prompts, redact, store
|
|
14
|
+
from scamscript import report as reporting
|
|
15
|
+
from scamscript.analysis import Outcome, analyze_script
|
|
16
|
+
from scamscript.client import ChatClient
|
|
17
|
+
from scamscript.config import (
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
DEFAULT_MAX_TOKENS,
|
|
20
|
+
DEFAULT_MODEL,
|
|
21
|
+
DEFAULT_RETRIES,
|
|
22
|
+
DEFAULT_TIMEOUT,
|
|
23
|
+
ConfigError,
|
|
24
|
+
Settings,
|
|
25
|
+
)
|
|
26
|
+
from scamscript.ingest import IngestError, ScriptFile, collect_scripts, read_script
|
|
27
|
+
from scamscript.render import (
|
|
28
|
+
render_breakdown,
|
|
29
|
+
render_failure,
|
|
30
|
+
render_prompt,
|
|
31
|
+
render_summary_table,
|
|
32
|
+
)
|
|
33
|
+
from scamscript.store import RunLogger
|
|
34
|
+
|
|
35
|
+
DISCLAIMER = (
|
|
36
|
+
"For security research and fraud-awareness training only. "
|
|
37
|
+
"Breakdowns are model-produced readings, not legal advice."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
add_completion=False,
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
rich_markup_mode="rich",
|
|
44
|
+
help="Reverse-engineer scam call scripts into structured, defensible breakdowns.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class Context:
|
|
50
|
+
settings: Settings
|
|
51
|
+
dry_run: bool
|
|
52
|
+
json_output: bool
|
|
53
|
+
verify_quotes: bool
|
|
54
|
+
strict_quotes: bool
|
|
55
|
+
console: Console
|
|
56
|
+
err_console: Console
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def version_callback(value: bool) -> None:
|
|
60
|
+
if value:
|
|
61
|
+
typer.echo(f"scamscript {__version__}")
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback()
|
|
66
|
+
def main(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
base_url: str = typer.Option(
|
|
69
|
+
DEFAULT_BASE_URL,
|
|
70
|
+
"--base-url",
|
|
71
|
+
envvar="SCAMSCRIPT_BASE_URL",
|
|
72
|
+
help="OpenAI-compatible API base URL.",
|
|
73
|
+
show_default=True,
|
|
74
|
+
),
|
|
75
|
+
model: str = typer.Option(
|
|
76
|
+
DEFAULT_MODEL,
|
|
77
|
+
"--model",
|
|
78
|
+
"-m",
|
|
79
|
+
envvar="SCAMSCRIPT_MODEL",
|
|
80
|
+
help="Model identifier sent to the API.",
|
|
81
|
+
show_default=True,
|
|
82
|
+
),
|
|
83
|
+
api_key: str | None = typer.Option(
|
|
84
|
+
None,
|
|
85
|
+
"--api-key",
|
|
86
|
+
envvar="API_KEY",
|
|
87
|
+
help="API key. Falls back to the API_KEY environment variable.",
|
|
88
|
+
),
|
|
89
|
+
home: Path | None = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--home",
|
|
92
|
+
envvar="SCAMSCRIPT_HOME",
|
|
93
|
+
help="State directory for logs. Defaults to ~/.scamscript.",
|
|
94
|
+
),
|
|
95
|
+
timeout: float = typer.Option(
|
|
96
|
+
DEFAULT_TIMEOUT, "--timeout", min=1.0, help="Per-request timeout in seconds."
|
|
97
|
+
),
|
|
98
|
+
retries: int = typer.Option(
|
|
99
|
+
DEFAULT_RETRIES,
|
|
100
|
+
"--retries",
|
|
101
|
+
min=0,
|
|
102
|
+
max=8,
|
|
103
|
+
help="Retries for transport and rate-limit failures.",
|
|
104
|
+
),
|
|
105
|
+
max_tokens: int = typer.Option(
|
|
106
|
+
DEFAULT_MAX_TOKENS,
|
|
107
|
+
"--max-tokens",
|
|
108
|
+
min=256,
|
|
109
|
+
help="Output token budget per reply.",
|
|
110
|
+
),
|
|
111
|
+
redact: bool = typer.Option(
|
|
112
|
+
False,
|
|
113
|
+
"--redact",
|
|
114
|
+
help="Mask likely identifiers before sending and before logging.",
|
|
115
|
+
),
|
|
116
|
+
verify_quotes: bool = typer.Option(
|
|
117
|
+
True,
|
|
118
|
+
"--verify/--no-verify",
|
|
119
|
+
help="Check quoted claims against the source script.",
|
|
120
|
+
),
|
|
121
|
+
strict_quotes: bool = typer.Option(
|
|
122
|
+
False,
|
|
123
|
+
"--strict-quotes",
|
|
124
|
+
help="Exit non-zero when a quoted claim is not found in the source.",
|
|
125
|
+
),
|
|
126
|
+
dry_run: bool = typer.Option(
|
|
127
|
+
False,
|
|
128
|
+
"--dry-run",
|
|
129
|
+
help="Assemble and print prompts without calling the API.",
|
|
130
|
+
),
|
|
131
|
+
json_output: bool = typer.Option(
|
|
132
|
+
False,
|
|
133
|
+
"--json",
|
|
134
|
+
help="Emit one JSON object per script on stdout.",
|
|
135
|
+
),
|
|
136
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors."),
|
|
137
|
+
version: bool = typer.Option(
|
|
138
|
+
False,
|
|
139
|
+
"--version",
|
|
140
|
+
callback=version_callback,
|
|
141
|
+
is_eager=True,
|
|
142
|
+
help="Show version and exit.",
|
|
143
|
+
),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Reverse-engineering analyzer for leaked and public scam scripts."""
|
|
146
|
+
settings = Settings(
|
|
147
|
+
base_url=base_url,
|
|
148
|
+
model=model,
|
|
149
|
+
api_key=api_key,
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
retries=retries,
|
|
152
|
+
max_tokens=max_tokens,
|
|
153
|
+
redact=redact,
|
|
154
|
+
home=str(home) if home else "",
|
|
155
|
+
)
|
|
156
|
+
ctx.obj = Context(
|
|
157
|
+
settings=settings,
|
|
158
|
+
dry_run=dry_run,
|
|
159
|
+
json_output=json_output,
|
|
160
|
+
verify_quotes=verify_quotes,
|
|
161
|
+
strict_quotes=strict_quotes,
|
|
162
|
+
console=Console(
|
|
163
|
+
no_color=no_color,
|
|
164
|
+
highlight=False,
|
|
165
|
+
soft_wrap=False,
|
|
166
|
+
stderr=json_output,
|
|
167
|
+
),
|
|
168
|
+
err_console=Console(no_color=no_color, highlight=False, stderr=True),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _context(ctx: typer.Context) -> Context:
|
|
173
|
+
state = ctx.obj
|
|
174
|
+
if not isinstance(state, Context):
|
|
175
|
+
raise typer.Exit(2)
|
|
176
|
+
return state
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _fail(console: Console, message: str) -> None:
|
|
180
|
+
console.print(f"[bold red]error[/bold red] {message}")
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _choose_sources(file: Path | None, directory: Path | None, batch: bool) -> bool:
|
|
185
|
+
if file is None and directory is None:
|
|
186
|
+
raise typer.BadParameter("pass --file for one script or --dir for a directory")
|
|
187
|
+
if file is not None and directory is not None:
|
|
188
|
+
raise typer.BadParameter("--file and --dir are mutually exclusive")
|
|
189
|
+
return directory is not None or batch
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_sources(
|
|
193
|
+
file: Path | None, directory: Path | None, recursive: bool
|
|
194
|
+
) -> list[ScriptFile]:
|
|
195
|
+
if file is not None:
|
|
196
|
+
return [read_script(file)]
|
|
197
|
+
assert directory is not None
|
|
198
|
+
return collect_scripts(directory, recursive=recursive)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run(
|
|
202
|
+
state: Context,
|
|
203
|
+
scripts: list[ScriptFile],
|
|
204
|
+
*,
|
|
205
|
+
command: str,
|
|
206
|
+
batch_mode: bool,
|
|
207
|
+
output: Path | None,
|
|
208
|
+
top: int | None,
|
|
209
|
+
) -> None:
|
|
210
|
+
console = state.console
|
|
211
|
+
settings = state.settings
|
|
212
|
+
logger = RunLogger(settings.logs_dir, command)
|
|
213
|
+
logger.run_start(
|
|
214
|
+
model=settings.model,
|
|
215
|
+
base_url=settings.base_url,
|
|
216
|
+
dry_run=state.dry_run,
|
|
217
|
+
command=command,
|
|
218
|
+
sources=[str(script.path) for script in scripts],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if state.dry_run:
|
|
222
|
+
for script in scripts:
|
|
223
|
+
text, counts = _maybe_redact(script.text, settings.redact)
|
|
224
|
+
messages = prompts.build_messages(script.name, text)
|
|
225
|
+
logger.request(
|
|
226
|
+
source=str(script.path),
|
|
227
|
+
attempt=1,
|
|
228
|
+
dry_run=True,
|
|
229
|
+
prompt_chars=sum(len(m["content"]) for m in messages),
|
|
230
|
+
redactions=counts,
|
|
231
|
+
)
|
|
232
|
+
if state.json_output:
|
|
233
|
+
_emit_json(
|
|
234
|
+
{
|
|
235
|
+
"event": "prompt",
|
|
236
|
+
"name": script.name,
|
|
237
|
+
"source": str(script.path),
|
|
238
|
+
"messages": messages,
|
|
239
|
+
"redactions": counts,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
console.print(render_prompt(script, messages))
|
|
244
|
+
logger.run_end(status="dry-run", results=0, failures=0)
|
|
245
|
+
console.print(f"[dim]dry run logged to {logger.path}[/dim]")
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
api_key = settings.require_api_key()
|
|
250
|
+
except ConfigError as exc:
|
|
251
|
+
_fail(console, str(exc))
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
outcomes: list[Outcome] = []
|
|
255
|
+
report_entries: list[reporting.ReportEntry] = []
|
|
256
|
+
client = ChatClient(
|
|
257
|
+
api_key=api_key,
|
|
258
|
+
base_url=settings.base_url,
|
|
259
|
+
model=settings.model,
|
|
260
|
+
timeout=settings.timeout,
|
|
261
|
+
retries=settings.retries,
|
|
262
|
+
max_tokens=settings.max_tokens,
|
|
263
|
+
)
|
|
264
|
+
with client:
|
|
265
|
+
for index, script in enumerate(scripts, start=1):
|
|
266
|
+
if batch_mode and not state.json_output:
|
|
267
|
+
console.print(
|
|
268
|
+
f"[dim]({index}/{len(scripts)}) analyzing {script.name}...[/dim]"
|
|
269
|
+
)
|
|
270
|
+
outcome = analyze_script(
|
|
271
|
+
client,
|
|
272
|
+
script,
|
|
273
|
+
logger,
|
|
274
|
+
redact_identifiers=settings.redact,
|
|
275
|
+
verify_quotes=state.verify_quotes,
|
|
276
|
+
)
|
|
277
|
+
outcomes.append(outcome)
|
|
278
|
+
report_entries.append(reporting.entry_from_outcome(outcome))
|
|
279
|
+
if state.json_output:
|
|
280
|
+
_emit_json(outcome.as_record())
|
|
281
|
+
elif outcome.breakdown is not None:
|
|
282
|
+
console.print(
|
|
283
|
+
render_breakdown(
|
|
284
|
+
outcome.breakdown,
|
|
285
|
+
outcome.name,
|
|
286
|
+
outcome.attempts,
|
|
287
|
+
outcome.issues,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
console.print(
|
|
292
|
+
render_failure(outcome.name, outcome.error or "unknown", outcome.attempts)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
successes = sum(1 for outcome in outcomes if outcome.ok)
|
|
296
|
+
failures = len(outcomes) - successes
|
|
297
|
+
unverified = sum(
|
|
298
|
+
1 for outcome in outcomes for issue in (outcome.issues or []) if not issue.ok
|
|
299
|
+
)
|
|
300
|
+
if state.strict_quotes and unverified:
|
|
301
|
+
failures += 0
|
|
302
|
+
logger.run_end(status="ok" if failures == 0 else "partial", results=successes, failures=failures)
|
|
303
|
+
|
|
304
|
+
if batch_mode and successes:
|
|
305
|
+
if not state.json_output:
|
|
306
|
+
console.print(
|
|
307
|
+
render_summary_table([(o.name, o.breakdown, o.error) for o in outcomes])
|
|
308
|
+
)
|
|
309
|
+
ranked = reporting.rank(report_entries)
|
|
310
|
+
if top:
|
|
311
|
+
ranked = ranked[:top]
|
|
312
|
+
markdown = reporting.build_markdown(
|
|
313
|
+
ranked,
|
|
314
|
+
run_id=logger.run_id,
|
|
315
|
+
command=command,
|
|
316
|
+
model=settings.model,
|
|
317
|
+
)
|
|
318
|
+
written = _emit_report(console, markdown, output, json_mode=state.json_output)
|
|
319
|
+
if state.json_output:
|
|
320
|
+
payload: dict[str, object] = {
|
|
321
|
+
"event": "report",
|
|
322
|
+
"run_id": logger.run_id,
|
|
323
|
+
"path": str(written) if written else None,
|
|
324
|
+
}
|
|
325
|
+
if written is None:
|
|
326
|
+
payload["markdown"] = markdown
|
|
327
|
+
_emit_json(payload)
|
|
328
|
+
console.print(f"[dim]log: {logger.path}[/dim]")
|
|
329
|
+
|
|
330
|
+
if unverified and not state.json_output:
|
|
331
|
+
console.print(
|
|
332
|
+
f"[yellow]{unverified} quoted claim(s) not found verbatim in the source[/yellow]"
|
|
333
|
+
)
|
|
334
|
+
if failures:
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
if state.strict_quotes and unverified:
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _emit_report(
|
|
341
|
+
console: Console, markdown: str, output: Path | None, json_mode: bool = False
|
|
342
|
+
) -> Path | None:
|
|
343
|
+
if output is None:
|
|
344
|
+
if not json_mode:
|
|
345
|
+
typer.echo(markdown)
|
|
346
|
+
return None
|
|
347
|
+
path = output.expanduser()
|
|
348
|
+
try:
|
|
349
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
path.write_text(markdown, encoding="utf-8")
|
|
351
|
+
except OSError as exc:
|
|
352
|
+
_fail(console, f"cannot write report to {path}: {exc}")
|
|
353
|
+
return None
|
|
354
|
+
console.print(f"[green]report written[/green] {path}")
|
|
355
|
+
return path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _emit_json(payload: dict[str, object]) -> None:
|
|
359
|
+
typer.echo(json.dumps(payload, ensure_ascii=False))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _maybe_redact(text: str, enabled: bool) -> tuple[str, dict[str, int]]:
|
|
363
|
+
if not enabled:
|
|
364
|
+
return text, {}
|
|
365
|
+
return redact.redact(text)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.command()
|
|
369
|
+
def analyze(
|
|
370
|
+
ctx: typer.Context,
|
|
371
|
+
file: Path | None = typer.Option(None, "--file", "-f", help="Path to one script file."),
|
|
372
|
+
directory: Path | None = typer.Option(
|
|
373
|
+
None, "--dir", "-d", help="Directory of script files."
|
|
374
|
+
),
|
|
375
|
+
batch: bool = typer.Option(
|
|
376
|
+
False, "--batch", help="Treat --dir as a batch and emit an aggregated report."
|
|
377
|
+
),
|
|
378
|
+
output: Path | None = typer.Option(
|
|
379
|
+
None, "--output", "-o", help="Write the aggregated markdown report here."
|
|
380
|
+
),
|
|
381
|
+
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
382
|
+
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Analyze a single script file, or a directory with --batch."""
|
|
385
|
+
state = _context(ctx)
|
|
386
|
+
try:
|
|
387
|
+
batch_mode = _choose_sources(file, directory, batch)
|
|
388
|
+
scripts = _load_sources(file, directory, recursive)
|
|
389
|
+
except typer.BadParameter as exc:
|
|
390
|
+
_fail(state.console, str(exc.message))
|
|
391
|
+
return
|
|
392
|
+
except IngestError as exc:
|
|
393
|
+
_fail(state.console, str(exc))
|
|
394
|
+
return
|
|
395
|
+
_run(state, scripts, command="analyze", batch_mode=batch_mode, output=output, top=top)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.command()
|
|
399
|
+
def batch(
|
|
400
|
+
ctx: typer.Context,
|
|
401
|
+
directory: Path = typer.Argument(..., help="Directory of script files."),
|
|
402
|
+
output: Path | None = typer.Option(
|
|
403
|
+
None, "--output", "-o", help="Write the aggregated markdown report here."
|
|
404
|
+
),
|
|
405
|
+
recursive: bool = typer.Option(False, "--recursive", "-r", help="Recurse into subdirectories."),
|
|
406
|
+
top: int | None = typer.Option(None, "--top", min=1, help="Keep only the N most sophisticated scripts."),
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Analyze a directory and rank every script by sophistication."""
|
|
409
|
+
state = _context(ctx)
|
|
410
|
+
try:
|
|
411
|
+
scripts = collect_scripts(directory, recursive=recursive)
|
|
412
|
+
except IngestError as exc:
|
|
413
|
+
_fail(state.console, str(exc))
|
|
414
|
+
return
|
|
415
|
+
_run(state, scripts, command="batch", batch_mode=True, output=output, top=top)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.command()
|
|
419
|
+
def report( # noqa: A001
|
|
420
|
+
ctx: typer.Context,
|
|
421
|
+
run_id: str | None = typer.Option(
|
|
422
|
+
None, "--run-id", help="Run to replay. Defaults to the newest run with results."
|
|
423
|
+
),
|
|
424
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write the markdown here."),
|
|
425
|
+
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Regenerate the aggregated report from a past run's log."""
|
|
428
|
+
state = _context(ctx)
|
|
429
|
+
directory = str(logs_dir.expanduser()) if logs_dir else state.settings.logs_dir
|
|
430
|
+
|
|
431
|
+
if run_id is None:
|
|
432
|
+
runs = [run for run in store.discover_runs(directory) if run["results"]]
|
|
433
|
+
if not runs:
|
|
434
|
+
_fail(state.console, f"no logged runs with results in {directory}")
|
|
435
|
+
return
|
|
436
|
+
run = runs[0]
|
|
437
|
+
else:
|
|
438
|
+
run = store.load_run(directory, run_id)
|
|
439
|
+
if run is None:
|
|
440
|
+
_fail(state.console, f"run {run_id} not found in {directory}")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
entries = reporting.entries_from_run(run)
|
|
444
|
+
if not entries:
|
|
445
|
+
_fail(state.console, f"run {run['run_id']} has no stored results")
|
|
446
|
+
return
|
|
447
|
+
markdown = reporting.build_markdown(
|
|
448
|
+
entries,
|
|
449
|
+
run_id=str(run["run_id"]),
|
|
450
|
+
command=str(run.get("command") or "report"),
|
|
451
|
+
model=run.get("model"),
|
|
452
|
+
)
|
|
453
|
+
_emit_report(state.console, markdown, output)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@app.command()
|
|
457
|
+
def history(
|
|
458
|
+
ctx: typer.Context,
|
|
459
|
+
limit: int = typer.Option(20, "--limit", "-n", min=1, help="How many runs to list."),
|
|
460
|
+
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
461
|
+
) -> None:
|
|
462
|
+
"""List previous runs from the log store."""
|
|
463
|
+
state = _context(ctx)
|
|
464
|
+
directory = str(logs_dir.expanduser()) if logs_dir else state.settings.logs_dir
|
|
465
|
+
runs = store.discover_runs(directory)
|
|
466
|
+
if not runs:
|
|
467
|
+
state.console.print(f"[dim]no runs logged in {directory}[/dim]")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
table = Table(title=f"Previous runs ({directory})", header_style="bold")
|
|
471
|
+
table.add_column("Run")
|
|
472
|
+
table.add_column("Started")
|
|
473
|
+
table.add_column("Command")
|
|
474
|
+
table.add_column("Results", justify="right")
|
|
475
|
+
table.add_column("Failures", justify="right")
|
|
476
|
+
table.add_column("Status")
|
|
477
|
+
for run in runs[:limit]:
|
|
478
|
+
table.add_row(
|
|
479
|
+
str(run["run_id"]),
|
|
480
|
+
str(run["started"] or "-"),
|
|
481
|
+
str(run["command"]),
|
|
482
|
+
str(run["results"]),
|
|
483
|
+
str(run["failures"]),
|
|
484
|
+
str(run["status"]),
|
|
485
|
+
)
|
|
486
|
+
state.console.print(table)
|
|
487
|
+
state.console.print(
|
|
488
|
+
f"[dim]{len(runs)} run(s) total; regenerate one with "
|
|
489
|
+
f"`scamscript report --run-id <id>`[/dim]"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@app.command()
|
|
494
|
+
def prune(
|
|
495
|
+
ctx: typer.Context,
|
|
496
|
+
keep_days: int | None = typer.Option(
|
|
497
|
+
30, "--keep-days", min=0, help="Delete runs older than this many days. 0 disables the age rule."
|
|
498
|
+
),
|
|
499
|
+
keep: int | None = typer.Option(
|
|
500
|
+
200, "--keep", min=0, help="Always keep at least the newest N runs. 0 disables the count rule."
|
|
501
|
+
),
|
|
502
|
+
logs_dir: Path | None = typer.Option(None, "--logs-dir", help="Override the log directory."),
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Delete old run logs, keeping the newest runs and recent history."""
|
|
505
|
+
state = _context(ctx)
|
|
506
|
+
if not keep_days and not keep:
|
|
507
|
+
_fail(state.console, "both --keep-days and --keep are 0; nothing to do")
|
|
508
|
+
return
|
|
509
|
+
directory = str(logs_dir.expanduser()) if logs_dir else state.settings.logs_dir
|
|
510
|
+
deleted, freed, kept = store.prune(
|
|
511
|
+
directory,
|
|
512
|
+
keep_days=keep_days or None,
|
|
513
|
+
keep=keep or None,
|
|
514
|
+
)
|
|
515
|
+
state.console.print(
|
|
516
|
+
f"[green]pruned[/green] {deleted} run(s), freed {freed / 1024:.1f} KiB, kept {kept}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
if __name__ == "__main__":
|
|
521
|
+
app()
|