open-code-review-toolkit 0.1.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.
- ocr_toolkit/__init__.py +1 -0
- ocr_toolkit/_version.py +24 -0
- ocr_toolkit/cli.py +56 -0
- ocr_toolkit/common/__init__.py +1 -0
- ocr_toolkit/common/language.py +60 -0
- ocr_toolkit/common/markdown.py +214 -0
- ocr_toolkit/common/redaction.py +324 -0
- ocr_toolkit/config_writer.py +106 -0
- ocr_toolkit/configure.py +194 -0
- ocr_toolkit/context/__init__.py +1 -0
- ocr_toolkit/context/__main__.py +8 -0
- ocr_toolkit/context/ansible.py +561 -0
- ocr_toolkit/context/categorize.py +162 -0
- ocr_toolkit/context/instructions.py +269 -0
- ocr_toolkit/context/manifests.py +459 -0
- ocr_toolkit/context/planner.py +227 -0
- ocr_toolkit/context/render.py +955 -0
- ocr_toolkit/context/repo.py +672 -0
- ocr_toolkit/context/settings.py +97 -0
- ocr_toolkit/mcp_config.py +257 -0
- ocr_toolkit/posting/__init__.py +1 -0
- ocr_toolkit/posting/__main__.py +8 -0
- ocr_toolkit/posting/comments.py +87 -0
- ocr_toolkit/posting/formatting.py +755 -0
- ocr_toolkit/posting/gitlab.py +853 -0
- ocr_toolkit/posting/markers.py +284 -0
- ocr_toolkit/posting/payloads.py +141 -0
- ocr_toolkit/posting/result.py +116 -0
- ocr_toolkit/posting/settings.py +181 -0
- ocr_toolkit/posting/snapshot.py +468 -0
- ocr_toolkit/posting/workflow.py +873 -0
- ocr_toolkit/preflight.py +395 -0
- ocr_toolkit/py.typed +1 -0
- open_code_review_toolkit-0.1.0.dist-info/METADATA +283 -0
- open_code_review_toolkit-0.1.0.dist-info/RECORD +38 -0
- open_code_review_toolkit-0.1.0.dist-info/WHEEL +4 -0
- open_code_review_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- open_code_review_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""OCR bot note markers, fingerprints, and ownership checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from ocr_toolkit.posting.comments import clean_text, code_text, comment_line, line_number
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ocr_toolkit.posting.gitlab import GitLabConfig
|
|
13
|
+
|
|
14
|
+
MARKER = "<!-- open-code-review-bot -->"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MARKER_WITH_FINGERPRINT_RE = re.compile(
|
|
18
|
+
r"^<!-- open-code-review-bot(?:\s+fp=([0-9a-f]{8,64}))?\s*-->"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
OCR_REPLY_COMMAND_RE = re.compile(r"(?i)\A[ \t]*/ocr[ \t]+(skip|keep)[ \t]*(?:\r?\n[ \t]*)*\Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
FINGERPRINT_LEN = 32 # hex characters (= 16 raw bytes from blake2b)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _digest_payload(parts: list[str], digest_size: int = FINGERPRINT_LEN // 2) -> str:
|
|
29
|
+
"""Return a blake2b digest for normalized fingerprint fields."""
|
|
30
|
+
|
|
31
|
+
return hashlib.blake2b("\n".join(parts).encode("utf-8"), digest_size=digest_size).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalized_payload_parts(
|
|
35
|
+
comment: dict[str, Any], *, line_based: bool = False
|
|
36
|
+
) -> list[str] | None:
|
|
37
|
+
"""Build shared canonical fingerprint fields, or None for invalid input."""
|
|
38
|
+
|
|
39
|
+
path = clean_text(comment.get("path"))
|
|
40
|
+
if not path:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
content = clean_text(comment.get("content"))
|
|
44
|
+
suggestion = code_text(comment.get("suggestion_code"))
|
|
45
|
+
existing = code_text(comment.get("existing_code"))
|
|
46
|
+
category = clean_text(comment.get("category"))
|
|
47
|
+
rule_id = clean_text(comment.get("rule_id"))
|
|
48
|
+
line = comment_line(comment)
|
|
49
|
+
if line_based:
|
|
50
|
+
start_line = line_number(comment.get("start_line") or comment.get("line"))
|
|
51
|
+
return [
|
|
52
|
+
path.strip(),
|
|
53
|
+
str(start_line),
|
|
54
|
+
rule_id.lower(),
|
|
55
|
+
category.lower(),
|
|
56
|
+
" ".join(content.lower().split()),
|
|
57
|
+
" ".join(suggestion.split()),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
parts = [
|
|
61
|
+
path.strip(),
|
|
62
|
+
rule_id.lower(),
|
|
63
|
+
category.lower(),
|
|
64
|
+
" ".join(content.lower().split()),
|
|
65
|
+
" ".join(existing.split()),
|
|
66
|
+
" ".join(suggestion.split()),
|
|
67
|
+
]
|
|
68
|
+
if not existing.strip():
|
|
69
|
+
parts.append(str(line))
|
|
70
|
+
|
|
71
|
+
return parts
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fingerprint_payload(comment: dict[str, Any]) -> bytes | None:
|
|
75
|
+
"""Build the canonical payload bytes for a fingerprint, or None."""
|
|
76
|
+
|
|
77
|
+
parts = _normalized_payload_parts(comment)
|
|
78
|
+
if parts is None:
|
|
79
|
+
return None
|
|
80
|
+
return "\n".join(parts).encode("utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _line_based_fingerprint_payload(comment: dict[str, Any]) -> bytes | None:
|
|
84
|
+
"""Build the previous line-based payload for compatibility checks."""
|
|
85
|
+
|
|
86
|
+
parts = _normalized_payload_parts(comment, line_based=True)
|
|
87
|
+
if parts is None:
|
|
88
|
+
return None
|
|
89
|
+
return "\n".join(parts).encode("utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def comment_fingerprint(comment: dict[str, Any]) -> str | None:
|
|
93
|
+
"""Return a stable short hash describing one OCR finding.
|
|
94
|
+
|
|
95
|
+
The hash combines path, normalized review text, and the commented code
|
|
96
|
+
fragment (`existing_code`) when OCR provides it. It omits the anchor line
|
|
97
|
+
only when that code anchor exists, so resolved findings and `/ocr skip`
|
|
98
|
+
survive ordinary line shifts without broadening generic no-code comments
|
|
99
|
+
across a whole file. Returns None for comments missing a usable path.
|
|
100
|
+
|
|
101
|
+
Uses 16 raw bytes (32 hex chars) to keep birthday-collision risk
|
|
102
|
+
well below MR sizes the bot is likely to see. The marker regex
|
|
103
|
+
accepts 8–64 hex chars, so the wider hash is forward-compatible.
|
|
104
|
+
Older markers used a line-based payload; filter code checks those
|
|
105
|
+
compatibility hashes alongside this value.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
payload = _fingerprint_payload(comment)
|
|
109
|
+
if payload is None:
|
|
110
|
+
return None
|
|
111
|
+
return hashlib.blake2b(payload, digest_size=FINGERPRINT_LEN // 2).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def line_based_comment_fingerprint(comment: dict[str, Any]) -> str | None:
|
|
115
|
+
"""Return the previous 32-hex line-based fingerprint for compatibility."""
|
|
116
|
+
|
|
117
|
+
payload = _line_based_fingerprint_payload(comment)
|
|
118
|
+
if payload is None:
|
|
119
|
+
return None
|
|
120
|
+
return hashlib.blake2b(payload, digest_size=FINGERPRINT_LEN // 2).hexdigest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def occurrence_comment_fingerprint(comment: dict[str, Any], occurrence_index: int) -> str | None:
|
|
124
|
+
"""Return a fingerprint that separates repeated identical findings."""
|
|
125
|
+
|
|
126
|
+
base = comment_fingerprint(comment)
|
|
127
|
+
if base is None or occurrence_index <= 0:
|
|
128
|
+
return base
|
|
129
|
+
|
|
130
|
+
return _digest_payload([base, f"occurrence:{occurrence_index}"])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def annotate_comment_fingerprints(
|
|
134
|
+
comments: list[dict[str, Any]], existing_key: str = "_ocr_fingerprint"
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Attach occurrence-aware fingerprints to OCR comments in posting order."""
|
|
137
|
+
|
|
138
|
+
grouped: dict[str, list[tuple[int, dict[str, Any]]]] = {}
|
|
139
|
+
for index, comment in enumerate(comments):
|
|
140
|
+
base = comment_fingerprint(comment)
|
|
141
|
+
if base is None:
|
|
142
|
+
comment.pop(existing_key, None)
|
|
143
|
+
continue
|
|
144
|
+
grouped.setdefault(base, []).append((index, comment))
|
|
145
|
+
|
|
146
|
+
for base, indexed_comments in grouped.items():
|
|
147
|
+
ordered_comments = sorted(
|
|
148
|
+
indexed_comments,
|
|
149
|
+
key=lambda item: (
|
|
150
|
+
comment_line(item[1]),
|
|
151
|
+
clean_text(item[1].get("path")),
|
|
152
|
+
code_text(item[1].get("existing_code")),
|
|
153
|
+
code_text(item[1].get("suggestion_code")),
|
|
154
|
+
item[0],
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
for occurrence, (_index, comment) in enumerate(ordered_comments):
|
|
158
|
+
comment[existing_key] = occurrence_comment_fingerprint(comment, occurrence)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def legacy_comment_fingerprint(comment: dict[str, Any]) -> str | None:
|
|
162
|
+
"""Return the pre-migration 16-hex fingerprint for one OCR finding.
|
|
163
|
+
|
|
164
|
+
Why: markers produced before the 8→16 byte digest migration are still
|
|
165
|
+
stored in resolved/skip-flagged threads. Without this companion,
|
|
166
|
+
`/ocr skip` decisions made under the old length would silently stop
|
|
167
|
+
suppressing their findings after the migration. Same line-based
|
|
168
|
+
payload as the previous fingerprint implementation, narrower digest.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
payload = _line_based_fingerprint_payload(comment)
|
|
172
|
+
if payload is None:
|
|
173
|
+
return None
|
|
174
|
+
return hashlib.blake2b(payload, digest_size=8).hexdigest()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def comment_fingerprint_candidates(comment: dict[str, Any]) -> set[str]:
|
|
178
|
+
"""Return current and backward-compatible fingerprints for a comment."""
|
|
179
|
+
|
|
180
|
+
annotated = comment.get("_ocr_fingerprint")
|
|
181
|
+
if isinstance(annotated, str) and annotated:
|
|
182
|
+
# For duplicate findings, the annotated marker is the current run's
|
|
183
|
+
# identity. Do not also include the base hash: a skip on the first
|
|
184
|
+
# duplicate would suppress every later duplicate in the same file.
|
|
185
|
+
candidates = {annotated}
|
|
186
|
+
if line_number(comment.get("start_line") or comment.get("line")) > 0:
|
|
187
|
+
line_fingerprint = line_based_comment_fingerprint(comment)
|
|
188
|
+
legacy_fingerprint = legacy_comment_fingerprint(comment)
|
|
189
|
+
if line_fingerprint:
|
|
190
|
+
candidates.add(line_fingerprint)
|
|
191
|
+
if legacy_fingerprint:
|
|
192
|
+
candidates.add(legacy_fingerprint)
|
|
193
|
+
return {fingerprint for fingerprint in candidates if fingerprint}
|
|
194
|
+
|
|
195
|
+
candidates = set(
|
|
196
|
+
filter(
|
|
197
|
+
None,
|
|
198
|
+
(
|
|
199
|
+
comment_fingerprint(comment),
|
|
200
|
+
line_based_comment_fingerprint(comment),
|
|
201
|
+
legacy_comment_fingerprint(comment),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return {fingerprint for fingerprint in candidates if fingerprint}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_marker(fingerprint: str | None) -> str:
|
|
209
|
+
"""Render the OCR marker, embedding the fingerprint when available."""
|
|
210
|
+
|
|
211
|
+
if fingerprint:
|
|
212
|
+
return f"<!-- open-code-review-bot fp={fingerprint} -->"
|
|
213
|
+
return MARKER
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def note_starts_with_marker(body: str) -> bool:
|
|
217
|
+
"""Return whether a note body is owned by this script format."""
|
|
218
|
+
|
|
219
|
+
return bool(MARKER_WITH_FINGERPRINT_RE.match(body))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fingerprint_from_marker(body: str) -> str | None:
|
|
223
|
+
"""Return the fingerprint embedded in a bot marker, if any."""
|
|
224
|
+
|
|
225
|
+
match = MARKER_WITH_FINGERPRINT_RE.match(body)
|
|
226
|
+
if not match:
|
|
227
|
+
return None
|
|
228
|
+
return match.group(1)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def author_id_from_note(note: dict[str, Any]) -> int | None:
|
|
232
|
+
"""Extract author id from a published note or draft note object."""
|
|
233
|
+
|
|
234
|
+
raw_author_id = note.get("author_id")
|
|
235
|
+
|
|
236
|
+
if raw_author_id is None:
|
|
237
|
+
author = note.get("author")
|
|
238
|
+
if isinstance(author, dict):
|
|
239
|
+
raw_author_id = author.get("id")
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
return int(raw_author_id)
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def is_diff_note(note: dict[str, Any]) -> bool:
|
|
248
|
+
"""Return True if a note belongs to an inline (diff) discussion.
|
|
249
|
+
|
|
250
|
+
Such notes are owned by the /discussions cycle and must NOT be
|
|
251
|
+
deleted via DELETE /notes/{id}, otherwise a reviewer-preserved
|
|
252
|
+
thread (resolved, /ocr skip, /ocr keep) gets destroyed despite our
|
|
253
|
+
decision to keep it.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
if note.get("type") == "DiffNote":
|
|
257
|
+
return True
|
|
258
|
+
if isinstance(note.get("position"), dict):
|
|
259
|
+
return True
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_own_bot_note(config: GitLabConfig, note: dict[str, Any], body_field: str) -> bool:
|
|
264
|
+
"""Return whether a note belongs to this bot and this script.
|
|
265
|
+
|
|
266
|
+
Authorship must be positively verifiable. If `current_user_id` could
|
|
267
|
+
not be resolved (see `print_user_id_failure_banner`) or the note has
|
|
268
|
+
no author id, refuse to claim ownership — otherwise this bot might
|
|
269
|
+
delete notes written by other users whose body happens to start with
|
|
270
|
+
the same MARKER.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
body = str(note.get(body_field) or "")
|
|
274
|
+
if not note_starts_with_marker(body):
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
if config.current_user_id is None:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
actual_author_id = author_id_from_note(note)
|
|
281
|
+
if actual_author_id is None:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
return actual_author_id == config.current_user_id
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""GitLab note payload size limiting and marker wrapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ocr_toolkit.common.markdown import markdown_code_block
|
|
6
|
+
from ocr_toolkit.posting.markers import build_marker
|
|
7
|
+
from ocr_toolkit.posting.settings import (
|
|
8
|
+
MAX_INLINE_NOTE_CHARS,
|
|
9
|
+
MAX_NOTE_CHARS,
|
|
10
|
+
SUGGESTION_BLOCK_RE,
|
|
11
|
+
TRUNCATION_NOTICE,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fits_limit(text: str, max_chars: int) -> bool:
|
|
16
|
+
return len(text) <= max_chars and len(text.encode("utf-8")) <= max_chars
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _utf8_prefix(text: str, max_bytes: int) -> str:
|
|
20
|
+
if max_bytes <= 0:
|
|
21
|
+
return ""
|
|
22
|
+
return text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def truncate_note_body(body: str, max_chars: int = MAX_NOTE_CHARS) -> str:
|
|
26
|
+
"""Truncate a GitLab note body without splitting Markdown structures."""
|
|
27
|
+
|
|
28
|
+
if _fits_limit(body, max_chars):
|
|
29
|
+
return body
|
|
30
|
+
|
|
31
|
+
notice = f"{TRUNCATION_NOTICE}\n\nRaw Markdown excerpt follows."
|
|
32
|
+
budget = max(0, max_chars - len(notice.encode("utf-8")) - 2)
|
|
33
|
+
excerpt = _utf8_prefix(body, budget).rstrip()
|
|
34
|
+
|
|
35
|
+
while excerpt:
|
|
36
|
+
result = f"{notice}\n\n{markdown_code_block('markdown', excerpt)}"
|
|
37
|
+
if _fits_limit(result, max_chars):
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
overflow = max(len(result) - max_chars, len(result.encode("utf-8")) - max_chars)
|
|
41
|
+
excerpt = _utf8_prefix(
|
|
42
|
+
excerpt, max(0, len(excerpt.encode("utf-8")) - overflow - 1)
|
|
43
|
+
).rstrip()
|
|
44
|
+
|
|
45
|
+
return notice[:max_chars]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def truncate_plain_text(text: str, max_chars: int) -> str:
|
|
49
|
+
"""Truncate plain text with a short notice."""
|
|
50
|
+
|
|
51
|
+
if _fits_limit(text, max_chars):
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
notice = "\n\nText was truncated before publishing to GitLab."
|
|
55
|
+
if max_chars <= len(notice.encode("utf-8")):
|
|
56
|
+
return _utf8_prefix(notice, max_chars)
|
|
57
|
+
|
|
58
|
+
budget = max(0, max_chars - len(notice.encode("utf-8")))
|
|
59
|
+
return _utf8_prefix(text, budget).rstrip() + notice
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def truncate_code_text(text: str, max_chars: int) -> str:
|
|
63
|
+
"""Truncate code text while preserving indentation as much as possible."""
|
|
64
|
+
|
|
65
|
+
if max_chars <= 0:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
if _fits_limit(text, max_chars):
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
notice = "\n# ... truncated before publishing to GitLab ..."
|
|
72
|
+
if max_chars <= len(notice.encode("utf-8")):
|
|
73
|
+
return _utf8_prefix(notice, max_chars)
|
|
74
|
+
|
|
75
|
+
budget = max(0, max_chars - len(notice.encode("utf-8")))
|
|
76
|
+
return _utf8_prefix(text, budget).rstrip("\n") + notice
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def limit_inline_body(body: str, max_chars: int = MAX_INLINE_NOTE_CHARS) -> str:
|
|
80
|
+
"""Limit inline discussion body without breaking GitLab suggestion blocks.
|
|
81
|
+
|
|
82
|
+
If the inline body is too large, drop the suggestion block first. If the
|
|
83
|
+
plain body is still too large, truncate it as plain Markdown text.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
if _fits_limit(body, max_chars):
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
match = SUGGESTION_BLOCK_RE.search(body)
|
|
90
|
+
if match:
|
|
91
|
+
body_without_suggestion = body[: match.start()] + (
|
|
92
|
+
"\n\nSuggestion block was omitted because the inline comment exceeded "
|
|
93
|
+
"the safe GitLab note size."
|
|
94
|
+
)
|
|
95
|
+
if _fits_limit(body_without_suggestion, max_chars):
|
|
96
|
+
return body_without_suggestion
|
|
97
|
+
|
|
98
|
+
body = body_without_suggestion
|
|
99
|
+
|
|
100
|
+
return truncate_plain_text(body, max_chars)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def limit_regular_note_body(body: str, max_chars: int = MAX_NOTE_CHARS) -> str:
|
|
104
|
+
"""Limit a regular MR note body before calling the GitLab API."""
|
|
105
|
+
|
|
106
|
+
return truncate_note_body(body, max_chars=max_chars)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def note_body_budget(max_chars: int, fingerprint: str | None = None) -> int:
|
|
110
|
+
"""Return the body budget after reserving space for the bot marker."""
|
|
111
|
+
|
|
112
|
+
return max(0, max_chars - len(build_marker(fingerprint)) - 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_marked_note_body(
|
|
116
|
+
body: str,
|
|
117
|
+
*,
|
|
118
|
+
fingerprint: str | None = None,
|
|
119
|
+
max_chars: int = MAX_NOTE_CHARS,
|
|
120
|
+
inline: bool = False,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Build the exact GitLab note payload, including marker and size limit."""
|
|
123
|
+
|
|
124
|
+
marker = build_marker(fingerprint)
|
|
125
|
+
if max_chars < len(marker) + 1:
|
|
126
|
+
raise ValueError("GitLab note payload budget is too small for OCR marker")
|
|
127
|
+
|
|
128
|
+
budget = note_body_budget(max_chars, fingerprint)
|
|
129
|
+
limited_body = (
|
|
130
|
+
limit_inline_body(body, max_chars=budget)
|
|
131
|
+
if inline
|
|
132
|
+
else limit_regular_note_body(body, max_chars=budget)
|
|
133
|
+
)
|
|
134
|
+
payload = f"{marker}\n{limited_body}"
|
|
135
|
+
if _fits_limit(payload, max_chars):
|
|
136
|
+
return payload
|
|
137
|
+
|
|
138
|
+
# Defensive fallback: the limiting helpers should already respect
|
|
139
|
+
# the marker-aware budget, but never send an over-limit API payload.
|
|
140
|
+
limited_body = truncate_plain_text(limited_body, budget)
|
|
141
|
+
return f"{marker}\n{limited_body}"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""OCR result artifact loading and provider failure classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ocr_toolkit.common.redaction import sanitize_ocr_value
|
|
12
|
+
from ocr_toolkit.posting.comments import clean_text
|
|
13
|
+
from ocr_toolkit.posting.settings import max_result_bytes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OcrResultMissing(Exception):
|
|
17
|
+
"""The OCR result artifact is missing or unreadable on disk."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OcrResultMalformed(Exception):
|
|
21
|
+
"""The OCR result artifact exists but is not valid JSON."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OcrResultTooLarge(Exception):
|
|
25
|
+
"""The OCR result artifact exceeds the configured safety limit."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_ocr_result(path: Path) -> Any:
|
|
29
|
+
"""Load OCR JSON result from disk.
|
|
30
|
+
|
|
31
|
+
Raises `OcrResultMissing` when the artifact is absent or
|
|
32
|
+
unreadable, `OcrResultTooLarge` before reading a runaway artifact,
|
|
33
|
+
and `OcrResultMalformed` when the contents are not valid JSON. The
|
|
34
|
+
caller routes these failure modes differently: a missing artifact
|
|
35
|
+
looks like an OCR crash, while an oversized or malformed artifact is
|
|
36
|
+
a controlled OCR output contract violation.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
size = path.stat().st_size
|
|
41
|
+
except FileNotFoundError as exc:
|
|
42
|
+
raise OcrResultMissing(str(exc)) from exc
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise OcrResultMissing(str(exc)) from exc
|
|
45
|
+
|
|
46
|
+
limit = max_result_bytes()
|
|
47
|
+
if size > limit:
|
|
48
|
+
raise OcrResultTooLarge(
|
|
49
|
+
f"OCR result JSON is {size} bytes, above OCR_MAX_RESULT_BYTES={limit}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with path.open("rb") as handle:
|
|
54
|
+
data = handle.read(limit + 1)
|
|
55
|
+
except FileNotFoundError as exc:
|
|
56
|
+
raise OcrResultMissing(str(exc)) from exc
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise OcrResultMissing(str(exc)) from exc
|
|
59
|
+
if len(data) > limit:
|
|
60
|
+
raise OcrResultTooLarge(f"OCR result JSON grew above OCR_MAX_RESULT_BYTES={limit}")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
text = data.decode("utf-8")
|
|
64
|
+
except UnicodeDecodeError as exc:
|
|
65
|
+
raise OcrResultMalformed(str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return sanitize_ocr_value(json.loads(text))
|
|
69
|
+
except (json.JSONDecodeError, RecursionError) as exc:
|
|
70
|
+
raise OcrResultMalformed(str(exc)) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
LLM_BILLING_FAILURE_RE = re.compile(
|
|
74
|
+
r"(?i)\b("
|
|
75
|
+
r"(?:http\s*)?status(?:[_\s]*code)?[\"']?\s*[:=]\s*[\"']?402|"
|
|
76
|
+
r"code[\"']?\s*[:=]\s*[\"']?402|payment required|insufficient[_ -]?funds|insufficient user balance|"
|
|
77
|
+
r"insufficient balance|insufficient[_ -]?quota|quota[_ -]?exceeded|"
|
|
78
|
+
r"out of credits|credit balance"
|
|
79
|
+
r")\b"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ocr_warning_text(warning: Any) -> str:
|
|
84
|
+
"""Return warning text relevant for provider failure classification."""
|
|
85
|
+
|
|
86
|
+
if isinstance(warning, dict):
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
for key in ("type", "message", "code", "status", "status_code", "detail"):
|
|
89
|
+
text = clean_text(warning.get(key))
|
|
90
|
+
if text:
|
|
91
|
+
parts.append(f"{key}: {text}" if key in {"code", "status", "status_code"} else text)
|
|
92
|
+
for key in ("error", "details"):
|
|
93
|
+
nested = warning.get(key)
|
|
94
|
+
if isinstance(nested, dict):
|
|
95
|
+
text = ocr_warning_text(nested)
|
|
96
|
+
if text:
|
|
97
|
+
parts.append(text)
|
|
98
|
+
else:
|
|
99
|
+
text = clean_text(nested)
|
|
100
|
+
if text:
|
|
101
|
+
parts.append(text)
|
|
102
|
+
return "\n".join(parts)[:4000]
|
|
103
|
+
if isinstance(warning, list):
|
|
104
|
+
return "\n".join(text for value in warning[:40] if (text := ocr_warning_text(value)))[:4000]
|
|
105
|
+
return clean_text(warning)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def llm_billing_failure_warnings(warnings: Sequence[Any]) -> list[str]:
|
|
109
|
+
"""Return OCR warnings that indicate LLM provider billing/quota failure."""
|
|
110
|
+
|
|
111
|
+
matches: list[str] = []
|
|
112
|
+
for warning in warnings:
|
|
113
|
+
text = ocr_warning_text(warning)
|
|
114
|
+
if text and LLM_BILLING_FAILURE_RE.search(text):
|
|
115
|
+
matches.append(text)
|
|
116
|
+
return matches
|