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/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