File size: 24,639 Bytes
4e76c6f | 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 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 | """Contract v2 normalizer: evidence spans -> emitted record, deterministically.
Implements docs/LABELING-CONTRACT.md sections 3-7. This is the only permitted
path from labels to an emitted record: annotators mark spans, this code derives
the record. Hand-editing an output string is a contract violation, so the
derivation has to be inspectable and testable on its own.
Span dicts: `{'kind', 'start', 'end'}` plus optional `text` (must match the
query bytes), `options` and `value`. Kinds: TITLE, EPISODE_TITLE, GROUP, YEAR, SEASON,
EPISODE, RANGE, LIST, RESOLUTION, DIMENSIONS, DISCARD. `options` carries
label-side decisions the contract refuses to guess from bytes: `is_title`,
`fold_case`, `items`. `value` carries a normalized season number (None means
an explicit-but-unbounded ordinal, which is omitted, never 0).
`covered` may list extra [start, end) ranges (for example a path component
carrying no field) so that the debris check is a fact about the label, not a
guess about punctuation. Debris checking is opt-in because real corpora are
partially labeled; a full-label row should turn it on.
"""
import re
import unicodedata
# A group name is copied verbatim from one bracket, so a collab credit such as
# `ACB字幕组&天空字幕组` stays one string; two separate group spans conflict.
MULTI_GROUP = re.compile(r"\]\s*\[")
EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,8}$")
# These punctuation marks are used as structural delimiters by the controlled
# format suites. They are not title characters at a predicted span edge; the
# gold derivation still uses the original spans and never calls this trimmer.
# The Unicode members were added after the consumed fresh-6 audit exposed
# full-width/vertical delimiters bleeding into TITLE, SEASON, and EPISODE spans,
# then extended for the fresh-7 delimiter inventory *before* fresh-7 was
# generated, so no suite can be accused of picking delimiters after seeing the
# trimmer. The list is duplicated verbatim in goport/normalize.go; both sides
# are pinned by tests. ASCII "+" is deliberately absent: it occurs in real
# titles ("C++") and would corrupt evidence to trim it.
STRUCTURAL_EDGE_CHARS = frozenset(
"#<>{}@|/∥|⁘❖【】『』⟦⟧‖‣⁓⧸⁄∕⋯»〔〕❪❫⦅⦆〘〙〖〗«》"
)
# The roster above is only the manual half of the edge test. Any non-ASCII rune in the
# dash, bracket or punctuation categories is an edge rune too, even when the roster
# predates it: that is what stops the set from measuring which delimiters happened to be
# enumerated before a suite shipped. ASCII runes are never widened this way -- ASCII
# punctuation legitimately terminates role text ("Title...", "(Recut)", "Title '05'"), so
# ASCII stays roster-only.
_EDGE_CATEGORIES = frozenset({"Pd", "Po", "Ps", "Pe", "Pi", "Pf", "Pc"})
def is_structural_edge(ch: str) -> bool:
"""Whether `ch` may be trimmed from a *predicted* span edge. Gold spans never see this."""
if ord(ch) < 0x80:
return ch in STRUCTURAL_EDGE_CHARS
return ch in STRUCTURAL_EDGE_CHARS or unicodedata.category(ch) in _EDGE_CATEGORIES
PATH_HASH_GROUP = re.compile(
r"(?i)(?:[- ](?:subs?|raws?|team|works?|encode|unit|signal|depot|delta|plow|"
r"cellar|marrow|nest|harbor|lantern|rip|release|group)|"
r"(?:subs|raws|team|works|unit|signal|depot|delta|plow|cellar|marrow|nest|"
r"harbor|lantern))$"
)
# Contract §10: sidecars and documents are not media, even with a perfect
# title. Containers (.rar/.zip/.7z/.torrent) are NOT listed: §10 parses them.
NON_MEDIA_EXTENSIONS = frozenset({
"srt", "ass", "ssa", "vtt", "sub", "idx", "sup", "smi", # subtitles
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tbn", # images
"nfo", "txt", "pdf", "epub", "docx", "doc", "md", "html", "htm", # documents
"xml", "json", "log",
"md5", "sfv", "sha1", "sha256", "par2", # checksums
"gz", "tar", "bz2", "xz", # non-media archives
})
def non_media_extension(query):
"""The final dot-suffix, lower-cased, when it names a non-media sidecar;
otherwise None. Position-anchored like `EXTENSION`: only the true suffix
counts, a `.srt` in the middle of a path is not a decision here."""
match = EXTENSION.search(query)
if not match:
return None
ext = match.group(0)[1:].lower()
return ext if ext in NON_MEDIA_EXTENSIONS else None
def leading_hash_stem(query):
"""Whether a query has the titleless CRC-prefixed release-stem shape.
This is intentionally narrower than ``query.startswith('[')``: the stem
must have a hex token, two dash-separated fields, and a closed resolution
class at the end. It is a decision-layer rule for rows whose only apparent
title is a group-shaped token between a checksum and codec junk; ordinary
bracket-prefixed titles and two-field releases remain untouched.
"""
if not re.match(r"^\[[0-9a-fA-F]{7,12}\]\s+", query):
return False
rest = query[query.find("]") + 1:].lstrip()
fields = rest.split(" - ")
if len(fields) != 3 or not all(fields[:2]):
return False
tail = fields[2]
return bool(re.fullmatch(r"(?:480p|576p|720p|1080p|1080i|2160p)\.[A-Za-z0-9]{1,8}",
tail, flags=re.IGNORECASE))
UNICODE_SPACE = re.compile("[ - ]")
DIGITS = re.compile(r"\d+(?:\.\d+)*")
YEAR_LIKE = re.compile(r"\b\d{4}\b")
OPEN_BRACKETS = "([{「『〈《【“「"
CLOSE_BRACKETS = ")]}」』〉》】”」"
BRACKETS = OPEN_BRACKETS + CLOSE_BRACKETS
# Brackets are deliberately NOT edge-stripped: an unmatched bracket is either
# lexical title content or a label error, and silently deleting one side of a
# pair corrupts evidence. Only balanced pairs are removed (see normalize_text).
STRIP_CHARS = " \t\r\n-–—~~·•::/\\."
def _balanced(text):
return all(text.count(open_) >= text.count(close)
for open_, close in zip(OPEN_BRACKETS, CLOSE_BRACKETS))
def _strip_bracket_pairs(text):
"""Contract 3: brackets are removed only as balanced pairs enclosing the
whole fragment. `ポコニャン(叮噹貓)` keeps its parentheses (they are inside
the title), `「title」` and `「title」` lose them (they delimit it)."""
while (len(text) >= 2 and text[0] in OPEN_BRACKETS and text[-1] in CLOSE_BRACKETS
and _balanced(text[1:-1])):
text = _fold(text[1:-1]).strip(STRIP_CHARS)
return text
SCHEMA_VALUES = {"480p", "576p", "720p", "1080p", "1080i", "2160p"}
# Contract 7: a bare height is only HD evidence. SD heights are shared with
# computer displays (640x480 VGA, 800x480) and non-square-pixel DVD captures,
# so they never establish a broadcast class.
HEIGHT_CLASS = {720: "720p", 1080: "1080p", 2160: "2160p"}
MIN_ASPECT, MAX_ASPECT = 1.7, 1.95 # widescreen video frames only
MAX_EPISODES = 64
YEAR_MIN, YEAR_MAX = 1870, 2099
# Contract 5 value resolution. Deliberately a *closed* vocabulary: if a season
# marker is not in here, the row reports `season-unparsable` instead of guessing.
# A model that emits the right span never has to know numbers, and this table is
# the auditable place where text becomes a value.
DIGITS = re.compile(r"\d+")
FINAL_WORDS = ("final", "última", "ultimas", "last", "fin", "おしまい", "最終", "最后", "最终",
"最後", "끝", "последн")
ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "primeira": 1, "segunda": 2,
"terceira": 3, "quarta": 4, "quinta": 5, "primera": 1, "tercera": 3,
"premiere": 1, "deuxieme": 2, "deuxième": 2, "troisieme": 3, "troisième": 3,
"erste": 1, "zweite": 2, "dritte": 3, "primo": 1, "secundo": 2, "seconda": 2,
"prima": 1, "terzo": 3, "uma": 1, "dois": 2, "tres": 3, "três": 3, "cuarta": 4,
"quinta": 5, "sexta": 6, "fifth": 5,
"первый": 1, "второй": 2, "третий": 3, "четвёртый": 4, "пятый": 5, "шестой": 6,
"седьмой": 7, "восьмой": 8, "девятый": 9, "десятый": 10,
}
CJK_NUMERALS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7,
"八": 8, "九": 9, "十": 10, "壹": 1, "貳": 2, "叁": 3}
def season_value(text):
"""int, None (=explicit season with no number), or 'unparsable'.
Digits win over words: `2nd Season` and `第2期` are the same value. A
multi-number marker (`Season 2 of 12`, `S01E02` mistagged) is unparsable
rather than a pick-one, because either choice is a guess about the work.
"""
folded = normalize_text(text, fold_case=True)
if any(word in folded for word in FINAL_WORDS):
return None
numbers = DIGITS.findall(folded)
if len(numbers) == 1:
value = int(numbers[0])
return value if 0 <= value <= 99 else "unparsable"
if len(numbers) > 1:
return "unparsable"
for word in folded.split(" "):
if word in ORDINAL_WORDS:
return ORDINAL_WORDS[word]
runs = [c for c in folded if c in CJK_NUMERALS]
if len(runs) == 1:
return CJK_NUMERALS[runs[0]]
return "unparsable"
def _fold(text):
"""Contract 3: underscores and exotic whitespace become spaces, runs
collapse, nothing is transliterated. Dots are handled separately, because
whether one is a delimiter depends on its neighbours (see
`fold_dot_delimiters`)."""
text = UNICODE_SPACE.sub(" ", text)
text = text.replace("_", " ")
text = re.sub(r"[-–—]{2,}", "-", text)
return re.sub(r" {2,}", " ", text).strip()
ALPHA = re.compile(r"[^\W\d_]+\Z", re.UNICODE)
NUMERIC = re.compile(r"\d+\Z")
def _segment_kind(unit):
if not unit:
return "empty"
if ALPHA.match(unit):
return "word"
if NUMERIC.match(unit):
return "number"
return "mixed"
def fold_dot_delimiters(text):
"""Contract 3.2, title fragments only, decided dot by dot inside each
whitespace token, where a segment is a *word* (letters of any script), a
*number* (digits only), *mixed* (letters and digits together: `1080p`,
`2a`, `S01`, `x264`) or *empty* (a doubled or trailing dot).
A dot splits when either neighbour is a word (`CITY.HUNTER` ->
`CITY HUNTER`, `Akane.1988` -> `Akane 1988`, `Movie.2.Innocence` ->
`Movie 2 Innocence`), when the right side is mixed (`Ghost.1080p` ->
`Ghost 1080p`: a technical tag is never title text, and a split that only
touches title-adjacent junk cannot lose evidence), or when a side is empty
(`Dr.`, `Ghost.` lose the delimiter dot). The one shape that is never cut
is `number . number` before any word has appeared in the token:
`1917.2019` and `2.43.121` survive as single tokens, because a run that
opens with digits reads as an encoded value and splitting it would be a
guess about which part is the title. Single letters are words, so
`A.K.A.` folds to `A K A`.
Consequence: a title that genuinely contains a digit-dot-digit sequence
must be labeled as two fragments. That is the honest cost of a rule an
annotator can apply without knowing the work."""
pieces = []
for token in text.split(" "):
segments = token.split(".")
if len(segments) == 1:
if token:
pieces.append(token)
continue
kinds = [_segment_kind(s) for s in segments]
cut, word_seen = [0], False
for index in range(1, len(segments)):
previous, current = kinds[index - 1], kinds[index]
touches_word = previous == "word" or current == "word"
if current in ("mixed", "empty") or touches_word:
cut.append(index) # a word or junk boundary: split here
elif previous == "number" and current == "number" and word_seen:
cut.append(index) # `Akane.1988.2020` splits; `1917.2019` does not
word_seen = word_seen or touches_word
for boundary, edge in zip(cut, cut[1:] + [len(segments)]):
unit = ".".join(segments[boundary:edge])
if unit:
pieces.append(unit)
text = " ".join(pieces)
return re.sub(r" {2,}", " ", text).strip()
def trim_structural_edges(query, spans):
"""Trim structural delimiter runes from predicted span edges.
This is deliberately prediction-only. Gold labels are never rewritten:
a label span that owns `#`, `<`, `>`, `{`, `}`, `@`, or `|` remains
lossless. The trimmer handles a decoder that includes a neighboring
delimiter in a role span, and drops a span made only of delimiters.
"""
out = []
changed = 0
for span in spans:
start, end = span["start"], span["end"]
first, last = start, end
while first < last and is_structural_edge(query[first]):
first += 1
while last > first and is_structural_edge(query[last - 1]):
last -= 1
if first != start or last != end:
changed += 1
if first < last:
out.append({**span, "start": first, "end": last})
return out, changed
def titleless_path_group(query, spans):
"""Narrow prediction gate for ``(hash)/junk/Group/res`` stems.
The parenthesized hash, slash path, and group-like title suffix must all be
present, with no episode, season, year, or episode-title evidence. A
positive row with any additional title evidence is left untouched.
"""
if not re.match(r"^\([0-9a-fA-F]{7,12}\)", query) or "/" not in query:
return False
titles = [span for span in spans if span["kind"] == "TITLE"]
evidence = [span for span in spans if span["kind"] in
("EPISODE", "EPISODE_TITLE", "SEASON", "YEAR")]
if len(titles) != 1 or evidence:
return False
return bool(PATH_HASH_GROUP.search(query[titles[0]["start"]:titles[0]["end"]].strip()))
def normalize_text(text, is_title=False, fold_case=False, keep_edges=False):
"""`keep_edges=True` is for an already-assembled title: an edge bracket or
hyphen there belongs to an adjacent fragment, so edge stripping is skipped
and only byte-internal rules (separators, whitespace, delimiter dots)
apply. Fragments must not use it - that is where delimiters are removed."""
text = _fold(text)
if not keep_edges:
text = text.strip(STRIP_CHARS)
text = _strip_bracket_pairs(text)
text = text.strip(STRIP_CHARS)
if fold_case:
text = text.lower()
if is_title:
text = fold_dot_delimiters(text)
return _fold(text)
def _range_endpoints(text):
"""Return (None, (lo, hi), issues). Repeated season prefixes
(`S01E01-S01E03`) and embedded technical values (`[01 ~ 24][1080p]`,
`1280x720`) are removed first, so the endpoints are the only numbers left.
Endpoints are 1-3 digits: a 4-digit number is a year or a checksum, not an
episode index."""
stripped = re.sub(r"(?i)s\d+", " ", text)
stripped = re.sub(r"(?i)\d{3,5}\s*[x×]\s*\d{3,5}|\d{3,4}[pi]\b", " ", stripped)
nums = [int(n) for n in re.findall(r"\d{1,3}", stripped)]
if not nums:
return None, None, ["range-no-digits"]
if len(nums) > 2:
return None, None, ["range-too-complex"]
if len(nums) == 1:
return None, None, ["range-single-value"]
if not re.search(r"[-~–—至到]", text):
return None, None, ["range-no-connector"]
return None, (nums[0], nums[-1]), []
def expand_range(text, cap=MAX_EPISODES):
_, endpoints, issues = _range_endpoints(text)
if issues:
return [], issues
a, b = endpoints
if a == b:
issues.append("range-single-value")
lo, hi = min(a, b), max(a, b)
if hi - lo + 1 > cap:
return [], ["list-too-long"]
if hi - lo + 1 > 200:
issues.append("range-suspiciously-long")
return list(range(lo, hi + 1)), issues
def expand_list(span, text=None):
"""Item values come from the label side when there is one: re-reading them
from bytes would reintroduce exactly the ambiguity the contract says to
quarantine. A *predicted* span has no label side, so for those the digits
inside the claimed boundary are read - the model's claim is the boundary.
"""
values = span.get("options", {}).get("items")
if values is None:
values = re.findall(r"\d+", text if text is not None else span["text"])
values = sorted({int(v) for v in values})
if not values:
return [], ["list-empty"]
if len(values) > MAX_EPISODES:
return [], ["list-too-long"]
return values, []
def debris(query, claims):
"""Unclaimed, non-punctuation text: what a full-label row cannot explain.
Only the container suffix at the very end of the query is excused, and it
is excused by *position*, not by appearance: a `.720p` left unclaimed in
the middle of a name is a missing decision and must be reported even
though it looks like an extension. Stripping dot-suffixes from the
leftovers instead silently forgives exactly the technical tags that strict
rows exist to catch."""
mask = bytearray(len(query))
suffix = EXTENSION.search(query) # anchored at `$`, so this is the final suffix
for start, end in list(claims) + ([(suffix.start(), suffix.end())] if suffix else []):
for i in range(max(0, min(start, len(query))), min(max(0, end), len(query))):
mask[i] = 1
text = "".join(ch for i, ch in enumerate(query) if not mask[i])
# Contract 3.3: a run of underscores or hyphens that only delimits fields is
# debris the pipeline removes, not text that needs a recorded decision. `_`
# counted as a word character here, which made every underscored layout look
# unexplainable.
text = text.replace("_", " ")
return [part for part in re.split(r"[^\w]+", text, flags=re.UNICODE) if part]
def record(query, spans, *, covered=None, check_debris=False):
"""Derive the emitted record from labeled spans. Returns (record|None,
issues). Issues never silently change a value; conflicting or
unrepresentable evidence is reported so the row can be quarantined."""
issues = []
claims = [(s["start"], s["end"]) for s in spans] + list(covered or [])
title, episode_title, episodes = [], [], []
group = year = season = resolution = dimensions = None
for span in sorted(spans, key=lambda s: (s["start"], s["end"])):
kind = span["kind"]
text = span.get("text")
if text is None:
text = query[span["start"]:span["end"]]
elif query[span["start"]:span["end"]] != text:
issues.append("span-text-mismatch")
opts = span.get("options", {})
if kind in ("TITLE", "EPISODE_TITLE"):
# Which fragment is the work title and which is the episode title is
# the annotator's decision, recorded as a span kind. Position cannot
# decide it: a second TITLE fragment is usually the same work title
# in another script, while a second EPISODE_TITLE fragment is the
# next segment of one episode's name.
bucket = title if kind == "TITLE" else episode_title
value = normalize_text(text, is_title=True,
fold_case=opts.get("fold_case", False))
if value:
bucket.append(value)
else:
issues.append("empty-title-fragment")
elif kind == "GROUP":
value = normalize_text(text)
if group is not None:
issues.append("group-multiple-brackets")
elif MULTI_GROUP.search(value):
issues.append("group-multiple-names")
group = group or value
elif kind == "YEAR":
digits = YEAR_LIKE.findall(text)
if len(digits) != 1:
issues.append("year-ambiguous")
continue
value = int(digits[0])
if not YEAR_MIN <= value <= YEAR_MAX:
issues.append("year-out-of-range")
elif year is not None and year != value:
issues.append("year-conflict")
else:
year = value
elif kind == "SEASON":
if "value" in span:
value = span["value"] # annotator-supplied (contract 5, worksheet)
else:
value = season_value(text) # predicted span: text decides, not a head
if value == "unparsable":
issues.append("season-unparsable")
continue
if value is None:
issues.append("season-unspecified")
elif season is not None and season != value:
issues.append("season-conflict")
elif not 0 <= value <= 99:
issues.append("season-out-of-range")
else:
season = value
elif kind == "EPISODE":
nums = re.findall(r"\d+", text)
if len(nums) != 1:
issues.append("episode-ambiguous")
else:
episodes.append(int(nums[0]))
elif kind == "RANGE":
values, extra = expand_range(text)
issues += extra
episodes += values
elif kind == "LIST":
values, extra = expand_list(span, text)
issues += extra
episodes += values
elif kind == "RESOLUTION":
value = normalize_text(text, fold_case=True)
if value not in SCHEMA_VALUES:
issues.append("resolution-unknown-class")
elif resolution and resolution != value:
issues.append("resolution-conflict")
else:
resolution = value
elif kind == "DIMENSIONS":
match = re.search(r"(\d{3,5})\s*[x×]\s*(\d{3,5})", text, re.I)
if not match:
issues.append("dimensions-unreadable")
continue
width, height = int(match.group(1)), int(match.group(2))
value = HEIGHT_CLASS.get(height) if MIN_ASPECT <= width / height <= MAX_ASPECT else None
if value is None:
issues.append("dimensions-no-class")
elif resolution and resolution != value:
issues.append("resolution-conflict")
else:
resolution, dimensions = value, text
elif kind != "DISCARD":
issues.append("unhandled-kind:" + kind)
if check_debris:
left = debris(query, claims)
if left:
issues.append("unexplained-debris:" + "|".join(left[:6]))
out = {}
if title:
# join=True: an edge bracket here belongs to an adjacent fragment, so
# only byte-internal rules apply to the assembled title.
out["title"] = normalize_text(" ".join(title), is_title=True, keep_edges=True)
if episode_title:
out["episode_title"] = normalize_text(" ".join(episode_title), is_title=True,
keep_edges=True)
if year is not None:
out["year"] = year
if season is not None:
out["season"] = season
if episodes:
values = sorted(set(episodes))
if len(values) > MAX_EPISODES:
# Contract 9: never silently truncate; the row is quarantined.
issues.append("list-too-long")
out["episodes"] = values
if group:
out["release_group"] = group
if resolution:
out["resolution"] = resolution
# `required: title` means a title-less record is the same output as no call.
return (out if out.get("title") else None), issues
def gated_record(query, spans, **kwargs):
"""`record` followed by the decision layer applied at prediction time.
A non-media suffix or the narrow CRC-prefixed titleless release-stem shape
forces abstention and reports why in the issues list.
"""
ext = non_media_extension(query)
trimmed, trimmed_count = trim_structural_edges(query, spans)
out, issues = record(query, trimmed, **kwargs)
if trimmed_count:
issues = issues + [f"trimmed-structural-edges:{trimmed_count}"]
if out is not None and ext is not None:
return None, issues + [f"gated-non-media-extension:{ext}"]
if out is not None and leading_hash_stem(query):
return None, issues + ["gated-leading-hash-stem"]
if out is not None and titleless_path_group(query, trimmed):
return None, issues + ["gated-titleless-path-group"]
return out, issues
|