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 ADDED
@@ -0,0 +1,3 @@
1
+ """scamscript: reverse-engineering analyzer for scam call scripts."""
2
+
3
+ __version__ = "0.2.0"
scamscript/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from scamscript.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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()