scamscript 0.4.1__tar.gz → 0.5.0__tar.gz
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-0.4.1 → scamscript-0.5.0}/CHANGELOG.md +12 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/PKG-INFO +17 -3
- {scamscript-0.4.1 → scamscript-0.5.0}/README.md +16 -2
- {scamscript-0.4.1 → scamscript-0.5.0}/pyproject.toml +1 -1
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/__init__.py +1 -1
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/analysis.py +8 -2
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/cli.py +32 -5
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/client.py +1 -1
- scamscript-0.5.0/src/scamscript/dedupe.py +29 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/report.py +36 -3
- scamscript-0.5.0/tests/fixtures/reprints/copy-header.txt +4 -0
- scamscript-0.5.0/tests/fixtures/reprints/copy-punctuation.txt +11 -0
- scamscript-0.5.0/tests/fixtures/reprints/near-twin.txt +5 -0
- scamscript-0.5.0/tests/fixtures/reprints/original.txt +4 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_cli.py +47 -1
- scamscript-0.5.0/tests/test_dedupe.py +60 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/SECURITY.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/pull_request_template.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/workflows/ci.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.github/workflows/release.yml +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/.gitignore +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/CITATION.cff +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/CONTRIBUTING.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/LICENSE +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/docs/banner.svg +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/docs/calibration.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/docs/demo.gif +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/docs/sample-report.md +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/docs/social-preview.png +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fake_output_json.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fence_escape.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fixed_attack_type.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/non_json_demand.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/print_system_prompt.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/role_switch.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/score_override.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/crude_robocall.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/gov_impersonation_vishing.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/prompt_injection_script.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/romance_scam_sms.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/sophisticated_wire_fraud.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/examples/tech_support_scam.txt +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/scripts/calibrate.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/scripts/injection_eval.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/__main__.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/config.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/ingest.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/models.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/prompts.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/redact.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/render.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/store.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/verify.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_analysis.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_client.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_ingest.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_models.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_prompts.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_redact.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_render.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_report.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_store.py +0 -0
- {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_verify.py +0 -0
|
@@ -5,6 +5,18 @@ schema are the things that count as public API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.5.0 - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Content fingerprints and duplicate folding: reprints collapse to the strongest copy in reports,
|
|
13
|
+
with a folded count and `duplicate_of` in JSON.
|
|
14
|
+
- `--dedupe/--no-dedupe` (on by default) and `--show-duplicates` to render every panel.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Duplicate panels are replaced by a one-line pointer unless `--show-duplicates` is set.
|
|
19
|
+
|
|
8
20
|
## 0.4.1 - 2026-11-02
|
|
9
21
|
|
|
10
22
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scamscript
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks
|
|
5
5
|
Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/scamscript
|
|
6
6
|
Project-URL: Repository, https://github.com/sjdsjadjajdjsa/scamscript
|
|
@@ -133,7 +133,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
133
133
|
| `prune` | Delete old logs |
|
|
134
134
|
|
|
135
135
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
136
|
-
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-
|
|
136
|
+
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
137
|
+
`--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
137
138
|
Rich output on stderr, so it pipes:
|
|
138
139
|
|
|
139
140
|
```bash
|
|
@@ -154,6 +155,19 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
154
155
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
155
156
|
up to the concurrency, so keep headroom on a hard limit.
|
|
156
157
|
|
|
158
|
+
## Duplicates
|
|
159
|
+
|
|
160
|
+
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
161
|
+
and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
|
|
162
|
+
under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
scamscript batch corpus/ --output report.md # folds by default
|
|
166
|
+
scamscript batch corpus/ --no-dedupe --show-duplicates
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Only byte-level twins fold. A copy with one changed account number stays separate, by design.
|
|
170
|
+
|
|
157
171
|
## How it works
|
|
158
172
|
|
|
159
173
|
```text
|
|
@@ -262,7 +276,7 @@ docs/ calibration record, sample report, banner, demo
|
|
|
262
276
|
- Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
|
|
263
277
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
264
278
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
265
|
-
-
|
|
279
|
+
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
266
280
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
267
281
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
268
282
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -97,7 +97,8 @@ corpus is at [`docs/sample-report.md`](https://github.com/sjdsjadjajdjsa/scamscr
|
|
|
97
97
|
| `prune` | Delete old logs |
|
|
98
98
|
|
|
99
99
|
Useful flags: `--dry-run`, `--json`, `--redact`, `--top N`, `--recursive`, `--timeout`,
|
|
100
|
-
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--no-
|
|
100
|
+
`--retries`, `--max-tokens`, `--concurrency`, `--max-cost`, `--dedupe/--no-dedupe`,
|
|
101
|
+
`--show-duplicates`, `--no-color`. `--json` puts one object per script on stdout and all
|
|
101
102
|
Rich output on stderr, so it pipes:
|
|
102
103
|
|
|
103
104
|
```bash
|
|
@@ -118,6 +119,19 @@ The cap reads `usage.cost` from the response, so it works only where the provide
|
|
|
118
119
|
says so when none is sent. Spend is printed and logged per run. In-flight requests can overshoot by
|
|
119
120
|
up to the concurrency, so keep headroom on a hard limit.
|
|
120
121
|
|
|
122
|
+
## Duplicates
|
|
123
|
+
|
|
124
|
+
Corpora are full of reprints. Each script is fingerprinted on its body: headers, casing, punctuation
|
|
125
|
+
and whitespace are ignored, so copies collapse. The strongest copy is ranked and the rest are listed
|
|
126
|
+
under it; the report counts what was folded and JSON records carry `fingerprint` and `duplicate_of`.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
scamscript batch corpus/ --output report.md # folds by default
|
|
130
|
+
scamscript batch corpus/ --no-dedupe --show-duplicates
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Only byte-level twins fold. A copy with one changed account number stays separate, by design.
|
|
134
|
+
|
|
121
135
|
## How it works
|
|
122
136
|
|
|
123
137
|
```text
|
|
@@ -226,7 +240,7 @@ docs/ calibration record, sample report, banner, demo
|
|
|
226
240
|
- Breakdowns are model readings; a bad prompt or a weak model produces confident nonsense. Read
|
|
227
241
|
`opening_hook` and `red_flags` against the source before quoting anything.
|
|
228
242
|
- Redaction is regex-based and will miss names and identifiers without a fixed shape.
|
|
229
|
-
-
|
|
243
|
+
- Duplicate folding ignores formatting only; a single changed digit keeps a copy separate.
|
|
230
244
|
- The cost cap depends on provider-reported usage and can overshoot by the in-flight count.
|
|
231
245
|
- Analysis is English-centric: the prompt and schema assume English artifacts.
|
|
232
246
|
- Quote checking compares text, not meaning. A legitimate paraphrase left in quotes is flagged;
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scamscript"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Reverse-engineer scam scripts and vishing transcripts into structured counter-playbooks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from scamscript import prompts, redact, verify
|
|
10
|
+
from scamscript import dedupe, prompts, redact, verify
|
|
11
11
|
from scamscript.client import ChatClient, ClientError, TruncatedError
|
|
12
12
|
from scamscript.ingest import ScriptFile
|
|
13
13
|
from scamscript.models import BreakdownError, ScriptBreakdown, parse_breakdown
|
|
@@ -27,6 +27,8 @@ class Outcome:
|
|
|
27
27
|
duration: float = 0.0
|
|
28
28
|
redactions: dict[str, int] | None = None
|
|
29
29
|
skipped: bool = False
|
|
30
|
+
fingerprint: str = ""
|
|
31
|
+
duplicate_of: str | None = None
|
|
30
32
|
issues: list[verify.Issue] | None = None
|
|
31
33
|
verification: dict[str, int] | None = None
|
|
32
34
|
|
|
@@ -41,6 +43,8 @@ class Outcome:
|
|
|
41
43
|
"name": self.name,
|
|
42
44
|
"ok": self.ok,
|
|
43
45
|
"skipped": self.skipped,
|
|
46
|
+
"fingerprint": self.fingerprint,
|
|
47
|
+
"duplicate_of": self.duplicate_of,
|
|
44
48
|
"attempts": self.attempts,
|
|
45
49
|
"duration_s": round(self.duration, 3),
|
|
46
50
|
"breakdown": self.breakdown.model_dump() if self.breakdown else None,
|
|
@@ -63,7 +67,9 @@ def analyze_script(
|
|
|
63
67
|
redact_identifiers: bool = False,
|
|
64
68
|
verify_quotes: bool = True,
|
|
65
69
|
) -> Outcome:
|
|
66
|
-
outcome = Outcome(
|
|
70
|
+
outcome = Outcome(
|
|
71
|
+
source=script.path, name=script.name, fingerprint=dedupe.fingerprint(script.text)
|
|
72
|
+
)
|
|
67
73
|
started = time.monotonic()
|
|
68
74
|
|
|
69
75
|
text = script.text
|
|
@@ -52,6 +52,8 @@ class Context:
|
|
|
52
52
|
json_output: bool
|
|
53
53
|
verify_quotes: bool
|
|
54
54
|
strict_quotes: bool
|
|
55
|
+
dedupe: bool
|
|
56
|
+
show_duplicates: bool
|
|
55
57
|
console: Console
|
|
56
58
|
err_console: Console
|
|
57
59
|
|
|
@@ -135,6 +137,16 @@ def main(
|
|
|
135
137
|
"--strict-quotes",
|
|
136
138
|
help="Exit non-zero when a quoted claim is not found in the source.",
|
|
137
139
|
),
|
|
140
|
+
dedupe: bool = typer.Option(
|
|
141
|
+
True,
|
|
142
|
+
"--dedupe/--no-dedupe",
|
|
143
|
+
help="Fold identical scripts into the strongest copy in reports.",
|
|
144
|
+
),
|
|
145
|
+
show_duplicates: bool = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--show-duplicates",
|
|
148
|
+
help="Render a full panel for duplicate copies too.",
|
|
149
|
+
),
|
|
138
150
|
dry_run: bool = typer.Option(
|
|
139
151
|
False,
|
|
140
152
|
"--dry-run",
|
|
@@ -173,6 +185,8 @@ def main(
|
|
|
173
185
|
json_output=json_output,
|
|
174
186
|
verify_quotes=verify_quotes,
|
|
175
187
|
strict_quotes=strict_quotes,
|
|
188
|
+
dedupe=dedupe,
|
|
189
|
+
show_duplicates=show_duplicates,
|
|
176
190
|
console=Console(
|
|
177
191
|
no_color=no_color,
|
|
178
192
|
highlight=False,
|
|
@@ -278,6 +292,7 @@ def _run(
|
|
|
278
292
|
spent = 0.0
|
|
279
293
|
spent_seen = False
|
|
280
294
|
lock = threading.Lock()
|
|
295
|
+
seen: dict[str, str] = {}
|
|
281
296
|
client = ChatClient(
|
|
282
297
|
api_key=api_key,
|
|
283
298
|
base_url=settings.base_url,
|
|
@@ -308,6 +323,11 @@ def _run(
|
|
|
308
323
|
redact_identifiers=settings.redact,
|
|
309
324
|
verify_quotes=state.verify_quotes,
|
|
310
325
|
)
|
|
326
|
+
with lock:
|
|
327
|
+
if state.dedupe and outcome.fingerprint:
|
|
328
|
+
outcome.duplicate_of = seen.setdefault(outcome.fingerprint, outcome.name)
|
|
329
|
+
if outcome.duplicate_of == outcome.name:
|
|
330
|
+
outcome.duplicate_of = None
|
|
311
331
|
cost = client.last_cost
|
|
312
332
|
if cost:
|
|
313
333
|
with lock:
|
|
@@ -320,6 +340,10 @@ def _run(
|
|
|
320
340
|
_emit_json(outcome.as_record())
|
|
321
341
|
elif outcome.skipped:
|
|
322
342
|
console.print(f"[dim]skipped {outcome.name}: budget reached[/dim]")
|
|
343
|
+
elif outcome.duplicate_of and not state.show_duplicates:
|
|
344
|
+
console.print(
|
|
345
|
+
f"[dim]{outcome.name}: duplicate of {outcome.duplicate_of}[/dim]"
|
|
346
|
+
)
|
|
323
347
|
elif outcome.breakdown is not None:
|
|
324
348
|
console.print(
|
|
325
349
|
render_breakdown(
|
|
@@ -388,14 +412,17 @@ def _run(
|
|
|
388
412
|
cost_usd=round(spent, 6),
|
|
389
413
|
)
|
|
390
414
|
|
|
415
|
+
collapsed = reporting.collapse(report_entries) if state.dedupe else report_entries
|
|
416
|
+
|
|
391
417
|
if batch_mode and (successes or skipped):
|
|
392
418
|
if not state.json_output:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
)
|
|
419
|
+
rows = (
|
|
420
|
+
[(e.name, e.breakdown, e.error, e.skipped) for e in collapsed]
|
|
421
|
+
if state.dedupe
|
|
422
|
+
else [(o.name, o.breakdown, o.error, o.skipped) for o in finished]
|
|
397
423
|
)
|
|
398
|
-
|
|
424
|
+
console.print(render_summary_table(rows))
|
|
425
|
+
ranked = reporting.rank(collapsed)
|
|
399
426
|
if top:
|
|
400
427
|
ranked = ranked[:top]
|
|
401
428
|
markdown = reporting.build_markdown(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Content fingerprints for collapsing reprinted scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
NON_WORD = re.compile(r"[^a-z0-9]+")
|
|
9
|
+
HEADER_LINE = re.compile(r"^\s*#")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def body(text: str) -> str:
|
|
13
|
+
"""Drop the leading comment block a captured script usually carries."""
|
|
14
|
+
lines = text.splitlines()
|
|
15
|
+
index = 0
|
|
16
|
+
while index < len(lines) and (not lines[index].strip() or HEADER_LINE.match(lines[index])):
|
|
17
|
+
index += 1
|
|
18
|
+
return "\n".join(lines[index:])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(text: str) -> str:
|
|
22
|
+
lowered = body(text).lower()
|
|
23
|
+
return " ".join(NON_WORD.sub(" ", lowered).split())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fingerprint(text: str) -> str:
|
|
27
|
+
"""Short hash of the normalized body, stable across whitespace and punctuation."""
|
|
28
|
+
normalized = normalize(text)
|
|
29
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
@@ -30,6 +30,8 @@ class ReportEntry:
|
|
|
30
30
|
error: str | None = None
|
|
31
31
|
duration: float = 0.0
|
|
32
32
|
skipped: bool = False
|
|
33
|
+
fingerprint: str = ""
|
|
34
|
+
duplicates: list[str] | None = None
|
|
33
35
|
verification: dict[str, int] | None = None
|
|
34
36
|
unverified: list[dict[str, str]] | None = None
|
|
35
37
|
|
|
@@ -44,6 +46,7 @@ def entry_from_outcome(outcome: Any) -> ReportEntry:
|
|
|
44
46
|
source=str(getattr(outcome, "source", outcome.name)),
|
|
45
47
|
breakdown=outcome.breakdown,
|
|
46
48
|
skipped=getattr(outcome, "skipped", False),
|
|
49
|
+
fingerprint=getattr(outcome, "fingerprint", ""),
|
|
47
50
|
error=outcome.error,
|
|
48
51
|
duration=getattr(outcome, "duration", 0.0),
|
|
49
52
|
verification=getattr(outcome, "verification", None),
|
|
@@ -71,6 +74,8 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
71
74
|
source=record.get("source") or "",
|
|
72
75
|
breakdown=breakdown,
|
|
73
76
|
skipped=bool(record.get("skipped")),
|
|
77
|
+
fingerprint=record.get("fingerprint") or "",
|
|
78
|
+
duplicates=record.get("duplicates") or None,
|
|
74
79
|
error=None if breakdown else record.get("error") or "no breakdown stored",
|
|
75
80
|
duration=float(record.get("duration_s") or 0.0),
|
|
76
81
|
verification=record.get("verification") or None,
|
|
@@ -80,6 +85,29 @@ def entries_from_run(run: dict[str, Any]) -> list[ReportEntry]:
|
|
|
80
85
|
return entries
|
|
81
86
|
|
|
82
87
|
|
|
88
|
+
def collapse(entries: list[ReportEntry]) -> list[ReportEntry]:
|
|
89
|
+
"""Keep the strongest copy of each fingerprint and list the rest under it."""
|
|
90
|
+
groups: dict[str, list[ReportEntry]] = {}
|
|
91
|
+
for entry in entries:
|
|
92
|
+
if entry.ok and entry.fingerprint:
|
|
93
|
+
groups.setdefault(entry.fingerprint, []).append(entry)
|
|
94
|
+
|
|
95
|
+
primaries: dict[str, ReportEntry] = {}
|
|
96
|
+
for key, group in groups.items():
|
|
97
|
+
if len(group) > 1:
|
|
98
|
+
best = max(group, key=lambda item: (item.breakdown.sophistication_score, item.breakdown.flag_count))
|
|
99
|
+
best.duplicates = [item.name for item in group if item is not best]
|
|
100
|
+
primaries[key] = best
|
|
101
|
+
else:
|
|
102
|
+
primaries[key] = group[0]
|
|
103
|
+
|
|
104
|
+
return [
|
|
105
|
+
primaries[entry.fingerprint] if entry.ok and entry.fingerprint else entry
|
|
106
|
+
for entry in entries
|
|
107
|
+
if not (entry.ok and entry.fingerprint and primaries[entry.fingerprint] is not entry)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
83
111
|
def rank(entries: list[ReportEntry]) -> list[ReportEntry]:
|
|
84
112
|
return sorted(
|
|
85
113
|
entries,
|
|
@@ -118,6 +146,9 @@ def build_markdown(
|
|
|
118
146
|
lines.append(f"- Failed: {len(failed)}")
|
|
119
147
|
if skipped:
|
|
120
148
|
lines.append(f"- Skipped at budget: {len(skipped)}")
|
|
149
|
+
folded = sum(len(entry.duplicates or []) for entry in ok)
|
|
150
|
+
if folded:
|
|
151
|
+
lines.append(f"- Duplicate copies folded: {folded}")
|
|
121
152
|
unverified_total = sum(len(entry.unverified or []) for entry in ok)
|
|
122
153
|
if unverified_total:
|
|
123
154
|
lines.append(f"- Unverified quotes: {unverified_total}")
|
|
@@ -128,13 +159,13 @@ def build_markdown(
|
|
|
128
159
|
if ok:
|
|
129
160
|
lines.append("## Ranking by sophistication")
|
|
130
161
|
lines.append("")
|
|
131
|
-
lines.append("| Rank | Script | Attack type | Sophistication | Red flags | Unverified |")
|
|
132
|
-
lines.append("| ---: | --- | --- | ---: | ---: | ---: |")
|
|
162
|
+
lines.append("| Rank | Script | Attack type | Sophistication | Red flags | Unverified | Copies |")
|
|
163
|
+
lines.append("| ---: | --- | --- | ---: | ---: | ---: | ---: |")
|
|
133
164
|
for position, entry in enumerate(ok, start=1):
|
|
134
165
|
lines.append(
|
|
135
166
|
f"| {position} | {_cell(entry.name)} | {_cell(entry.breakdown.attack_type)} | "
|
|
136
167
|
f"{entry.breakdown.sophistication_score}/10 | {entry.breakdown.flag_count} | "
|
|
137
|
-
f"{len(entry.unverified or [])} |"
|
|
168
|
+
f"{len(entry.unverified or [])} | {1 + len(entry.duplicates or [])} |"
|
|
138
169
|
)
|
|
139
170
|
lines.append("")
|
|
140
171
|
for position, entry in enumerate(ok, start=1):
|
|
@@ -174,6 +205,8 @@ def _detail_section(position: int, entry: ReportEntry) -> list[str]:
|
|
|
174
205
|
lines.append(f"- **Attack type:** {breakdown.attack_type}")
|
|
175
206
|
lines.append(f"- **Sophistication:** {breakdown.sophistication_score}/10")
|
|
176
207
|
lines.append(f"- **Red flags observed:** {breakdown.flag_count}")
|
|
208
|
+
if entry.duplicates:
|
|
209
|
+
lines.append(f"- **Identical copies folded:** {len(entry.duplicates)} ({', '.join(_cell(name) for name in entry.duplicates)})")
|
|
177
210
|
counts = entry.verification or {}
|
|
178
211
|
if counts.get("checked"):
|
|
179
212
|
lines.append(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# second copy, reformatted
|
|
2
|
+
|
|
3
|
+
# captured 2026-10-02, first copy
|
|
4
|
+
|
|
5
|
+
Caller — Good morning, this is the card security desk.
|
|
6
|
+
|
|
7
|
+
Caller — We have blocked two transactions on your card ending 4417!
|
|
8
|
+
|
|
9
|
+
Caller — Read me the last four digits and we will release the block.
|
|
10
|
+
|
|
11
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# near twin: one extra sentence, different card digits
|
|
2
|
+
CALLER: Good morning, this is the card security desk.
|
|
3
|
+
CALLER: We have blocked two transactions on your card ending 9902.
|
|
4
|
+
CALLER: Read me the last four digits and we will release the block.
|
|
5
|
+
CALLER: If we do not clear it now, the block stays until you visit a branch.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
import pytest
|
|
4
6
|
from typer.testing import CliRunner
|
|
@@ -228,7 +230,7 @@ def test_dry_run_without_endpoint(home, tmp_path, monkeypatch):
|
|
|
228
230
|
def test_version(home):
|
|
229
231
|
result = runner.invoke(cli.app, ["--version"])
|
|
230
232
|
assert result.exit_code == 0
|
|
231
|
-
assert result.stdout.strip() == "scamscript 0.
|
|
233
|
+
assert result.stdout.strip() == "scamscript 0.5.0"
|
|
232
234
|
|
|
233
235
|
|
|
234
236
|
def test_json_output_is_one_object_per_script(home, tmp_path, fake):
|
|
@@ -409,3 +411,47 @@ def test_max_cost_warns_when_provider_reports_nothing(home, tmp_path, fake):
|
|
|
409
411
|
)
|
|
410
412
|
assert result.exit_code == 0
|
|
411
413
|
assert "could not be enforced" in result.stdout
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _reprints(tmp_path):
|
|
417
|
+
src = Path(__file__).parent / "fixtures" / "reprints"
|
|
418
|
+
for item in src.iterdir():
|
|
419
|
+
(tmp_path / item.name).write_text(item.read_text(), encoding="utf-8")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_dedupe_folds_reprints(home, tmp_path, fake):
|
|
423
|
+
_reprints(tmp_path)
|
|
424
|
+
scores = {"original": 4, "copy-punctuation": 9, "copy-header": 6, "near-twin": 8}
|
|
425
|
+
for key, score in scores.items():
|
|
426
|
+
fake.replies[key] = breakdown(score=score)
|
|
427
|
+
out = tmp_path / "r.md"
|
|
428
|
+
result = runner.invoke(cli.app, ["--api-key", "k", "batch", str(tmp_path), "--output", str(out)])
|
|
429
|
+
assert result.exit_code == 0
|
|
430
|
+
markdown = out.read_text()
|
|
431
|
+
assert "Duplicate copies folded: 2" in markdown
|
|
432
|
+
assert len(re.findall(r"^## \d+\. ", markdown, re.M)) == 2
|
|
433
|
+
assert "| 1 | copy-punctuation.txt |" in markdown
|
|
434
|
+
assert "near-twin.txt" in markdown
|
|
435
|
+
assert "copy-header.txt" in markdown
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_no_dedupe_keeps_every_copy(home, tmp_path, fake):
|
|
439
|
+
_reprints(tmp_path)
|
|
440
|
+
out = tmp_path / "r.md"
|
|
441
|
+
result = runner.invoke(
|
|
442
|
+
cli.app, ["--api-key", "k", "--no-dedupe", "batch", str(tmp_path), "--output", str(out)]
|
|
443
|
+
)
|
|
444
|
+
assert result.exit_code == 0
|
|
445
|
+
text = out.read_text()
|
|
446
|
+
assert "Duplicate copies folded" not in text
|
|
447
|
+
assert len(re.findall(r"^\| \d+ \|", text, re.M)) == 4
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_json_marks_duplicates(home, tmp_path, fake):
|
|
451
|
+
_reprints(tmp_path)
|
|
452
|
+
result = runner.invoke(cli.app, ["--api-key", "k", "--json", "batch", str(tmp_path)])
|
|
453
|
+
assert result.exit_code == 0
|
|
454
|
+
records = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
|
|
455
|
+
results = [record for record in records if record.get("event") == "result"]
|
|
456
|
+
assert all(record["fingerprint"] for record in results)
|
|
457
|
+
assert sum(1 for record in results if record["duplicate_of"]) == 2
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from scamscript.dedupe import fingerprint, normalize
|
|
4
|
+
from scamscript.report import ReportEntry, collapse
|
|
5
|
+
|
|
6
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "reprints"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read(name):
|
|
10
|
+
return (FIXTURES / name).read_text()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_header_and_punctuation_ignored():
|
|
14
|
+
assert fingerprint(read("original.txt")) == fingerprint(read("copy-punctuation.txt"))
|
|
15
|
+
assert fingerprint(read("original.txt")) == fingerprint(read("copy-header.txt"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_near_twin_differs():
|
|
19
|
+
assert fingerprint(read("original.txt")) != fingerprint(read("near-twin.txt"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_normalize_collapses_whitespace():
|
|
23
|
+
assert normalize("a b\n\tc") == "a b c"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_empty_and_header_only_stable():
|
|
27
|
+
assert fingerprint("") == fingerprint("# only a header\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def entry(name, score, fp="abc"):
|
|
31
|
+
from scamscript.models import ScriptBreakdown
|
|
32
|
+
|
|
33
|
+
breakdown = ScriptBreakdown(
|
|
34
|
+
attack_type="vishing",
|
|
35
|
+
persona="p",
|
|
36
|
+
opening_hook="h",
|
|
37
|
+
trust_building_moves=["a"],
|
|
38
|
+
urgency_tactics=["b"],
|
|
39
|
+
escalation_ladder=["c"],
|
|
40
|
+
target_demographics="d",
|
|
41
|
+
red_flags=["e"],
|
|
42
|
+
defense_summary="f",
|
|
43
|
+
sophistication_score=score,
|
|
44
|
+
)
|
|
45
|
+
return ReportEntry(name=name, source=name, breakdown=breakdown, fingerprint=fp)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_collapse_keeps_strongest_and_lists_rest():
|
|
49
|
+
entries = [entry("a", 4), entry("b", 9), entry("c", 6)]
|
|
50
|
+
collapsed = collapse(entries)
|
|
51
|
+
assert len(collapsed) == 1
|
|
52
|
+
assert collapsed[0].name == "b"
|
|
53
|
+
assert collapsed[0].duplicates == ["a", "c"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_collapse_leaves_distinct_and_failed_alone():
|
|
57
|
+
entries = [entry("a", 5), entry("twin", 8, fp="zzz"), ReportEntry(name="bad", source="bad")]
|
|
58
|
+
collapsed = collapse(entries)
|
|
59
|
+
assert [item.name for item in collapsed] == ["a", "twin", "bad"]
|
|
60
|
+
assert collapsed[0].duplicates is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|