Files
2026-09-04 14:58:42 +08:00

456 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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