ko-copycheck 0.1.1__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.
- ko_copycheck/__init__.py +11 -0
- ko_copycheck/cli.py +225 -0
- ko_copycheck/config.py +44 -0
- ko_copycheck/engine.py +131 -0
- ko_copycheck/extract/__init__.py +5 -0
- ko_copycheck/extract/formats.py +175 -0
- ko_copycheck/extract/sandbox.py +198 -0
- ko_copycheck/index.py +56 -0
- ko_copycheck/normalize.py +56 -0
- ko_copycheck/paraphrase.py +119 -0
- ko_copycheck/report/__init__.py +5 -0
- ko_copycheck/report/html.py +216 -0
- ko_copycheck/report/tabular.py +48 -0
- ko_copycheck/score.py +103 -0
- ko_copycheck/verbatim.py +123 -0
- ko_copycheck/winnow.py +50 -0
- ko_copycheck-0.1.1.dist-info/METADATA +181 -0
- ko_copycheck-0.1.1.dist-info/RECORD +21 -0
- ko_copycheck-0.1.1.dist-info/WHEEL +4 -0
- ko_copycheck-0.1.1.dist-info/entry_points.txt +3 -0
- ko_copycheck-0.1.1.dist-info/licenses/LICENSE +21 -0
ko_copycheck/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ko-copycheck: offline plagiarism checker for Korean prose.
|
|
2
|
+
|
|
3
|
+
Public API: :func:`compare`, :class:`Config`, :class:`Report`. Everything else is
|
|
4
|
+
internal and may change.
|
|
5
|
+
"""
|
|
6
|
+
from .engine import compare
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .score import Report
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.1"
|
|
11
|
+
__all__ = ["compare", "Config", "Report", "__version__"]
|
ko_copycheck/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Command line interface: scan, pair, extract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import unicodedata
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .engine import compare
|
|
12
|
+
from .extract import SUPPORTED, Limits, SandboxedExtractor
|
|
13
|
+
from .report import write_csv, write_json, write_report
|
|
14
|
+
from .report.html import pair_page
|
|
15
|
+
from .normalize import normalize_text
|
|
16
|
+
from .score import Failure, Report
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_ERROR = 0, 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _add_tuning(p: argparse.ArgumentParser) -> None:
|
|
22
|
+
d = Config()
|
|
23
|
+
p.add_argument("--prompt", action="append", default=[], metavar="FILE",
|
|
24
|
+
help="assignment text every submission may quote (repeatable); matching parts are ignored")
|
|
25
|
+
p.add_argument("--k", type=int, default=d.k, help=f"k-gram length in characters (default {d.k})")
|
|
26
|
+
p.add_argument("--w", type=int, default=d.w, help=f"winnowing window (default {d.w})")
|
|
27
|
+
p.add_argument("--min-match", type=int, default=d.min_match, help=f"shortest verbatim match in characters (default {d.min_match})")
|
|
28
|
+
p.add_argument("--dice", type=float, default=d.dice, help=f"paraphrase Dice threshold (default {d.dice})")
|
|
29
|
+
p.add_argument("--min-sentence", type=int, default=d.min_sentence, help=f"shortest sentence for paraphrase layer (default {d.min_sentence})")
|
|
30
|
+
p.add_argument("--group-ratio", type=float, default=d.group_ratio, help=f"share of documents that makes a passage common (default {d.group_ratio})")
|
|
31
|
+
p.add_argument("--timeout", type=float, default=d.timeout, help=f"seconds allowed per file extraction (default {d.timeout:g})")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _config(a: argparse.Namespace) -> Config:
|
|
35
|
+
return Config(k=a.k, w=a.w, min_match=a.min_match, dice=a.dice, min_sentence=a.min_sentence,
|
|
36
|
+
group_ratio=a.group_ratio, timeout=a.timeout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
ap = argparse.ArgumentParser(
|
|
41
|
+
prog="ko-copycheck",
|
|
42
|
+
description="Offline plagiarism checker for Korean prose submissions. Compares files with each other only; nothing is uploaded.",
|
|
43
|
+
)
|
|
44
|
+
ap.add_argument("--version", action="version", version=f"ko-copycheck {__version__}")
|
|
45
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
scan = sub.add_parser("scan", help="compare every pair of files in a folder")
|
|
48
|
+
scan.add_argument("folder", type=Path)
|
|
49
|
+
scan.add_argument("--out", type=Path, default=Path("report"), help="output folder (default ./report)")
|
|
50
|
+
scan.add_argument("--recursive", action="store_true", help="also read sub-folders")
|
|
51
|
+
scan.add_argument("--top", type=int, default=300, help="write side-by-side pages for at most this many pairs (default 300)")
|
|
52
|
+
_add_tuning(scan)
|
|
53
|
+
|
|
54
|
+
pair = sub.add_parser("pair", help="compare two files and write one side-by-side HTML page")
|
|
55
|
+
pair.add_argument("left", type=Path)
|
|
56
|
+
pair.add_argument("right", type=Path)
|
|
57
|
+
pair.add_argument("--out", type=Path, default=Path("pair.html"), help="output HTML file (default ./pair.html)")
|
|
58
|
+
_add_tuning(pair)
|
|
59
|
+
|
|
60
|
+
ex = sub.add_parser("extract", help="print the text extracted from one file (check how it is read)")
|
|
61
|
+
ex.add_argument("file", type=Path)
|
|
62
|
+
ex.add_argument("--timeout", type=float, default=Config().timeout, help="seconds allowed (default 60)")
|
|
63
|
+
return ap
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _say(msg: str) -> None:
|
|
67
|
+
print(msg, file=sys.stderr, flush=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _collect(folder: Path, recursive: bool, exclude: set[Path]) -> list[Path]:
|
|
71
|
+
it = folder.rglob("*") if recursive else folder.glob("*")
|
|
72
|
+
files = [p for p in it if p.is_file() and p.suffix.lower() in SUPPORTED and not p.name.startswith((".", "~$"))]
|
|
73
|
+
return sorted(p for p in files if p.resolve() not in exclude)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_all(paths: list[Path], names: list[str], timeout: float, min_chars: int = 0) -> tuple[dict[str, str], list[Failure]]:
|
|
77
|
+
texts: dict[str, str] = {}
|
|
78
|
+
failures: list[Failure] = []
|
|
79
|
+
with SandboxedExtractor(timeout=timeout) as ex:
|
|
80
|
+
for i, (path, name) in enumerate(zip(paths, names), 1):
|
|
81
|
+
res = ex.extract(path)
|
|
82
|
+
if res.ok and res.text is not None and len(normalize_text(res.text)) < min_chars:
|
|
83
|
+
failures.append(Failure(name, f"no_text: fewer than {min_chars} readable characters (empty file?)"))
|
|
84
|
+
elif res.ok and res.text is not None:
|
|
85
|
+
texts[name] = res.text
|
|
86
|
+
else:
|
|
87
|
+
failures.append(Failure(name, res.error or "unknown"))
|
|
88
|
+
_say(f"extract {i}/{len(paths)} failed {len(failures)}")
|
|
89
|
+
return texts, failures
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_prompts(paths: list[str], timeout: float) -> list[str] | None:
|
|
93
|
+
files = [Path(p) for p in paths]
|
|
94
|
+
for f in files:
|
|
95
|
+
if not f.is_file():
|
|
96
|
+
_say(f"error: prompt file not found: {f}")
|
|
97
|
+
return None
|
|
98
|
+
if not files:
|
|
99
|
+
return []
|
|
100
|
+
texts, failures = _extract_all(files, [f.name for f in files], timeout)
|
|
101
|
+
if failures:
|
|
102
|
+
_say(f"error: cannot read prompt {failures[0].name}: {failures[0].reason}")
|
|
103
|
+
return None
|
|
104
|
+
return list(texts.values())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _width(s: str) -> int:
|
|
108
|
+
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pad(s: str, n: int) -> str:
|
|
112
|
+
return s + " " * max(0, n - _width(s))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_top(report: Report, n: int = 10) -> None:
|
|
116
|
+
"""Terminal ranking of the most similar pairs."""
|
|
117
|
+
rows = [p for p in report.pairs if p.rank_key > 0][:n]
|
|
118
|
+
if not rows:
|
|
119
|
+
print("no overlapping pairs found")
|
|
120
|
+
return
|
|
121
|
+
label = [f"{p.left} ~ {p.right}" for p in rows]
|
|
122
|
+
wid = max(_width(x) for x in label)
|
|
123
|
+
print(f"{_pad('pair', wid)} verbatim L<-R / R<-L paraphrase total L<-R / R<-L")
|
|
124
|
+
for text, p in zip(label, rows):
|
|
125
|
+
print(f"{_pad(text, wid)} {p.verbatim_lr * 100:5.1f}% / {p.verbatim_rl * 100:5.1f}% "
|
|
126
|
+
f"{p.para_sentences:6d} {p.score_lr * 100:5.1f}% / {p.score_rl * 100:5.1f}%")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_scan(a: argparse.Namespace) -> int:
|
|
130
|
+
cfg = _config(a)
|
|
131
|
+
cfg.validate()
|
|
132
|
+
if not a.folder.is_dir():
|
|
133
|
+
_say(f"error: not a folder: {a.folder}. Pass the folder that holds the submissions.")
|
|
134
|
+
return EXIT_ERROR
|
|
135
|
+
prompts = _read_prompts(a.prompt, cfg.timeout)
|
|
136
|
+
if prompts is None:
|
|
137
|
+
return EXIT_ERROR
|
|
138
|
+
exclude = {Path(p).resolve() for p in a.prompt}
|
|
139
|
+
paths = _collect(a.folder, a.recursive, exclude)
|
|
140
|
+
names = [p.relative_to(a.folder).as_posix() for p in paths]
|
|
141
|
+
if len(paths) < 2:
|
|
142
|
+
_say(f"error: need at least 2 files ({', '.join(SUPPORTED)}) in {a.folder}, found {len(paths)}. Try --recursive.")
|
|
143
|
+
return EXIT_ERROR
|
|
144
|
+
texts, failures = _extract_all(paths, names, cfg.timeout, cfg.k)
|
|
145
|
+
if len(texts) < 2:
|
|
146
|
+
_say("error: fewer than 2 files could be read; see the failure reasons above (run 'extract' on one file).")
|
|
147
|
+
for f in failures:
|
|
148
|
+
_say(f" {f.name}: {f.reason}")
|
|
149
|
+
return EXIT_ERROR
|
|
150
|
+
report = compare(texts, prompts, cfg, failures)
|
|
151
|
+
a.out.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
write_csv(report, a.out / "pairs.csv")
|
|
153
|
+
write_json(report, a.out / "pairs.json")
|
|
154
|
+
index = write_report(report, a.out, top=a.top)
|
|
155
|
+
print_top(report)
|
|
156
|
+
for f in failures:
|
|
157
|
+
print(f"FAILED {f.name}: {f.reason}")
|
|
158
|
+
s = report.stats
|
|
159
|
+
print(f"\n{s['documents']} documents, {s['candidate_pairs']} candidate pairs of {s['all_pairs']}, {s['elapsed_seconds']}s")
|
|
160
|
+
print(f"report: {index}")
|
|
161
|
+
return EXIT_OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_pair(a: argparse.Namespace) -> int:
|
|
165
|
+
cfg = _config(a)
|
|
166
|
+
cfg.validate()
|
|
167
|
+
for f in (a.left, a.right):
|
|
168
|
+
if not f.is_file():
|
|
169
|
+
_say(f"error: file not found: {f}")
|
|
170
|
+
return EXIT_ERROR
|
|
171
|
+
prompts = _read_prompts(a.prompt, cfg.timeout)
|
|
172
|
+
if prompts is None:
|
|
173
|
+
return EXIT_ERROR
|
|
174
|
+
left_name, right_name = a.left.name, a.right.name
|
|
175
|
+
if left_name == right_name:
|
|
176
|
+
left_name, right_name = str(a.left), str(a.right)
|
|
177
|
+
texts, failures = _extract_all([a.left, a.right], [left_name, right_name], cfg.timeout, cfg.k)
|
|
178
|
+
if failures:
|
|
179
|
+
for f in failures:
|
|
180
|
+
_say(f"error: cannot read {f.name}: {f.reason}")
|
|
181
|
+
return EXIT_ERROR
|
|
182
|
+
report = compare(texts, prompts, cfg)
|
|
183
|
+
result = report.pairs[0]
|
|
184
|
+
a.out.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
a.out.write_text(pair_page(report, result), encoding="utf-8")
|
|
186
|
+
print(f"{result.left} ~ {result.right}: verbatim {result.verbatim_lr * 100:.1f}% / {result.verbatim_rl * 100:.1f}%, "
|
|
187
|
+
f"paraphrase sentences {result.para_sentences}")
|
|
188
|
+
print(f"page: {a.out}")
|
|
189
|
+
return EXIT_OK
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_extract(a: argparse.Namespace) -> int:
|
|
193
|
+
if not a.file.is_file():
|
|
194
|
+
_say(f"error: file not found: {a.file}")
|
|
195
|
+
return EXIT_ERROR
|
|
196
|
+
with SandboxedExtractor(timeout=a.timeout, limits=Limits()) as ex:
|
|
197
|
+
res = ex.extract(a.file)
|
|
198
|
+
if not res.ok or res.text is None:
|
|
199
|
+
_say(f"error: {res.error}")
|
|
200
|
+
return EXIT_ERROR
|
|
201
|
+
sys.stdout.write(res.text if res.text.endswith("\n") else res.text + "\n")
|
|
202
|
+
return EXIT_OK
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(argv: list[str] | None = None) -> int:
|
|
206
|
+
for stream in (sys.stdout, sys.stderr):
|
|
207
|
+
if hasattr(stream, "reconfigure"):
|
|
208
|
+
stream.reconfigure(encoding="utf-8")
|
|
209
|
+
args = build_parser().parse_args(argv)
|
|
210
|
+
try:
|
|
211
|
+
if args.command == "scan":
|
|
212
|
+
return cmd_scan(args)
|
|
213
|
+
if args.command == "pair":
|
|
214
|
+
return cmd_pair(args)
|
|
215
|
+
return cmd_extract(args)
|
|
216
|
+
except ValueError as exc:
|
|
217
|
+
_say(f"error: {exc}")
|
|
218
|
+
return EXIT_ERROR
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
_say("interrupted")
|
|
221
|
+
return 130
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|
ko_copycheck/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tunable settings. Defaults are documented in the README option table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Config:
|
|
9
|
+
"""All knobs of a comparison run.
|
|
10
|
+
|
|
11
|
+
``dice`` default comes from ``scripts/calibrate.py`` (hand-written calibration
|
|
12
|
+
corpus); it is a starting point, not a verdict. Tune it with ``--dice``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
k: int = 8 # characters per k-gram
|
|
16
|
+
w: int = 4 # winnowing window (guarantee threshold = w + k - 1 characters)
|
|
17
|
+
min_match: int = 20 # shortest verbatim match (normalized characters) that counts
|
|
18
|
+
dice: float = 0.30 # sentence bigram Dice threshold for the paraphrase layer
|
|
19
|
+
min_sentence: int = 10 # shortest sentence (normalized characters) for the paraphrase layer
|
|
20
|
+
group_ratio: float = 0.5 # k-grams shared by this share of documents are "common"...
|
|
21
|
+
group_min_docs: int = 3 # ...but only when at least this many documents share them
|
|
22
|
+
covered_skip: float = 0.8 # skip paraphrase pairs whose sentence is already this covered by verbatim
|
|
23
|
+
max_postings: int = 200 # ignore k-grams occurring more often than this in the index
|
|
24
|
+
timeout: float = 60.0 # seconds per file for extraction
|
|
25
|
+
|
|
26
|
+
def validate(self) -> None:
|
|
27
|
+
"""Raise ``ValueError`` with a fix hint for settings that cannot work."""
|
|
28
|
+
if self.k < 3:
|
|
29
|
+
raise ValueError(f"k must be >= 3 (got {self.k}); try --k 8")
|
|
30
|
+
if self.w < 1:
|
|
31
|
+
raise ValueError(f"w must be >= 1 (got {self.w}); try --w 4")
|
|
32
|
+
if self.min_match < self.w + self.k - 1:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"min_match ({self.min_match}) must be >= w + k - 1 ({self.w + self.k - 1}); "
|
|
35
|
+
"shorter matches are not guaranteed to be found. Try --min-match 20"
|
|
36
|
+
)
|
|
37
|
+
if not 0.0 < self.dice <= 1.0:
|
|
38
|
+
raise ValueError(f"dice must be in (0, 1] (got {self.dice}); try --dice {type(self).dice}")
|
|
39
|
+
if not 0.0 < self.group_ratio <= 1.0:
|
|
40
|
+
raise ValueError(f"group_ratio must be in (0, 1] (got {self.group_ratio})")
|
|
41
|
+
if self.min_sentence < 2:
|
|
42
|
+
raise ValueError(f"min_sentence must be >= 2 (got {self.min_sentence})")
|
|
43
|
+
if self.timeout <= 0:
|
|
44
|
+
raise ValueError(f"timeout must be > 0 seconds (got {self.timeout})")
|
ko_copycheck/engine.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Public entry point: compare a set of texts against each other."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
from typing import Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .index import candidate_hits, document_frequency
|
|
11
|
+
from .normalize import from_display_span, normalize
|
|
12
|
+
from .paraphrase import Sentence, bigram_set, matches_prefix, split_sentences
|
|
13
|
+
from .score import Doc, Failure, PairResult, ParaMatch, Report, finalize
|
|
14
|
+
from .verbatim import Span, covered, merge_spans, pair_spans, prompt_mask
|
|
15
|
+
from .winnow import kgram_hashes, winnow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _overlap_share(span: Span, others: list[Span]) -> float:
|
|
19
|
+
"""Share of ``span`` covered by the (merged, sorted) ``others``."""
|
|
20
|
+
s, e = span
|
|
21
|
+
if e <= s:
|
|
22
|
+
return 0.0
|
|
23
|
+
hit = sum(max(0, min(e, oe) - max(s, os)) for os, oe in others)
|
|
24
|
+
return hit / (e - s)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compare(
|
|
28
|
+
texts: Mapping[str, str],
|
|
29
|
+
prompts: Sequence[str] = (),
|
|
30
|
+
config: Config | None = None,
|
|
31
|
+
failures: Sequence[Failure] = (),
|
|
32
|
+
) -> Report:
|
|
33
|
+
"""Compare every pair of ``texts`` (name -> text) and return a :class:`Report`.
|
|
34
|
+
|
|
35
|
+
``prompts`` are the assignment texts every submission may quote; matching parts are
|
|
36
|
+
masked and never count. No network is used.
|
|
37
|
+
"""
|
|
38
|
+
cfg = config or Config()
|
|
39
|
+
cfg.validate()
|
|
40
|
+
t0 = time.perf_counter()
|
|
41
|
+
names = sorted(texts)
|
|
42
|
+
norms = [normalize(texts[n]) for n in names]
|
|
43
|
+
prompt_norms = [normalize(p).text for p in prompts]
|
|
44
|
+
prompt_norms = [p for p in prompt_norms if len(p) >= cfg.k]
|
|
45
|
+
|
|
46
|
+
hash_lists = [kgram_hashes(n.text, cfg.k) for n in norms]
|
|
47
|
+
fps = [winnow(h, cfg.w) for h in hash_lists]
|
|
48
|
+
prompt_fps = [winnow(kgram_hashes(p, cfg.k), cfg.w) for p in prompt_norms]
|
|
49
|
+
df = document_frequency(hash_lists)
|
|
50
|
+
del hash_lists
|
|
51
|
+
|
|
52
|
+
docs: list[Doc] = []
|
|
53
|
+
for name, norm, doc_fps in zip(names, norms, fps):
|
|
54
|
+
mask = prompt_mask(norm.text, doc_fps, prompt_norms, prompt_fps, cfg.k)
|
|
55
|
+
sent_spans = [
|
|
56
|
+
from_display_span(norm.offsets, s, e) for s, e in split_sentences(norm.display, cfg.min_sentence)
|
|
57
|
+
]
|
|
58
|
+
docs.append(Doc(name, norm, mask, sent_spans))
|
|
59
|
+
|
|
60
|
+
# Layer 1: verbatim matches only for pairs that share a (non-common) fingerprint.
|
|
61
|
+
hits = candidate_hits(fps, df, cfg)
|
|
62
|
+
verbatim: dict[tuple[int, int], tuple[list[Span], list[Span]]] = {}
|
|
63
|
+
for (i, j), pair_hits in hits.items():
|
|
64
|
+
sa, sb = pair_spans(norms[i].text, norms[j].text, pair_hits, docs[i].mask, docs[j].mask, cfg)
|
|
65
|
+
if sa:
|
|
66
|
+
verbatim[(i, j)] = (sa, sb)
|
|
67
|
+
|
|
68
|
+
# Layer 2: sentence Dice over all documents with the rare-bigram prefix filter.
|
|
69
|
+
sents: list[Sentence] = []
|
|
70
|
+
for di, doc in enumerate(docs):
|
|
71
|
+
for span in doc.sentences:
|
|
72
|
+
if _overlap_share(span, doc.mask) >= 0.5: # mostly prompt text
|
|
73
|
+
continue
|
|
74
|
+
grams = bigram_set(doc.norm.text[span[0] : span[1]])
|
|
75
|
+
if grams:
|
|
76
|
+
sents.append(Sentence(di, span, grams))
|
|
77
|
+
sent_pairs, compared = matches_prefix(sents, cfg.dice)
|
|
78
|
+
para_by_pair: dict[tuple[int, int], list[tuple[float, Span, Span]]] = defaultdict(list)
|
|
79
|
+
for a, b in sent_pairs:
|
|
80
|
+
sa, sb = sents[a], sents[b]
|
|
81
|
+
if sa.doc > sb.doc:
|
|
82
|
+
sa, sb = sb, sa
|
|
83
|
+
d = 2 * len(sa.bigrams & sb.bigrams) / (len(sa.bigrams) + len(sb.bigrams))
|
|
84
|
+
para_by_pair[(sa.doc, sb.doc)].append((d, sa.span, sb.span))
|
|
85
|
+
|
|
86
|
+
results: dict[tuple[int, int], PairResult] = {}
|
|
87
|
+
for (i, j), cand in para_by_pair.items():
|
|
88
|
+
vl, vr = verbatim.get((i, j), ([], []))
|
|
89
|
+
matches = _one_to_one(cand, vl, vr, cfg.covered_skip)
|
|
90
|
+
results[(i, j)] = finalize(docs[i], docs[j], vl, vr, matches)
|
|
91
|
+
for (i, j), (vl, vr) in verbatim.items():
|
|
92
|
+
if (i, j) not in results:
|
|
93
|
+
results[(i, j)] = finalize(docs[i], docs[j], vl, vr, [])
|
|
94
|
+
pairs = [results.get(p) or PairResult(docs[p[0]].name, docs[p[1]].name) for p in combinations(range(len(docs)), 2)]
|
|
95
|
+
pairs.sort(key=lambda p: (-p.rank_key, -(p.score_lr + p.score_rl), p.left, p.right))
|
|
96
|
+
|
|
97
|
+
n = len(docs)
|
|
98
|
+
stats = {
|
|
99
|
+
"documents": n,
|
|
100
|
+
"all_pairs": n * (n - 1) // 2,
|
|
101
|
+
"candidate_pairs": len(results), # pairs that reached either layer (= rows with a score)
|
|
102
|
+
"verbatim_pairs": len(verbatim),
|
|
103
|
+
"sentences": len(sents),
|
|
104
|
+
"sentence_candidates_compared": compared,
|
|
105
|
+
"elapsed_seconds": round(time.perf_counter() - t0, 3),
|
|
106
|
+
}
|
|
107
|
+
return Report({d.name: d for d in docs}, pairs, list(failures), cfg, stats, list(prompts))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _one_to_one(
|
|
111
|
+
cand: list[tuple[float, Span, Span]], vl: list[Span], vr: list[Span], skip: float
|
|
112
|
+
) -> list[ParaMatch]:
|
|
113
|
+
"""Greedy one-to-one sentence matching, best Dice first.
|
|
114
|
+
|
|
115
|
+
Pairs whose sentence is already mostly explained by a verbatim match are skipped:
|
|
116
|
+
that text is reported by layer 1.
|
|
117
|
+
"""
|
|
118
|
+
ml, mr = merge_spans(vl), merge_spans(vr)
|
|
119
|
+
used_l: set[Span] = set()
|
|
120
|
+
used_r: set[Span] = set()
|
|
121
|
+
out: list[ParaMatch] = []
|
|
122
|
+
for d, sl, sr in sorted(cand, key=lambda c: (-c[0], c[1], c[2])):
|
|
123
|
+
if sl in used_l or sr in used_r:
|
|
124
|
+
continue
|
|
125
|
+
if _overlap_share(sl, ml) >= skip or _overlap_share(sr, mr) >= skip:
|
|
126
|
+
continue
|
|
127
|
+
used_l.add(sl)
|
|
128
|
+
used_r.add(sr)
|
|
129
|
+
out.append(ParaMatch(sl, sr, round(d, 4)))
|
|
130
|
+
out.sort(key=lambda m: m.left)
|
|
131
|
+
return out
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Safe text extraction for txt, docx, hwpx and pdf submissions."""
|
|
2
|
+
from .formats import SUPPORTED, ExtractError, Limits, extract_file
|
|
3
|
+
from .sandbox import ExtractResult, SandboxedExtractor
|
|
4
|
+
|
|
5
|
+
__all__ = ["SUPPORTED", "ExtractError", "Limits", "extract_file", "ExtractResult", "SandboxedExtractor"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Format readers: txt, docx, hwpx, pdf. Run these inside the sandboxed worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from xml.etree import ElementTree as ET
|
|
9
|
+
|
|
10
|
+
SUPPORTED = (".txt", ".docx", ".hwpx", ".pdf")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExtractError(Exception):
|
|
14
|
+
"""A file cannot be read. ``code`` is a short machine-readable reason."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, code: str, message: str = "") -> None:
|
|
17
|
+
super().__init__(message or code)
|
|
18
|
+
self.code = code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Limits:
|
|
23
|
+
"""Resource limits applied to every input file."""
|
|
24
|
+
|
|
25
|
+
max_file_bytes: int = 50 * 1024 * 1024
|
|
26
|
+
max_unzipped_bytes: int = 200 * 1024 * 1024
|
|
27
|
+
max_zip_entries: int = 5000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_file(path: str, limits: Limits | None = None) -> str:
|
|
31
|
+
"""Return the plain text of ``path``; raise :class:`ExtractError` on failure."""
|
|
32
|
+
limits = limits or Limits()
|
|
33
|
+
p = Path(path)
|
|
34
|
+
ext = p.suffix.lower()
|
|
35
|
+
if ext not in SUPPORTED:
|
|
36
|
+
hint = " (binary .hwp is not supported; save as .hwpx or .pdf)" if ext == ".hwp" else ""
|
|
37
|
+
raise ExtractError("unsupported", f"unsupported file type {ext or '(none)'}{hint}")
|
|
38
|
+
size = p.stat().st_size
|
|
39
|
+
if size > limits.max_file_bytes:
|
|
40
|
+
raise ExtractError("too_large", f"{size} bytes exceeds the {limits.max_file_bytes} byte limit")
|
|
41
|
+
if ext == ".txt":
|
|
42
|
+
return read_txt(p)
|
|
43
|
+
if ext == ".pdf":
|
|
44
|
+
return read_pdf(p)
|
|
45
|
+
check_zip_budget(p, limits)
|
|
46
|
+
return read_docx(p) if ext == ".docx" else read_hwpx(p)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_txt(path: Path) -> str:
|
|
50
|
+
"""Decode a text file: UTF-16 with BOM, UTF-8 (with or without BOM), then CP949."""
|
|
51
|
+
raw = path.read_bytes()
|
|
52
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
53
|
+
return raw.decode("utf-16")
|
|
54
|
+
for enc in ("utf-8-sig", "cp949"):
|
|
55
|
+
try:
|
|
56
|
+
return raw.decode(enc)
|
|
57
|
+
except UnicodeDecodeError:
|
|
58
|
+
continue
|
|
59
|
+
return raw.decode("utf-8", errors="replace")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_zip_budget(path: Path, limits: Limits) -> None:
|
|
63
|
+
"""Refuse zip containers that expand beyond the limits.
|
|
64
|
+
|
|
65
|
+
Declared sizes in a zip header can lie, so every member is really decompressed in
|
|
66
|
+
small chunks and the *actual* byte count is checked.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
with zipfile.ZipFile(path) as zf:
|
|
70
|
+
infos = zf.infolist()
|
|
71
|
+
if len(infos) > limits.max_zip_entries:
|
|
72
|
+
raise ExtractError("too_large", f"{len(infos)} zip entries exceed {limits.max_zip_entries}")
|
|
73
|
+
total = 0
|
|
74
|
+
for info in infos:
|
|
75
|
+
with zf.open(info) as fh:
|
|
76
|
+
while chunk := fh.read(1 << 20):
|
|
77
|
+
total += len(chunk)
|
|
78
|
+
if total > limits.max_unzipped_bytes:
|
|
79
|
+
raise ExtractError(
|
|
80
|
+
"too_large", f"unzipped size exceeds {limits.max_unzipped_bytes} bytes"
|
|
81
|
+
)
|
|
82
|
+
except zipfile.BadZipFile as exc:
|
|
83
|
+
raise ExtractError("corrupt", f"not a valid zip container: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_docx(path: Path) -> str:
|
|
87
|
+
"""Paragraphs and table cells of a .docx, in document order."""
|
|
88
|
+
from docx import Document # imported lazily: only the worker needs it
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
doc = Document(str(path))
|
|
92
|
+
except Exception as exc: # python-docx raises several error types
|
|
93
|
+
raise ExtractError("corrupt", f"cannot open docx: {exc}") from exc
|
|
94
|
+
lines = [p.text for p in doc.paragraphs]
|
|
95
|
+
for table in doc.tables:
|
|
96
|
+
for row in table.rows:
|
|
97
|
+
lines.extend(cell.text for cell in row.cells)
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _local(tag: str) -> str:
|
|
102
|
+
return tag.rsplit("}", 1)[-1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hwpx_section_order(zf: zipfile.ZipFile) -> list[str]:
|
|
106
|
+
"""Section part names in reading order: content.hpf spine, else numeric order."""
|
|
107
|
+
members = set(zf.namelist())
|
|
108
|
+
names = [n for n in members if re.search(r"(^|/)section\d+\.xml$", n)]
|
|
109
|
+
try:
|
|
110
|
+
root = ET.fromstring(zf.read("Contents/content.hpf"))
|
|
111
|
+
except (KeyError, ET.ParseError):
|
|
112
|
+
root = None
|
|
113
|
+
if root is not None:
|
|
114
|
+
manifest = {el.get("id"): el.get("href") for el in root.iter() if _local(el.tag) == "item"}
|
|
115
|
+
spine = [el.get("idref") for el in root.iter() if _local(el.tag) == "itemref"]
|
|
116
|
+
ordered = []
|
|
117
|
+
for idref in spine:
|
|
118
|
+
href = manifest.get(idref)
|
|
119
|
+
if not href:
|
|
120
|
+
continue
|
|
121
|
+
found = next((c for c in (href, f"Contents/{href}") if c in members), None)
|
|
122
|
+
if found and re.search(r"section\d+\.xml$", found):
|
|
123
|
+
ordered.append(found)
|
|
124
|
+
if ordered:
|
|
125
|
+
return ordered
|
|
126
|
+
return sorted(names, key=lambda n: int(re.search(r"section(\d+)\.xml$", n).group(1))) # type: ignore[union-attr]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _collect_text(el: ET.Element, out: list[str]) -> None:
|
|
130
|
+
"""Depth-first text of an OWPML element: <t> text, line breaks, paragraph ends."""
|
|
131
|
+
name = _local(el.tag)
|
|
132
|
+
if name == "t":
|
|
133
|
+
out.append(el.text or "")
|
|
134
|
+
for child in el:
|
|
135
|
+
if _local(child.tag) == "lineBreak":
|
|
136
|
+
out.append("\n")
|
|
137
|
+
elif _local(child.tag) == "tab":
|
|
138
|
+
out.append("\t")
|
|
139
|
+
out.append(child.tail or "")
|
|
140
|
+
return
|
|
141
|
+
for child in el:
|
|
142
|
+
_collect_text(child, out)
|
|
143
|
+
if name == "p":
|
|
144
|
+
out.append("\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_hwpx(path: Path) -> str:
|
|
148
|
+
"""Body text of a .hwpx (OWPML zip), sections in spine order."""
|
|
149
|
+
try:
|
|
150
|
+
with zipfile.ZipFile(path) as zf:
|
|
151
|
+
sections = _hwpx_section_order(zf)
|
|
152
|
+
if not sections:
|
|
153
|
+
raise ExtractError("corrupt", "no Contents/section*.xml found; is this a real .hwpx?")
|
|
154
|
+
out: list[str] = []
|
|
155
|
+
for name in sections:
|
|
156
|
+
_collect_text(ET.fromstring(zf.read(name)), out)
|
|
157
|
+
except zipfile.BadZipFile as exc:
|
|
158
|
+
raise ExtractError("corrupt", f"not a valid hwpx (zip) file: {exc}") from exc
|
|
159
|
+
except ET.ParseError as exc:
|
|
160
|
+
raise ExtractError("corrupt", f"broken XML in hwpx: {exc}") from exc
|
|
161
|
+
return "".join(out)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_pdf(path: Path) -> str:
|
|
165
|
+
"""Text layer of a PDF. Scanned PDFs have none; that is reported as an error."""
|
|
166
|
+
from pypdf import PdfReader # imported lazily: only the worker needs it
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
reader = PdfReader(str(path))
|
|
170
|
+
text = "\n".join((page.extract_text() or "") for page in reader.pages)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
raise ExtractError("corrupt", f"cannot read pdf: {exc}") from exc
|
|
173
|
+
if len(text.strip()) < 5:
|
|
174
|
+
raise ExtractError("no_text", "no text layer found (scanned PDF? OCR is not supported)")
|
|
175
|
+
return text
|