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.
Files changed (67) hide show
  1. {scamscript-0.4.1 → scamscript-0.5.0}/CHANGELOG.md +12 -0
  2. {scamscript-0.4.1 → scamscript-0.5.0}/PKG-INFO +17 -3
  3. {scamscript-0.4.1 → scamscript-0.5.0}/README.md +16 -2
  4. {scamscript-0.4.1 → scamscript-0.5.0}/pyproject.toml +1 -1
  5. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/__init__.py +1 -1
  6. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/analysis.py +8 -2
  7. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/cli.py +32 -5
  8. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/client.py +1 -1
  9. scamscript-0.5.0/src/scamscript/dedupe.py +29 -0
  10. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/report.py +36 -3
  11. scamscript-0.5.0/tests/fixtures/reprints/copy-header.txt +4 -0
  12. scamscript-0.5.0/tests/fixtures/reprints/copy-punctuation.txt +11 -0
  13. scamscript-0.5.0/tests/fixtures/reprints/near-twin.txt +5 -0
  14. scamscript-0.5.0/tests/fixtures/reprints/original.txt +4 -0
  15. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_cli.py +47 -1
  16. scamscript-0.5.0/tests/test_dedupe.py +60 -0
  17. {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {scamscript-0.4.1 → scamscript-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {scamscript-0.4.1 → scamscript-0.5.0}/.github/SECURITY.md +0 -0
  21. {scamscript-0.4.1 → scamscript-0.5.0}/.github/pull_request_template.md +0 -0
  22. {scamscript-0.4.1 → scamscript-0.5.0}/.github/workflows/ci.yml +0 -0
  23. {scamscript-0.4.1 → scamscript-0.5.0}/.github/workflows/release.yml +0 -0
  24. {scamscript-0.4.1 → scamscript-0.5.0}/.gitignore +0 -0
  25. {scamscript-0.4.1 → scamscript-0.5.0}/CITATION.cff +0 -0
  26. {scamscript-0.4.1 → scamscript-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  27. {scamscript-0.4.1 → scamscript-0.5.0}/CONTRIBUTING.md +0 -0
  28. {scamscript-0.4.1 → scamscript-0.5.0}/LICENSE +0 -0
  29. {scamscript-0.4.1 → scamscript-0.5.0}/docs/banner.svg +0 -0
  30. {scamscript-0.4.1 → scamscript-0.5.0}/docs/calibration.md +0 -0
  31. {scamscript-0.4.1 → scamscript-0.5.0}/docs/demo.gif +0 -0
  32. {scamscript-0.4.1 → scamscript-0.5.0}/docs/sample-report.md +0 -0
  33. {scamscript-0.4.1 → scamscript-0.5.0}/docs/social-preview.png +0 -0
  34. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fake_output_json.txt +0 -0
  35. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fence_escape.txt +0 -0
  36. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/fixed_attack_type.txt +0 -0
  37. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/non_json_demand.txt +0 -0
  38. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/print_system_prompt.txt +0 -0
  39. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/role_switch.txt +0 -0
  40. {scamscript-0.4.1 → scamscript-0.5.0}/examples/adversarial/score_override.txt +0 -0
  41. {scamscript-0.4.1 → scamscript-0.5.0}/examples/crude_robocall.txt +0 -0
  42. {scamscript-0.4.1 → scamscript-0.5.0}/examples/gov_impersonation_vishing.txt +0 -0
  43. {scamscript-0.4.1 → scamscript-0.5.0}/examples/prompt_injection_script.txt +0 -0
  44. {scamscript-0.4.1 → scamscript-0.5.0}/examples/romance_scam_sms.txt +0 -0
  45. {scamscript-0.4.1 → scamscript-0.5.0}/examples/sophisticated_wire_fraud.txt +0 -0
  46. {scamscript-0.4.1 → scamscript-0.5.0}/examples/tech_support_scam.txt +0 -0
  47. {scamscript-0.4.1 → scamscript-0.5.0}/scripts/calibrate.py +0 -0
  48. {scamscript-0.4.1 → scamscript-0.5.0}/scripts/injection_eval.py +0 -0
  49. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/__main__.py +0 -0
  50. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/config.py +0 -0
  51. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/ingest.py +0 -0
  52. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/models.py +0 -0
  53. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/prompts.py +0 -0
  54. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/redact.py +0 -0
  55. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/render.py +0 -0
  56. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/store.py +0 -0
  57. {scamscript-0.4.1 → scamscript-0.5.0}/src/scamscript/verify.py +0 -0
  58. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_analysis.py +0 -0
  59. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_client.py +0 -0
  60. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_ingest.py +0 -0
  61. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_models.py +0 -0
  62. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_prompts.py +0 -0
  63. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_redact.py +0 -0
  64. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_render.py +0 -0
  65. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_report.py +0 -0
  66. {scamscript-0.4.1 → scamscript-0.5.0}/tests/test_store.py +0 -0
  67. {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.4.1
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-color`. `--json` puts one object per script on stdout and all
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
- - One script per request; no cross-script deduplication yet, so reprints inflate a ranking.
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-color`. `--json` puts one object per script on stdout and all
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
- - One script per request; no cross-script deduplication yet, so reprints inflate a ranking.
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.4.1"
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"
@@ -1,3 +1,3 @@
1
1
  """scamscript: reverse-engineering analyzer for scam call scripts."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.5.0"
@@ -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(source=script.path, name=script.name)
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
- console.print(
394
- render_summary_table(
395
- [(o.name, o.breakdown, o.error, o.skipped) for o in finished]
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
- ranked = reporting.rank(report_entries)
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(
@@ -63,7 +63,7 @@ class ChatClient:
63
63
  "Authorization": f"Bearer {api_key}",
64
64
  "Content-Type": "application/json",
65
65
  "Accept": "application/json",
66
- "User-Agent": "scamscript/0.4.1",
66
+ "User-Agent": "scamscript/0.5.0",
67
67
  },
68
68
  )
69
69
 
@@ -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,4 @@
1
+ # third copy, header differs only
2
+ CALLER: Good morning, this is the card security desk.
3
+ CALLER: We have blocked two transactions on your card ending 4417.
4
+ CALLER: Read me the last four digits and we will release the block.
@@ -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.
@@ -0,0 +1,4 @@
1
+ # captured 2026-10-02, first copy
2
+ CALLER: Good morning, this is the card security desk.
3
+ CALLER: We have blocked two transactions on your card ending 4417.
4
+ CALLER: Read me the last four digits and we will release the block.
@@ -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.4.1"
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