Fix phantom "Important:" header stealing a taking-fire report's deadline

Turned out to be one bug, not two: "Answer by <time>" phrasing was
already covered by _TAKING_FIRE_TIME_RE ('before|by <time>'). The real
bug was the last-resort bare-"<Name>:" header fallback matching a
same-message "Important:" follow-up line as a brand new named entity
(nothing excluded common prose lead-ins), creating a bogus
Target#Important that stole the deadline into its own requested_time
instead of the real report's. Fixed with a blocklist on that fallback
rule (important/note/warning/attention/caution/alert/reminder/priority).
New regression test, 55 total passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-13 22:09:15 +02:00
parent 6a61bffb22
commit 6e34f50b6a
3 changed files with 61 additions and 15 deletions

29
TODO.md
View File

@ -333,21 +333,22 @@ each confirmed live against the real VM, not just read off a diff:
fixing: does the board/firing-panel data model even have a place fixing: does the board/firing-panel data model even have a place
to put a second shell for one strike request, or does this need a to put a second shell for one strike request, or does this need a
new field/shape entirely? new field/shape entirely?
- [ ] "Answer by 10:30:00" isn't recognized as a deadline at all -- - [x] "Answer by 10:30:00" turned out to be one bug, not two. The
`_TAKING_FIRE_TIME_RE`/`_REQUESTED_BEFORE_RE` only know "before/by phrasing itself was never the problem -- `_TAKING_FIRE_TIME_RE`
\<time\>" and "Requested before - \<time\> -" phrasing. A third already matches any `before|by <time>`, "Answer BY 10:30:00"
deadline grammar to add. included. The REAL bug: a same-message "Important: ..." follow-up
- [ ] A same-message "Important: ... Answer by \<time\>" follow-up line line was misread as a brand new named entity header (the
gets misread as a brand new named entity header (the last-resort last-resort bare-`<name>:` fallback rule matched "Important:"
bare-`<name>:` fallback rule matches "Important:"), creating a itself with nothing excluding common prose lead-ins), creating a
bogus `Target#Important` carrying that deadline as its own bogus `Target#Important` that stole "Answer by 10:30:00" into ITS
`requested_time`. Real false-positive in the header-detection own `requested_time` instead of the real report's. Fixed with a
fallback, needs either an exclusion list (blocklist "Important" blocklist (`_BARE_NAME_HEADER_BLOCKLIST`: important/note/warning/
and similar prose lead-ins) or a stricter bare-header shape. attention/caution/alert/reminder/priority) on that fallback rule --
Confirmed live against the user's real pasted message (with an once the phantom split stopped happening, the deadline resolved
onto the right entry with no separate fix needed. New regression
test, confirmed against the user's real pasted message (with an
assumed `Infantry#N taking fire!` header line prepended, since assumed `Infantry#N taking fire!` header line prepended, since
their paste seems to have been cropped before it -- worth their paste seems to have been cropped before it).
double-checking against the actual full in-game message).
- [x] "\<Type\>#\<id\> Destroyed" kill-feed parsing already exists and - [x] "\<Type\>#\<id\> Destroyed" kill-feed parsing already exists and
already marks the matching Target dead (`parse_destroyed`, already marks the matching Target dead (`parse_destroyed`,

View File

@ -375,6 +375,17 @@ _NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$")
# one), nothing else anchors this match, so an optional colon would # one), nothing else anchors this match, so an optional colon would
# false-positive on an ordinary clue-continuation line's leading word. # false-positive on an ordinary clue-continuation line's leading word.
_BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$") _BARE_NAME_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*)$")
# Even requiring the colon isn't quite enough: a prose lead-in word right
# before a genuinely useful follow-up line ("Important: TEAR Shell first,
# then HE Shell.") false-positives the same way -- confirmed live against
# a real taking-fire message, where "Important:" got read as a brand new
# named entity ("Target#Important"), stealing the actual report's own
# "Answer by <time>" deadline into that bogus entry instead of the real
# one. None of these read as an actual thing being spotted/named, only
# ever as a prose interjection.
_BARE_NAME_HEADER_BLOCKLIST = {
"important", "note", "warning", "attention", "caution", "alert", "reminder", "priority",
}
# Ad-hoc enemy installations are named in plain English rather than given a # Ad-hoc enemy installations are named in plain English rather than given a
# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal # Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal
@ -908,7 +919,11 @@ def parse_intel_blocks(text: str) -> list[dict]:
# 'Target'), not a Reference Point, a named thing giving its own # 'Target'), not a Reference Point, a named thing giving its own
# clues is being spotted, not a fixed landmark spotters aim off # clues is being spotted, not a fixed landmark spotters aim off
# of, same reasoning as the "Target is at-" calibration line. # of, same reasoning as the "Target is at-" calibration line.
bare_m = next((m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))), None) bare_m = next(
(m for c in candidates if (m := _BARE_NAME_HEADER_RE.match(c))
and m.group(1).lower() not in _BARE_NAME_HEADER_BLOCKLIST),
None,
)
if bare_m: if bare_m:
flush() flush()
name = bare_m.group(1) name = bare_m.group(1)

View File

@ -383,3 +383,33 @@ def test_infantry_taking_fire_bearing_distance_short_range():
assert coord == Coord("H", 7, 8, 4) assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE assert shell is Shell.HE
assert requested_time == "10:17:37" assert requested_time == "10:17:37"
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
"""A real user-pasted message: a same-report "Important: ... Answer by
<time>" follow-up line was being misread as a brand new named entity
header (the last-resort bare-"<Name>:" fallback matched "Important:"
itself), creating a bogus Target#Important that stole the actual
report's own deadline into that wrong entry instead of the real
StrikeRequest. "Answer by <time>" is also a deadline phrasing
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
once the phantom split stops happening, it resolves correctly with
no extra fix needed."""
text = ("Infantry#4 taking fire!\n"
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
"Answer by <u>10:30:00</u>")
info = ocr.parse_text(text)
assert (TargetType.UNKNOWN, "Important") not in info.targets
assert (TargetType.INFANTRY, "4") in info.allies
assert (TargetType.INFANTRY, "4") not in info.targets
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
assert requested_time == "10:30:00"
# Known gap, not asserted as fixed here: only the FIRST shell of a
# "X first, then Y" sequence is captured -- see TODO.md.
assert shell is Shell.TEAR