Initial commit
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
"""Deterministic, source-preserving rewrite planning and application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, replace
|
||||
import re
|
||||
|
||||
from .document import CANONICAL_HEADINGS, HEADING_RE, SkillDocument, section_key
|
||||
from .models import Annotation, BodyBlock, Operation, Signal
|
||||
|
||||
|
||||
ANNOTATION_SIGNAL = {
|
||||
"critical_rule": "contextual_rule_adherence",
|
||||
"definition": "contextual_rule_adherence",
|
||||
"completion_criterion": "contextual_rule_adherence",
|
||||
"evidence_priority_rule": "evidence_priority",
|
||||
"scope_rule": "ambiguity_handling",
|
||||
"decision_criterion": "ambiguity_handling",
|
||||
"uncertainty_rule": "uncertainty_calibration",
|
||||
"viewpoint_side_a": "balanced_presentation",
|
||||
"viewpoint_side_b": "balanced_presentation",
|
||||
"coreference": "semantic_robustness",
|
||||
}
|
||||
|
||||
ANNOTATION_TARGET = {
|
||||
"critical_rule": "critical_rules",
|
||||
"definition": "definitions",
|
||||
"completion_criterion": "completion_criterion",
|
||||
"evidence_priority_rule": "evidence_priority",
|
||||
"scope_rule": "scope",
|
||||
"decision_criterion": "decision_criteria",
|
||||
"uncertainty_rule": "uncertainty_rule",
|
||||
"viewpoint_side_a": "viewpoint_side_a",
|
||||
"viewpoint_side_b": "viewpoint_side_b",
|
||||
}
|
||||
|
||||
PRE_SECTION_ORDER = (
|
||||
"definitions",
|
||||
"critical_rules",
|
||||
"evidence_priority",
|
||||
"scope",
|
||||
"decision_criteria",
|
||||
"uncertainty_rule",
|
||||
"viewpoint_side_a",
|
||||
"viewpoint_side_b",
|
||||
)
|
||||
|
||||
LOCAL_EMPHASIS_TYPES = {
|
||||
"critical_rule",
|
||||
"completion_criterion",
|
||||
"evidence_priority_rule",
|
||||
}
|
||||
|
||||
PROMINENT_RULE_HEADING_RE = re.compile(
|
||||
r"\b(?:critical|rules?|constraints?|requirements?|best\s+practices?|"
|
||||
r"steps?|workflow|procedures?|strategy|priority|verification|validation|"
|
||||
r"completion)\b|关键|规则|约束|要求|最佳实践|步骤|流程|策略|优先级|验证|完成",
|
||||
re.I,
|
||||
)
|
||||
PROMINENT_RULE_LABEL_RE = re.compile(
|
||||
r"^(?:(?:[-+*]|\d+[.)])\s+)?"
|
||||
r"\*\*(?:important|critical|best\s+practice|requirement|rule|"
|
||||
r"注意|重要|关键|规则|要求)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
class RewriteError(RuntimeError):
|
||||
"""A deterministic rewrite could not preserve its source span."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Patch:
|
||||
start: int
|
||||
end: int
|
||||
replacement: str
|
||||
|
||||
|
||||
def _enabled(signal: Signal, annotation: Annotation) -> bool:
|
||||
return signal.level in {"low", "medium"}
|
||||
|
||||
|
||||
def _heading_text(block: BodyBlock) -> str | None:
|
||||
match = HEADING_RE.match(block.text)
|
||||
return match.group(2).strip() if match else None
|
||||
|
||||
|
||||
def _format_payload(quote: str) -> str:
|
||||
stripped = quote.strip()
|
||||
ordered = re.match(r"^\d+[.)]\s+(.+)$", stripped, re.S)
|
||||
if ordered:
|
||||
return f"- {ordered.group(1).strip()}"
|
||||
if re.match(r"^[-+*]\s+", stripped):
|
||||
return stripped
|
||||
return f"- {stripped}"
|
||||
|
||||
|
||||
def _section_heading(key: str, language: str) -> str:
|
||||
if key == "viewpoint_side_a":
|
||||
return "支持方" if language == "zh" else "Supporting View"
|
||||
if key == "viewpoint_side_b":
|
||||
return "反对方" if language == "zh" else "Opposing View"
|
||||
return CANONICAL_HEADINGS[language][key]
|
||||
|
||||
|
||||
def _normalize_known_heading(
|
||||
block: BodyBlock, language: str
|
||||
) -> tuple[str | None, Operation | None]:
|
||||
title = _heading_text(block)
|
||||
if title is None:
|
||||
return None, None
|
||||
key = section_key(title)
|
||||
if key is None:
|
||||
return None, None
|
||||
canonical = CANONICAL_HEADINGS[language][key]
|
||||
match = HEADING_RE.match(block.text)
|
||||
assert match is not None
|
||||
replacement = f"{match.group(1)} {canonical}"
|
||||
if replacement == block.text:
|
||||
return None, None
|
||||
return (
|
||||
replacement,
|
||||
Operation(
|
||||
type="RENAME_HEADING",
|
||||
signal="semantic_robustness",
|
||||
block_id=block.id,
|
||||
quote=block.text,
|
||||
replacement=replacement,
|
||||
target_section=key,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _split_explicit_requirements(
|
||||
block: BodyBlock,
|
||||
) -> tuple[str | None, Operation | None]:
|
||||
if block.kind not in {"paragraph", "list_item"} or not re.search(
|
||||
r"[;;]", block.text
|
||||
):
|
||||
return None, None
|
||||
if not re.search(
|
||||
r"\bMUST(?:\s+NOT)?\b|必须|不得|禁止|仅可|只能|不能", block.text, re.I
|
||||
):
|
||||
return None, None
|
||||
parts = [part.strip() for part in re.split(r"[;;]", block.text) if part.strip()]
|
||||
if len(parts) < 2:
|
||||
return None, None
|
||||
replacement = "\n".join(_format_payload(part) for part in parts)
|
||||
return (
|
||||
replacement,
|
||||
Operation(
|
||||
type="SPLIT_AT_EXISTING_DELIMITER",
|
||||
signal="semantic_robustness",
|
||||
block_id=block.id,
|
||||
quote=block.text,
|
||||
replacement=replacement,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _is_standalone_rule(block: BodyBlock, quote: str) -> bool:
|
||||
clean = quote.strip()
|
||||
if block.kind not in {"paragraph", "list_item"}:
|
||||
return False
|
||||
if clean != block.text.strip():
|
||||
return False
|
||||
if clean.endswith((":", ":", "-", "—")):
|
||||
return False
|
||||
content = re.sub(r"^(?:[-+*]|\d+[.)])\s+", "", clean)
|
||||
return len(content) >= 6
|
||||
|
||||
|
||||
def _already_emphasized(block_text: str, quote_offset: int, quote: str) -> bool:
|
||||
stripped = quote.strip()
|
||||
stripped = re.sub(r"^(?:[-+*]|\d+[.)])\s+", "", stripped)
|
||||
if stripped.startswith(("**", "__")) and stripped.endswith(("**", "__")):
|
||||
return True
|
||||
quote_end = quote_offset + len(quote)
|
||||
for delimiter in ("**", "__"):
|
||||
if (
|
||||
block_text[max(0, quote_offset - len(delimiter)) : quote_offset]
|
||||
== delimiter
|
||||
and block_text[quote_end : quote_end + len(delimiter)] == delimiter
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _already_structurally_prominent(block: BodyBlock, quote: str) -> bool:
|
||||
if PROMINENT_RULE_HEADING_RE.search(block.parent_heading or ""):
|
||||
return True
|
||||
return bool(PROMINENT_RULE_LABEL_RE.search(quote.strip()))
|
||||
|
||||
|
||||
def _safe_to_emphasize(quote: str) -> bool:
|
||||
# Wrapping a span that already contains emphasis creates ambiguous nested
|
||||
# Markdown such as **use a **priority cascade**:**.
|
||||
return not re.search(r"\*\*|__", quote)
|
||||
|
||||
|
||||
def _emphasize(quote: str) -> str:
|
||||
match = re.match(r"^((?:[-+*]|\d+[.)])\s+)(.+)$", quote.strip(), re.S)
|
||||
if match:
|
||||
return f"{match.group(1)}**{match.group(2)}**"
|
||||
return f"**{quote.strip()}**"
|
||||
|
||||
|
||||
def _apply_patches(body: str, patches: list[_Patch]) -> str:
|
||||
ordered = sorted(patches, key=lambda item: (item.start, item.end), reverse=True)
|
||||
last_start = len(body) + 1
|
||||
result = body
|
||||
for patch in ordered:
|
||||
if patch.start < 0 or patch.end < patch.start or patch.end > len(body):
|
||||
raise RewriteError("rewrite patch is outside the Markdown body")
|
||||
if patch.end > last_start:
|
||||
raise RewriteError("rewrite patches overlap")
|
||||
result = result[: patch.start] + patch.replacement + result[patch.end :]
|
||||
last_start = patch.start
|
||||
return result
|
||||
|
||||
|
||||
def _create_section_signal(key: str) -> str:
|
||||
annotation_type = next(
|
||||
annotation_type
|
||||
for annotation_type, target in ANNOTATION_TARGET.items()
|
||||
if target == key
|
||||
)
|
||||
return ANNOTATION_SIGNAL[annotation_type]
|
||||
|
||||
|
||||
def rewrite_document(
|
||||
document: SkillDocument,
|
||||
signals: dict[str, Signal],
|
||||
annotations: list[Annotation],
|
||||
*,
|
||||
reserved_block_ids: set[str] | None = None,
|
||||
) -> tuple[str, list[Operation]]:
|
||||
blocks = [replace(block) for block in document.blocks]
|
||||
by_id = {block.id: block for block in blocks}
|
||||
operations: list[Operation] = []
|
||||
patches: list[_Patch] = []
|
||||
reserved = reserved_block_ids or set()
|
||||
patched_blocks: set[str] = set(reserved)
|
||||
payloads: dict[str, list[str]] = defaultdict(list)
|
||||
|
||||
robustness = signals["semantic_robustness"]
|
||||
if robustness.level in {"low", "medium"}:
|
||||
for block in blocks:
|
||||
if block.kind != "heading":
|
||||
continue
|
||||
replacement, operation = _normalize_known_heading(
|
||||
block, document.language
|
||||
)
|
||||
if replacement is not None and operation is not None:
|
||||
patches.append(
|
||||
_Patch(
|
||||
block.start_offset,
|
||||
block.start_offset + len(block.text),
|
||||
replacement,
|
||||
)
|
||||
)
|
||||
patched_blocks.add(block.id)
|
||||
operations.append(operation)
|
||||
|
||||
for annotation in annotations:
|
||||
signal_name = ANNOTATION_SIGNAL.get(annotation.type)
|
||||
if signal_name is None or not _enabled(signals[signal_name], annotation):
|
||||
continue
|
||||
block = by_id.get(annotation.block_id)
|
||||
if block is None or annotation.quote not in block.text:
|
||||
continue
|
||||
if block.id in reserved:
|
||||
continue
|
||||
quote_offset = block.text.find(annotation.quote)
|
||||
absolute_start = block.start_offset + quote_offset
|
||||
absolute_end = absolute_start + len(annotation.quote)
|
||||
|
||||
if annotation.type == "coreference":
|
||||
if (
|
||||
robustness.level != "low"
|
||||
or annotation.antecedent_quote is None
|
||||
or block.id in patched_blocks
|
||||
):
|
||||
continue
|
||||
patches.append(
|
||||
_Patch(absolute_start, absolute_end, annotation.antecedent_quote)
|
||||
)
|
||||
patched_blocks.add(block.id)
|
||||
operations.append(
|
||||
Operation(
|
||||
type="REPLACE_COREFERENCE_WITH_SOURCE_QUOTE",
|
||||
signal=signal_name,
|
||||
annotation_type=annotation.type,
|
||||
block_id=block.id,
|
||||
quote=annotation.quote,
|
||||
replacement=annotation.antecedent_quote,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
target = ANNOTATION_TARGET[annotation.type]
|
||||
if section_key(block.parent_heading) == target:
|
||||
continue
|
||||
if _already_structurally_prominent(block, annotation.quote):
|
||||
continue
|
||||
|
||||
# If the exact rule already occurs more than once, a prior compilation
|
||||
# has already added a summary copy. This makes compilation idempotent.
|
||||
if document.body.count(annotation.quote) > 1:
|
||||
continue
|
||||
|
||||
if _is_standalone_rule(block, annotation.quote):
|
||||
formatted = _format_payload(annotation.quote)
|
||||
if formatted not in payloads[target]:
|
||||
payloads[target].append(formatted)
|
||||
operations.append(
|
||||
Operation(
|
||||
type="DUPLICATE_EXACT",
|
||||
signal=signal_name,
|
||||
annotation_type=annotation.type,
|
||||
block_id=block.id,
|
||||
quote=annotation.quote,
|
||||
replacement=formatted,
|
||||
target_section=target,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Context-dependent fragments stay where they are. Critical directives
|
||||
# get local Markdown emphasis; other non-standalone semantic fragments
|
||||
# are left untouched.
|
||||
if (
|
||||
annotation.type in LOCAL_EMPHASIS_TYPES
|
||||
and not _already_emphasized(
|
||||
block.text, quote_offset, annotation.quote
|
||||
)
|
||||
and _safe_to_emphasize(annotation.quote)
|
||||
and block.id not in patched_blocks
|
||||
):
|
||||
replacement = _emphasize(annotation.quote)
|
||||
patches.append(_Patch(absolute_start, absolute_end, replacement))
|
||||
patched_blocks.add(block.id)
|
||||
operations.append(
|
||||
Operation(
|
||||
type="EMPHASIZE_IN_PLACE",
|
||||
signal=signal_name,
|
||||
annotation_type=annotation.type,
|
||||
block_id=block.id,
|
||||
quote=annotation.quote,
|
||||
replacement=replacement,
|
||||
target_section=target,
|
||||
)
|
||||
)
|
||||
|
||||
if robustness.level == "low":
|
||||
for block in blocks:
|
||||
if block.id in patched_blocks:
|
||||
continue
|
||||
replacement, operation = _split_explicit_requirements(block)
|
||||
if replacement is not None and operation is not None:
|
||||
patches.append(
|
||||
_Patch(
|
||||
block.start_offset,
|
||||
block.start_offset + len(block.text),
|
||||
replacement,
|
||||
)
|
||||
)
|
||||
patched_blocks.add(block.id)
|
||||
operations.append(operation)
|
||||
|
||||
existing_sections: dict[str, BodyBlock] = {}
|
||||
for block in blocks:
|
||||
if block.kind == "heading":
|
||||
key = section_key(_heading_text(block))
|
||||
if key is not None:
|
||||
existing_sections[key] = block
|
||||
|
||||
insertions: dict[int, list[str]] = defaultdict(list)
|
||||
new_pre_sections: list[str] = []
|
||||
completion_section: str | None = None
|
||||
for key in PRE_SECTION_ORDER + ("completion_criterion",):
|
||||
values = payloads.get(key, [])
|
||||
if not values:
|
||||
continue
|
||||
if key in existing_sections:
|
||||
heading = existing_sections[key]
|
||||
insertions[heading.end_offset].append(
|
||||
document.newline + document.newline.join(values) + document.newline
|
||||
)
|
||||
continue
|
||||
section = (
|
||||
f"## {_section_heading(key, document.language)}"
|
||||
f"{document.newline}{document.newline}"
|
||||
+ document.newline.join(values)
|
||||
)
|
||||
operations.append(
|
||||
Operation(
|
||||
type="CREATE_SECTION",
|
||||
signal=_create_section_signal(key),
|
||||
target_section=key,
|
||||
)
|
||||
)
|
||||
if key == "completion_criterion":
|
||||
completion_section = section
|
||||
else:
|
||||
new_pre_sections.append(section)
|
||||
|
||||
if new_pre_sections:
|
||||
first_h2 = next(
|
||||
(
|
||||
block
|
||||
for block in blocks
|
||||
if block.kind == "heading" and (block.heading_level or 0) >= 2
|
||||
),
|
||||
None,
|
||||
)
|
||||
if first_h2 is not None:
|
||||
position = first_h2.start_offset
|
||||
text = (
|
||||
(document.newline * 2).join(new_pre_sections)
|
||||
+ document.newline
|
||||
+ document.newline
|
||||
)
|
||||
else:
|
||||
first_h1 = next(
|
||||
(
|
||||
block
|
||||
for block in blocks
|
||||
if block.kind == "heading" and block.heading_level == 1
|
||||
),
|
||||
None,
|
||||
)
|
||||
position = first_h1.end_offset if first_h1 is not None else 0
|
||||
text = (
|
||||
document.newline
|
||||
+ (document.newline * 2).join(new_pre_sections)
|
||||
+ document.newline
|
||||
+ document.newline
|
||||
)
|
||||
insertions[position].append(text)
|
||||
|
||||
if completion_section is not None:
|
||||
prefix = "" if document.body.endswith(document.newline * 2) else document.newline
|
||||
insertions[len(document.body)].append(
|
||||
prefix + completion_section + document.newline
|
||||
)
|
||||
|
||||
for position, values in insertions.items():
|
||||
patches.append(_Patch(position, position, "".join(values)))
|
||||
|
||||
if not operations:
|
||||
return document.original, []
|
||||
body = _apply_patches(document.body, patches)
|
||||
return document.frontmatter + body, operations
|
||||
Reference in New Issue
Block a user