pdf-redpen 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.
pdf_redpen/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """pdf-redpen: ground LLM review quotes to PDF coordinates and mark them up.
2
+
3
+ Small public API (everything else is internal):
4
+
5
+ Finding, ground, annotate_pdf, InputError, __version__
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Iterable
11
+
12
+ from .grounding import ground
13
+ from .models import Finding, InputError, Match, Report
14
+ from .writer import DEFAULT_AUTHOR
15
+
16
+ __version__ = "0.2.0"
17
+ __all__ = ["Finding", "Match", "Report", "InputError", "ground", "annotate_pdf", "__version__"]
18
+
19
+
20
+ def annotate_pdf(
21
+ src: "str | Path",
22
+ findings: Iterable[Finding],
23
+ dst: "str | Path",
24
+ *,
25
+ max_per_page: int = 20,
26
+ author: str = DEFAULT_AUTHOR,
27
+ lang: "str | None" = None,
28
+ ) -> Report:
29
+ """Highlight each finding's quote in `src` and save the result to `dst`.
30
+
31
+ No model is called. Quotes that cannot be found become one memo per page. The memo tags
32
+ ("[not found]", "[over cap]", "(loose match)") follow `lang` ("en" or "ko"); by default the
33
+ language set with `pdf_redpen.i18n.set_lang` (English unless changed).
34
+ """
35
+ from .pipeline import JsonProvider, run_pipeline
36
+
37
+ return run_pipeline(
38
+ Path(src), Path(dst), JsonProvider(findings), mode="apply",
39
+ max_per_page=max_per_page, author=author, lang=lang,
40
+ )
pdf_redpen/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
pdf_redpen/cli.py ADDED
@@ -0,0 +1,222 @@
1
+ """Command line: review, apply, clear, demo. Exit codes: 0 ok, 2 bad input, 1 unexpected."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from typing import Optional, Sequence
9
+
10
+ from . import __version__, prompts
11
+ from .i18n import LANGS, CodedError, lang_from_argv, resolve_lang, set_lang, t
12
+ from .llm import Client, MockClient, OpenAIChatClient, RecordingClient
13
+ from .models import PAGE_SKIPPED, InputError, findings_from_json
14
+ from .pipeline import JsonProvider, LLMProvider, run_pipeline
15
+ from .report import mock_notice, page_detail, recall_line, stage_line, summary_line, write_reports
16
+ from .writer import DEFAULT_AUTHOR, clear_annotations
17
+
18
+
19
+ def parse_pages(spec: Optional[str]) -> Optional[list[int]]:
20
+ """'1-3,5' -> [1, 2, 3, 5]."""
21
+ if not spec:
22
+ return None
23
+ pages: list[int] = []
24
+ try:
25
+ for part in spec.split(","):
26
+ if "-" in part:
27
+ a, b = part.split("-", 1)
28
+ lo, hi = int(a), int(b)
29
+ if lo > hi:
30
+ raise ValueError
31
+ pages += range(lo, hi + 1)
32
+ else:
33
+ pages.append(int(part))
34
+ except ValueError:
35
+ raise InputError("err.pages_format", spec=spec) from None
36
+ if any(p < 1 for p in pages):
37
+ raise InputError("err.pages_start")
38
+ return pages
39
+
40
+
41
+ def _progress(result) -> None:
42
+ """One stderr line per page; failures (and a failed recall turn) show their cause."""
43
+ if result.detail and result.status != "skipped_no_text_layer":
44
+ line = t("progress.line_detail", page=result.page, status=result.status,
45
+ detail=page_detail(result))
46
+ else:
47
+ line = t("progress.line", page=result.page, status=result.status)
48
+ print(line, file=sys.stderr)
49
+
50
+
51
+ def _finish(report, dst: Path, notice_to=None) -> int:
52
+ """Print the summary; return 1 when every page that was sent to the model failed.
53
+
54
+ The mock notice is printed here (only once a run really replayed answers), to `notice_to`.
55
+ """
56
+ if "mock" in report.mode:
57
+ print(mock_notice(), file=notice_to or sys.stderr)
58
+ json_path, md_path = write_reports(report, dst)
59
+ print(summary_line(report))
60
+ print(stage_line(report))
61
+ if report.recall_pass:
62
+ print(recall_line(report))
63
+ for path in (dst, json_path, md_path):
64
+ print(t("out.wrote", path=path))
65
+ attempted = [p for p in report.pages if p.status != PAGE_SKIPPED]
66
+ if report.error_pages:
67
+ if attempted and report.error_pages == len(attempted):
68
+ print(t("err.all_failed"), file=sys.stderr)
69
+ return 1
70
+ print(t("warn.some_failed", n=report.error_pages), file=sys.stderr)
71
+ return 0
72
+
73
+
74
+ def _default_out(src: Path, suffix: str) -> Path:
75
+ return src.with_name(f"{src.stem}.{suffix}.pdf")
76
+
77
+
78
+ def _check_out(src: Path, dst: Path) -> None:
79
+ if src.resolve() == dst.resolve():
80
+ raise InputError("err.overwrite")
81
+
82
+
83
+ def cmd_review(a: argparse.Namespace) -> int:
84
+ src = Path(a.input)
85
+ dst = Path(a.output) if a.output else _default_out(src, "reviewed")
86
+ _check_out(src, dst)
87
+ if a.mock and a.record:
88
+ raise InputError("err.mock_record")
89
+ instructions = prompts.DEFAULT_INSTRUCTIONS[a.lang]
90
+ if a.instructions:
91
+ ipath = Path(a.instructions)
92
+ if not ipath.is_file():
93
+ raise InputError("err.instructions_missing", path=ipath)
94
+ instructions = ipath.read_text(encoding="utf-8")
95
+ client: Client
96
+ if a.mock:
97
+ client, mode = MockClient(Path(a.mock)), "review (mock: practice replay)"
98
+ else:
99
+ client, mode = OpenAIChatClient.from_env(), "review"
100
+ if a.record:
101
+ client = RecordingClient(client, Path(a.record))
102
+ provider = LLMProvider(client, instructions, a.recall_pass, a.lang)
103
+ report = run_pipeline(src, dst, provider, mode=mode, max_per_page=a.max_highlights_per_page,
104
+ pages=parse_pages(a.pages), author=a.author, recall=a.recall_pass,
105
+ on_page_done=_progress)
106
+ return _finish(report, dst)
107
+
108
+
109
+ def cmd_apply(a: argparse.Namespace) -> int:
110
+ src, fpath = Path(a.input), Path(a.findings)
111
+ dst = Path(a.output)
112
+ _check_out(src, dst)
113
+ if not fpath.is_file():
114
+ raise InputError("err.findings_missing", path=fpath)
115
+ findings = findings_from_json(fpath.read_text(encoding="utf-8"), str(fpath))
116
+ report = run_pipeline(src, dst, JsonProvider(findings), mode="apply",
117
+ max_per_page=a.max_highlights_per_page, author=DEFAULT_AUTHOR)
118
+ return _finish(report, dst)
119
+
120
+
121
+ def cmd_clear(a: argparse.Namespace) -> int:
122
+ src, dst = Path(a.input), Path(a.output)
123
+ _check_out(src, dst)
124
+ removed = clear_annotations(src, dst, a.author)
125
+ print(t("out.removed", n=removed, author=a.author, dst=dst))
126
+ return 0
127
+
128
+
129
+ def cmd_demo(a: argparse.Namespace) -> int:
130
+ out = Path(a.out)
131
+ data = resources.files("pdf_redpen") / "data"
132
+ with resources.as_file(data / "sample_report.pdf") as sample, \
133
+ resources.as_file(data / "recorded") as recorded, \
134
+ resources.as_file(data / "review.md") as instr:
135
+ dst = out / "reviewed.pdf"
136
+ provider = LLMProvider(MockClient(recorded), Path(instr).read_text(encoding="utf-8"),
137
+ recall=True)
138
+ report = run_pipeline(sample, dst, provider, mode="review (mock: practice replay)",
139
+ max_per_page=a.max_highlights_per_page, recall=True,
140
+ on_page_done=_progress)
141
+ return _finish(report, dst, notice_to=sys.stdout)
142
+
143
+
144
+ def build_parser() -> argparse.ArgumentParser:
145
+ """Build the parser; help texts come from the current screen language (see i18n)."""
146
+ p = argparse.ArgumentParser(prog="pdf-redpen", description=t("help.description"))
147
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
148
+ # Screen language (messages, reports). Not the same as `review --lang` (prompt language).
149
+ p.add_argument("--lang", dest="ui_lang", choices=LANGS, default=None, help=t("help.lang_ui"))
150
+ sub = p.add_subparsers(dest="command", required=True)
151
+
152
+ cap = ["--max-highlights-per-page"]
153
+ r = sub.add_parser("review", help=t("help.review"))
154
+ r.add_argument("input")
155
+ r.add_argument("-o", "--output")
156
+ r.add_argument("--instructions", help=t("help.instructions"))
157
+ r.add_argument("--mock", metavar="DIR", help=t("help.mock"))
158
+ r.add_argument("--record", metavar="DIR", help=t("help.record"))
159
+ r.add_argument("--recall-pass", action="store_true", help=t("help.recall_pass"))
160
+ r.add_argument(*cap, type=int, default=20)
161
+ r.add_argument("--pages", help=t("help.pages"))
162
+ r.add_argument("--author", default=DEFAULT_AUTHOR)
163
+ r.add_argument("--lang", choices=("ko", "en"), default="ko", help=t("help.review_lang"))
164
+ r.set_defaults(func=cmd_review)
165
+
166
+ ap = sub.add_parser("apply", help=t("help.apply"))
167
+ ap.add_argument("input")
168
+ ap.add_argument("findings")
169
+ ap.add_argument("-o", "--output", required=True)
170
+ ap.add_argument(*cap, type=int, default=20)
171
+ ap.set_defaults(func=cmd_apply)
172
+
173
+ cl = sub.add_parser("clear", help=t("help.clear"))
174
+ cl.add_argument("input")
175
+ cl.add_argument("-o", "--output", required=True)
176
+ cl.add_argument("--author", default=DEFAULT_AUTHOR)
177
+ cl.set_defaults(func=cmd_clear)
178
+
179
+ d = sub.add_parser("demo", help=t("help.demo"))
180
+ d.add_argument("--out", default="out")
181
+ d.add_argument(*cap, type=int, default=20)
182
+ d.set_defaults(func=cmd_demo)
183
+ return p
184
+
185
+
186
+ def main(argv: Optional[Sequence[str]] = None) -> int:
187
+ args_list = list(sys.argv[1:] if argv is None else argv)
188
+ # Pick the screen language before parsing so --help and parse errors use it too.
189
+ previous = set_lang(resolve_lang(lang_from_argv(args_list)))
190
+ try:
191
+ return _run(args_list)
192
+ finally:
193
+ set_lang(previous) # do not leak the language into the caller (tests, library use)
194
+
195
+
196
+ def _run(args_list: list[str]) -> int:
197
+ args = build_parser().parse_args(args_list)
198
+ try:
199
+ return args.func(args)
200
+ except CodedError as exc: # InputError, LLMError: message id + params, shown in our language
201
+ print(t("err.prefix", message=exc.render()), file=sys.stderr)
202
+ return 2 if isinstance(exc, InputError) else 1
203
+ except OSError as exc: # unreadable/unwritable paths, full disk, ...
204
+ reason = exc.strerror or str(exc)
205
+ if exc.filename:
206
+ path = str(exc.filename)
207
+ if path.endswith(".part"): # internal temp file: show the path the user gave
208
+ path = path[: -len(".part")]
209
+ print(t("err.os", path=path, reason=reason), file=sys.stderr)
210
+ else:
211
+ print(t("err.os_nopath", reason=reason), file=sys.stderr)
212
+ return 2
213
+ except KeyboardInterrupt:
214
+ print(t("err.interrupted"), file=sys.stderr)
215
+ return 1
216
+ except Exception as exc: # last resort: no traceback for users
217
+ print(t("err.unexpected", name=type(exc).__name__, detail=exc), file=sys.stderr)
218
+ return 1
219
+
220
+
221
+ if __name__ == "__main__":
222
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ 1쪽에서 문제가 되는 문장을 찾았습니다.
2
+
3
+ | # | 원문 인용 | 문제점 | 심각도 |
4
+ |---|-----------|--------|--------|
5
+ | 1 | "근거 표가 제시되지 않았습니다" | 매출 증가 주장에 근거 표가 없음 | 높음 |
6
+ | 2 | 신규 고객 수는 1,200명으로 | 다른 절(1,300명)과 숫자가 맞지 않음 | 높음 |
7
+ | 이 줄은 | 열 수가 맞지 않는 깨진 행 |
8
+ | 3 | `The quarterly revenue grew by fifteen-percent!` | Cite the table for the growth figure | 중간 |
9
+ | 4 | intentionally wraps across several lines so that a quoted passage will NOT match exactly | Tail of the quote is paraphrased by the model | 낮음 |
@@ -0,0 +1,4 @@
1
+ | Quote (missed only) | Comment | Severity |
2
+ |:--|:--|:--|
3
+ | 근거 표가 제시되지 않았습니다. | 첫 지적과 같은 문장 (중복) | High |
4
+ | 다른절에서는1,300명이라고 | 1,200명과 충돌하는 두 번째 수치 | High |
@@ -0,0 +1,6 @@
1
+ | 인용 | 지적 | 심각도 |
2
+ |---|---|---|
3
+ | 결론 문장은 다시 한번 결론 문장으로 반복됩니다 | 같은 문장이 반복됨 (첫 번째) | 낮음 |
4
+ | 결론 문장은 다시 한번 결론 문장으로 반복됩니다 | 같은 문장이 반복됨 (두 번째 출현) | 낮음 |
5
+ | 2025년 매출이 전년 대비 15% 증가했다고 | 모델이 쪽수를 틀림 (실제로는 1쪽 문장) | 중간 |
6
+ | 모든 위험이 완전히 제거되었다고 보고합니다 | 본문에 없는 문장 (모델이 만들어 낸 인용) | 높음 |
@@ -0,0 +1 @@
1
+ 추가로 놓친 지적은 없습니다.
@@ -0,0 +1,5 @@
1
+ 이 문서는 내부 검토용 보고서 초안입니다. 다음을 중점적으로 검토하세요.
2
+
3
+ - 숫자나 통계가 다른 곳과 맞지 않는 부분
4
+ - 근거 없이 단정하는 표현
5
+ - 서로 모순되는 문장
Binary file
@@ -0,0 +1,135 @@
1
+ """Five-stage quote locator.
2
+
3
+ Models rarely copy a sentence exactly: they change whitespace, drop Korean word
4
+ spacing, alter punctuation, paraphrase the tail, or name the wrong page. Each
5
+ stage below is looser than the one before; the first stage that finds a span of
6
+ characters that is not already used wins. Every stage searches a *projected*
7
+ string and keeps a map back to the original Char indices, so the highlight is
8
+ always drawn from real glyph boxes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Callable, Optional
14
+
15
+ from .models import Match
16
+ from .textlayer import PageText, fold
17
+
18
+ PREFIX_LEN = 25
19
+ ALNUM_MIN = 4
20
+ STAGE_ORDER = ("exact", "ws_collapse", "no_space", "alnum_only", "prefix25")
21
+
22
+
23
+ def _find_unused(hay: str, needle: str, mapping: list, used: set[int]) -> Optional[list[int]]:
24
+ """First occurrence of needle whose chars are all still unused."""
25
+ start = 0
26
+ while needle:
27
+ pos = hay.find(needle, start)
28
+ if pos < 0:
29
+ return None
30
+ span = {mapping[k] for k in range(pos, pos + len(needle)) if mapping[k] is not None}
31
+ if span and not (span & used):
32
+ return sorted(span)
33
+ start = pos + 1
34
+ return None
35
+
36
+
37
+ def _project(flat: str, idx: list, keep: Callable[[str], bool]) -> tuple[str, list]:
38
+ s, m = [], []
39
+ for ch, i in zip(flat, idx):
40
+ if keep(ch):
41
+ s.append(ch)
42
+ m.append(i)
43
+ return "".join(s), m
44
+
45
+
46
+ def _collapse(flat: str, idx: list) -> tuple[str, list]:
47
+ """Replace every whitespace run by a single space."""
48
+ s, m = [], []
49
+ for ch, i in zip(flat, idx):
50
+ if ch.isspace():
51
+ if s and s[-1] == " ":
52
+ continue
53
+ s.append(" ")
54
+ m.append(i)
55
+ else:
56
+ s.append(ch)
57
+ m.append(i)
58
+ return "".join(s), m
59
+
60
+
61
+ def ground(page: PageText, quote: str, used: set[int]) -> Optional[Match]:
62
+ """Find `quote` on one page, avoiding char indices in `used`. None if absent."""
63
+ quote = fold(quote).strip()
64
+ if not quote:
65
+ return None
66
+ flat, idx = page.flat, page.index_map
67
+
68
+ # 1. exact substring
69
+ hit = _find_unused(flat, quote, idx, used)
70
+ if hit:
71
+ return Match("exact", hit)
72
+
73
+ # 2. whitespace runs collapsed (double spaces, copied line breaks)
74
+ cflat, cidx = _collapse(flat, idx)
75
+ cq = re.sub(r"\s+", " ", quote)
76
+ hit = _find_unused(cflat, cq, cidx, used)
77
+ if hit:
78
+ return Match("ws_collapse", hit)
79
+
80
+ # 3. first 25 chars, extended to the quote's length (tail was paraphrased)
81
+ prefix_match: Optional[Match] = None
82
+ if len(cq) > PREFIX_LEN:
83
+ pre = cq[:PREFIX_LEN]
84
+ start = 0
85
+ while True:
86
+ pos = cflat.find(pre, start)
87
+ if pos < 0:
88
+ break
89
+ end = min(len(cflat), pos + len(cq))
90
+ span = {cidx[k] for k in range(pos, end) if cidx[k] is not None}
91
+ if span and not (span & used):
92
+ prefix_match = Match("prefix25", sorted(span))
93
+ break
94
+ start = pos + 1
95
+
96
+ # 4. all whitespace removed (Korean spacing errors)
97
+ sflat, sidx = _project(flat, idx, lambda ch: not ch.isspace())
98
+ sq = re.sub(r"\s+", "", quote)
99
+ hit = _find_unused(sflat, sq, sidx, used)
100
+ if hit:
101
+ return Match("no_space", hit)
102
+
103
+ # 5. letters and digits only (punctuation changed)
104
+ aflat, aidx = _project(flat, idx, lambda ch: ch.isalnum())
105
+ aq = "".join(ch for ch in quote if ch.isalnum())
106
+ if len(aq) >= ALNUM_MIN:
107
+ hit = _find_unused(aflat, aq, aidx, used)
108
+ if hit:
109
+ return Match("alnum_only", hit)
110
+
111
+ # The prefix match is the loosest guess (it may paint text the model never
112
+ # quoted), so it is only used when the stricter stages 4 and 5 found nothing.
113
+ return prefix_match
114
+
115
+
116
+ def ground_finding(
117
+ get_page: Callable[[int], PageText],
118
+ n_pages: int,
119
+ page_no: int,
120
+ quote: str,
121
+ used: dict[int, set[int]],
122
+ ) -> Optional[tuple[int, Match]]:
123
+ """Search the claimed page first, then the previous and the next page.
124
+
125
+ Returns (actual 1-based page, Match) or None. `used` holds the already
126
+ painted char indices per 1-based page and is NOT modified here.
127
+ """
128
+ page_no = min(max(page_no, 1), n_pages)
129
+ for p in (page_no, page_no - 1, page_no + 1):
130
+ if not (1 <= p <= n_pages):
131
+ continue
132
+ m = ground(get_page(p), quote, used.setdefault(p, set()))
133
+ if m:
134
+ return p, m
135
+ return None