bha-Search / app /proofread_core.py
vomebook's picture
Refactor API modules and optimize runtime image
aa0a3e7 verified
Raw
History Blame Contribute Delete
16.8 kB
import re
from typing import Any, AbstractSet
from urllib.parse import unquote_to_bytes
def utf16_length(value: str) -> int:
return len(value.encode("utf-16-le")) // 2
def delta_edit_cost(delta: str, original: str) -> int | None:
if not isinstance(delta, str):
return None
consumed = 0
cost = 0
for token in delta.split("\t") if delta else []:
if not token:
return None
operation = token[0]
value = token[1:]
if operation in {"=", "-"}:
if not value.isdigit():
return None
size = int(value)
consumed += size
if operation == "-":
cost += size
elif operation == "+":
try:
inserted = unquote_to_bytes(value.replace("+", "%2B")).decode("utf-8")
except (UnicodeDecodeError, ValueError):
return None
cost += utf16_length(inserted)
else:
return None
return cost if consumed == utf16_length(original) else None
def diff_cost(delta: str) -> int | None:
if not isinstance(delta, str):
return None
total = 0
for token in delta.split("\t") if delta else []:
if not token or token[0] not in {"=", "-", "+"}:
return None
if token[0] in {"=", "-"}:
if not token[1:].isdigit():
return None
if token[0] == "-":
total += int(token[1:])
else:
try:
total += utf16_length(unquote_to_bytes(token[1:].replace("+", "%2B")).decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
return total
def _patch_index(raw: Any, minimum: int, maximum: int) -> int:
if not isinstance(raw, str) or not re.fullmatch(r"0|[1-9][0-9]*", raw):
raise ValueError("patch index is invalid")
value = int(raw)
if value < minimum or value > maximum:
raise ValueError("patch index is out of range")
return value
def _content_part(value: Any, part_types: AbstractSet[str]) -> dict[str, str]:
if not isinstance(value, dict) or set(value) != {"text", "type"}:
raise ValueError("inserted part is invalid")
if not isinstance(value["text"], str) or value["type"] not in part_types:
raise ValueError("inserted part is invalid")
if len(value["text"]) > 20000:
raise ValueError("inserted part is too long")
return {"text": value["text"], "type": value["type"]}
def validate_proofread_patch(
patch: Any,
article: dict[str, Any],
part_types: AbstractSet[str],
) -> dict[str, Any]:
if not isinstance(patch, dict) or patch.get("version") != 2:
raise ValueError("invalid OCR patch")
if set(patch) - {"version", "parts", "comments", "description", "newComments"}:
raise ValueError("invalid OCR patch fields")
parts = patch.get("parts")
comments = patch.get("comments")
if not isinstance(parts, dict) or not isinstance(comments, dict):
raise ValueError("patch parts and comments must be objects")
article_parts = article.get("parts") if isinstance(article.get("parts"), list) else []
article_comments = article.get("comments") if isinstance(article.get("comments"), list) else []
description = patch.get("description", "")
normalized: dict[str, Any] = {"version": 2, "parts": {}, "comments": {}, "description": ""}
if not isinstance(description, str):
raise ValueError("patch description must be a string")
if description:
original_description = str(article.get("description") or "")
if delta_edit_cost(description, original_description) is None:
raise ValueError("description diff is invalid")
if apply_text_delta(original_description, description) != original_description:
normalized["description"] = description
for raw_index, change in parts.items():
index = _patch_index(raw_index, 0, len(article_parts) - 1)
if not isinstance(change, dict) or not set(change) <= {"insertBefore", "insertAfter", "delete", "diff", "type"}:
raise ValueError("part patch is invalid")
if not change:
raise ValueError("part patch is empty")
item: dict[str, Any] = {}
for key in ("insertBefore", "insertAfter"):
if key in change:
if not isinstance(change[key], list):
raise ValueError("inserted parts must be a list")
values = [_content_part(value, part_types) for value in change[key]]
if values:
item[key] = values
if "delete" in change:
if not isinstance(change["delete"], bool):
raise ValueError("part delete must be boolean")
if change["delete"] and any(key in change for key in ("diff", "type")):
raise ValueError("deleted part cannot also be changed")
if change["delete"]:
item["delete"] = True
if "diff" in change:
original = article_part_text(article, index)
if not isinstance(change["diff"], str) or delta_edit_cost(change["diff"], original) is None:
raise ValueError("part diff is invalid")
if apply_text_delta(original, change["diff"]) != original:
item["diff"] = change["diff"]
if "type" in change:
if change["type"] not in part_types:
raise ValueError("part type is invalid")
original_type = article_parts[index].get("type") if isinstance(article_parts[index], dict) else None
if change["type"] != (original_type or "paragraph"):
item["type"] = change["type"]
if item:
normalized["parts"][str(index)] = item
for raw_index, change in comments.items():
index = _patch_index(raw_index, 1, len(article_comments))
if not isinstance(change, dict) or not set(change) <= {"insertBefore", "insertAfter", "delete", "diff"} or not change:
raise ValueError("comment patch is invalid")
item = {}
for key in ("insertBefore", "insertAfter"):
if key in change:
if not isinstance(change[key], list):
raise ValueError("inserted comments must be a list")
values = []
for value in change[key]:
if not isinstance(value, dict) or set(value) - {"id", "text"} or not isinstance(value.get("text"), str):
raise ValueError("inserted comment is invalid")
values.append({key: value[key] for key in value})
if values:
item[key] = values
if "delete" in change:
if not isinstance(change["delete"], bool):
raise ValueError("comment delete must be boolean")
if change["delete"]:
item["delete"] = True
if "diff" in change:
original = str(article_comments[index - 1] or "")
if not isinstance(change["diff"], str) or delta_edit_cost(change["diff"], original) is None:
raise ValueError("comment diff is invalid")
if apply_text_delta(original, change["diff"]) != original:
item["diff"] = change["diff"]
if item:
normalized["comments"][str(index)] = item
if "newComments" in patch:
if not isinstance(patch["newComments"], list) or any(not isinstance(value, str) for value in patch["newComments"]):
raise ValueError("new comments are invalid")
if not patch["newComments"] and not normalized["parts"] and not normalized["comments"] and not normalized["description"]:
raise ValueError("patch contains no changes")
if patch["newComments"]:
normalized["newComments"] = list(patch["newComments"])
if not normalized["parts"] and not normalized["comments"] and not normalized["description"] and "newComments" not in normalized:
raise ValueError("patch contains no changes")
return normalized
def article_part_text(article: dict[str, Any], index: int) -> str:
parts = article.get("parts") if isinstance(article.get("parts"), list) else []
if index < 0 or index >= len(parts) or not isinstance(parts[index], dict):
raise ValueError("part index is invalid")
values = list(str(parts[index].get("text") or ""))
pivots = article.get("comment_pivots") if isinstance(article.get("comment_pivots"), list) else []
selected = [item for item in pivots if isinstance(item, dict) and item.get("part_idx") == index]
for pivot in sorted(selected, key=lambda item: int(item.get("offset", 0)), reverse=True):
offset = int(pivot.get("offset", 0))
if offset < 0 or offset > len(values):
raise ValueError("comment pivot offset is invalid")
values.insert(offset, f"〔{pivot.get('index')}〕")
return "".join(values)
def apply_text_delta(text: str, delta: str) -> str:
source = text.encode("utf-16-le")
cursor = 0
output = bytearray()
for token in delta.split("\t") if delta else []:
if not token:
continue
operation = token[0]
value = token[1:]
if operation == "=":
if value.isdigit():
size = int(value) * 2
output.extend(source[cursor:cursor + size])
cursor += size
elif operation == "-":
if value.isdigit():
cursor += int(value) * 2
elif operation == "+":
try:
inserted = unquote_to_bytes(value.replace("+", "%2B")).decode("utf-8")
output.extend(inserted.encode("utf-16-le"))
except (UnicodeDecodeError, ValueError):
continue
if cursor != len(source):
raise ValueError("delta length mismatch")
return output.decode("utf-16-le")
def _clip_text(value: Any, limit: int | None = None) -> str:
text = str(value if value is not None else "")
if limit is None or len(text) <= limit:
return text
return text[:limit] + "…"
def _preview_value(value: Any, limit: int | None = None) -> Any:
return value if limit is None else _clip_text(value, limit)
def proofread_change_preview(
patch: dict[str, Any] | None,
article: dict[str, Any],
metadata: dict[str, Any] | None,
source: dict[str, Any],
clip: int | None = None,
) -> list[dict[str, Any]]:
article = article or {}
changes: list[dict[str, Any]] = []
if patch:
article_parts = article.get("parts") if isinstance(article.get("parts"), list) else []
article_comments = article.get("comments") if isinstance(article.get("comments"), list) else []
for raw_index in sorted((patch.get("parts") or {}), key=int):
index = int(raw_index)
change = patch["parts"][raw_index]
original_full = article_part_text(article, index)
original = _clip_text(original_full, clip)
if "diff" in change:
edited_full = apply_text_delta(original_full, change["diff"])
if edited_full != original_full:
changes.append({"kind": "part", "index": index + 1, "original": original, "edited": _clip_text(edited_full, clip)})
if change.get("delete"):
changes.append({"kind": "part", "index": index + 1, "delete": True, "original": original})
for key in ("insertBefore", "insertAfter"):
for part in change.get(key) or []:
changes.append({
"kind": "part", "index": index + 1, "insert": key == "insertAfter",
"text": _clip_text(part.get("text"), clip), "part_type": part.get("type"),
})
if "type" in change:
old_type = article_parts[index].get("type") if 0 <= index < len(article_parts) and isinstance(article_parts[index], dict) else None
if old_type != change["type"]:
changes.append({"kind": "part_type", "index": index + 1, "old": old_type, "new": change["type"]})
for raw_index in sorted((patch.get("comments") or {}), key=int):
index = int(raw_index) - 1
change = patch["comments"][raw_index]
base = str(article_comments[index] or "") if 0 <= index < len(article_comments) else ""
original = _clip_text(base, clip)
if "diff" in change:
edited = apply_text_delta(base, change["diff"])
if edited != base:
changes.append({"kind": "comment", "index": index + 1, "original": original, "edited": _clip_text(edited, clip)})
if change.get("delete"):
changes.append({"kind": "comment", "index": index + 1, "delete": True, "original": original})
for key in ("insertBefore", "insertAfter"):
for comment in change.get(key) or []:
changes.append({
"kind": "comment", "index": index + 1, "insert": key == "insertAfter",
"text": _clip_text(comment.get("text"), clip), "part_type": "comment",
})
for offset, text in enumerate(patch.get("newComments") or [], start=1):
changes.append({"kind": "new_comment", "index": len(article_comments) + offset, "text": _clip_text(text, clip)})
if patch.get("description"):
original = str(article.get("description") or "")
edited = apply_text_delta(original, patch["description"])
if edited != original:
changes.append({
"kind": "description",
"original": _clip_text(original, clip),
"edited": _clip_text(edited, clip),
})
if metadata:
article_meta = metadata.get("article") or {}
for field in ("title", "authors", "dates", "tags"):
if field in article_meta and article.get(field) != article_meta[field]:
changes.append({"kind": "metadata", "field": field, "old": _preview_value(article.get(field), clip), "new": _preview_value(article_meta[field], clip)})
source_meta = metadata.get("source") or {}
source_fields = {"name": "publication_name", "author": "publication_author", "type": "publication_type", "files": "source_files"}
for field, key in source_fields.items():
if field in source_meta and source.get(key) != source_meta[field]:
changes.append({"kind": "metadata", "field": field, "old": _preview_value(source.get(key), clip), "new": _preview_value(source_meta[field], clip)})
return changes
def patch_is_safe_to_merge(
patch: dict[str, Any] | None,
_article: dict[str, Any],
max_diff_cost: int,
max_net_paragraph_delta: int,
) -> bool:
if patch is None:
return True
if patch.get("version") != 2 or patch.get("newComments") or patch.get("description"):
return False
parts = patch.get("parts") or {}
comments = patch.get("comments") or {}
if not isinstance(parts, dict) or not isinstance(comments, dict):
return False
if not parts and not comments:
return False
delta = 0
cost = 0
for change in parts.values():
if not isinstance(change, dict) or not change:
return False
if set(change) - {"diff", "type", "insertBefore", "insertAfter", "delete"}:
return False
if "delete" in change and change["delete"] is not True:
return False
if change.get("delete"):
delta -= 1
for key in ("insertBefore", "insertAfter"):
if key in change:
if not isinstance(change[key], list):
return False
delta += len(change[key])
for part in change[key]:
if not isinstance(part, dict) or not isinstance(part.get("text"), str):
return False
cost += utf16_length(part["text"])
if "diff" in change:
if not isinstance(change["diff"], str):
return False
item_cost = diff_cost(change["diff"])
if item_cost is None:
return False
cost += item_cost
for change in comments.values():
if not isinstance(change, dict) or set(change) != {"diff"} or not isinstance(change.get("diff"), str):
return False
item_cost = diff_cost(change["diff"])
if item_cost is None:
return False
cost += item_cost
return -max_net_paragraph_delta <= delta <= max_net_paragraph_delta and cost <= max_diff_cost