Spaces:
Sleeping
Sleeping
File size: 7,168 Bytes
f9784ca c758621 f9784ca c758621 f9784ca c758621 f9784ca c758621 f9784ca c758621 f9784ca ca11823 f9784ca ca11823 f9784ca ca11823 f9784ca 23e9b61 f9784ca 23e9b61 f9784ca 23e9b61 f9784ca 23e9b61 f9784ca c758621 f9784ca c758621 f9784ca c758621 23e9b61 c758621 f9784ca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | """Validation shared by model ingestion and the deterministic renderer.
The model-facing JSON schema is the first boundary. This module is the second:
cached values and direct renderer calls must be safe even when they did not come
from Structured Outputs.
"""
from __future__ import annotations
from typing import Any
MAX_QUOTE_CHARS = 240
MAX_NOTE_CHARS = 320
MAX_CORRECTION_CHARS = 80
MAX_DIAGRAM_TITLE_CHARS = 80
MAX_DIAGRAM_LABEL_CHARS = 60
MAX_ANNOTATIONS_PER_PAGE = 15
QUOTE_KINDS = {
"underline",
"strike",
"circle",
"highlight",
"scribble",
"doodle",
"margin",
"bracket",
"list",
"checkmark",
"callout",
}
_CONTRACT_KEYS = {
"underline": {"type", "quote", "note", "double"},
"strike": {"type", "quote", "correction", "note"},
"circle": {"type", "quote", "note"},
"highlight": {"type", "quote", "meaning"},
"scribble": {"type", "quote", "note"},
"doodle": {"type", "quote", "symbol"},
"margin": {"type", "quote", "note"},
"bracket": {"type", "quote", "end_quote", "note"},
"list": {"type", "quote", "title", "items"},
"checkmark": {"type", "quote", "counter"},
"callout": {"type", "quote", "icon", "note"},
"diagram": {"type", "title", "labels"},
}
def sanitize_annotations(
payload: Any, *, enforce_contract: bool = True
) -> list[dict[str, Any]]:
"""Return only renderer-safe annotations from a model/cache payload."""
if (
not isinstance(payload, dict)
or set(payload) != {"annotations"}
or not isinstance(payload.get("annotations"), list)
):
raise ValueError("Invalid annotation payload")
candidates = payload["annotations"]
if len(candidates) > MAX_ANNOTATIONS_PER_PAGE:
raise ValueError("Too many annotations")
result: list[dict[str, Any]] = []
for candidate in candidates:
if not isinstance(candidate, dict) or candidate.get("type") not in _CONTRACT_KEYS:
continue
if enforce_contract and not set(candidate).issubset(_CONTRACT_KEYS[candidate["type"]]):
continue
clean = sanitize_annotation(candidate)
if clean is None:
# Per-annotation isolation: a single weak model item must not discard
# the other renderer-safe annotations for this page.
continue
result.append(clean)
if candidates and not result:
# A wholly malformed non-empty response is not an intentional empty page
# and must never enter either cache.
raise ValueError("No valid annotations in non-empty response")
return result
def sanitize_annotation(
candidate: Any, *, enforce_quote_words: bool = True
) -> dict[str, Any] | None:
"""Validate and length-bound one annotation without retaining extra fields.
Model/cache ingestion enforces 3-8 word anchors, except that underlines and
legacy highlights may span a complete sentence of up to 30 words. The renderer can safely
consume shorter anchors in Track A's proven hand-authored fixture; character
bounds and structural checks still apply there.
"""
if not isinstance(candidate, dict):
return None
kind = candidate.get("type")
if kind == "diagram":
labels = candidate.get("labels")
if not isinstance(labels, list) or not 2 <= len(labels) <= 5:
return None
clean_labels = []
for label in labels:
value = _bounded_string(label, MAX_DIAGRAM_LABEL_CHARS)
if not value:
return None
clean_labels.append(value)
clean: dict[str, Any] = {"type": "diagram", "labels": clean_labels}
title = _bounded_string(candidate.get("title"), MAX_DIAGRAM_TITLE_CHARS)
if title:
clean["title"] = title
return clean
if kind not in QUOTE_KINDS:
return None
quote = _bounded_string(candidate.get("quote"), MAX_QUOTE_CHARS)
max_quote_words = 30 if kind in {"underline", "highlight"} else 8
if not quote or (
enforce_quote_words and not 3 <= len(quote.split()) <= max_quote_words
):
return None
clean = {"type": kind, "quote": quote}
note = _bounded_words(candidate.get("note"), 36, MAX_NOTE_CHARS)
correction = _bounded_words(candidate.get("correction"), 8, MAX_CORRECTION_CHARS)
if kind == "underline":
clean["double"] = bool(candidate.get("double", False))
if note:
clean["note"] = note
elif kind == "strike":
if not correction:
return None
clean["correction"] = correction
if note:
clean["note"] = note
elif kind == "highlight":
meaning = candidate.get("meaning", "key")
if meaning not in {"key", "theory", "example", "definition", "evidence", "caution"}:
return None
clean["meaning"] = meaning
elif kind in {"circle", "scribble", "margin"}:
if not note:
return None
clean["note"] = note
elif kind == "bracket":
if not note:
return None
end_quote = _bounded_string(candidate.get("end_quote"), MAX_QUOTE_CHARS)
if end_quote and enforce_quote_words and not 3 <= len(end_quote.split()) <= 8:
return None
if end_quote:
clean["end_quote"] = end_quote
clean["note"] = note
elif kind == "list":
items = candidate.get("items")
if not isinstance(items, list) or not 2 <= len(items) <= 5:
return None
clean_items = []
for item in items:
value = _bounded_string(item, MAX_DIAGRAM_LABEL_CHARS)
if not value:
return None
clean_items.append(value)
title = _bounded_string(candidate.get("title"), MAX_DIAGRAM_TITLE_CHARS)
if title:
clean["title"] = title
clean["items"] = clean_items
elif kind == "checkmark":
counter = _bounded_words(candidate.get("counter"), 36, MAX_NOTE_CHARS)
if counter:
clean["counter"] = counter
elif kind == "callout":
if not note or candidate.get("icon") not in {
"question",
"warning",
"practice",
"definition",
}:
return None
clean["icon"] = candidate["icon"]
clean["note"] = note
elif kind == "doodle":
symbol = candidate.get("symbol")
if symbol not in {"star", "asterisk", "exclaim"}:
return None
clean["symbol"] = symbol
return clean
def _bounded_string(value: Any, limit: int) -> str | None:
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
return value[:limit].rstrip()
def _bounded_words(value: Any, words: int, chars: int) -> str | None:
text = _bounded_string(value, chars)
if not text:
return None
return " ".join(text.split()[:words])[:chars].rstrip()
|