Files
skills/orchestrator/kvb_orchestrator/phrasing.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 5dbf8d5f51 feat(orchestrator): speak radio numbers, times and paths the ICAO way
pronounced() rewrites a radio text for speech in four passes: paths become file or folder with name and parent (/tmp as temp, ~/ as home), dates with or without a clock become a date-time group converted to Zulu, times become four ICAO digits with Zulu for UTC, and hex runs, version numbers and the remaining digits are spelled in NATO and ICAO words with Decimal between digits. Invalid dates and times stay as written; the displayed text is unchanged.

🎯 Fix: 90 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 86
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:11:02 +00:00

154 lines
6.6 KiB
Python

from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta, timezone
from .fields import LINE
SPEECH_LIMIT = 1000
SPOKEN_TEXT = 500
SPOKEN_NAME = 60
SPOKEN_GROUP = 120
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "say-cure-ee-tay"}
ADDRESSED = ("post", "call", "receipt")
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
HUB_FILE = re.compile(r"\s+file: /\S*$")
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
NATO = dict(zip("abcdef", ("Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot")))
ICAO = dict(zip("0123456789", ("Ze-ro", "One", "Two", "Tree", "Fow-er", "Fife", "Six", "Seven", "Eight", "Ni-ner")))
SPOKEN = {**NATO, **ICAO, ".": "Decimal"}
MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December")
MONTH_NUMBER = {name[:3].lower(): number for number, name in enumerate(MONTHS, 1)}
MONTH = "|".join(f"{name[:3]}(?:{name[3:]})?" for name in MONTHS)
SEGMENT = r"[\w.@+-]+"
PATH = re.compile(rf"(?<![\w.~/:@+-])(?:(?P<root>~?/){SEGMENT}(?:/{SEGMENT})*|{SEGMENT}(?:/{SEGMENT})+)/?(?<=[\w@+/-])")
EXTENSION = re.compile(r"\.[0-9A-Za-z]{1,5}$")
NAME_SPACES = str.maketrans({".": " dot ", "-": " ", "_": " "})
CLOCK = r"(?P<hour>[0-9]{1,2}):(?P<minute>[0-9]{2})(?::(?P<second>[0-9]{2}))?"
UTC_MARK = r"Z\b|\s*(?i:UTC|Zulu)\b(?![+-][0-9])"
DATE_TIME = re.compile(
r"(?<![\w.:/+-])(?:(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})"
rf"|(?P<named_day>[0-9]{{1,2}})(?:st|nd|rd|th)?\s+(?P<name>(?i:{MONTH}))\s+(?P<named_year>[0-9]{{4}}))"
rf"(?:(?:T|,?\s+(?:at\s+)?){CLOCK}(?:\.[0-9]+)?(?P<zone>{UTC_MARK}|[+-][0-9]{{2}}(?::?[0-9]{{2}})?)?)?(?![\w:])"
)
TIME = re.compile(rf"(?<![\w.:])(?:{CLOCK}(?P<zone>{UTC_MARK})?(?![0-9:])|(?P<packed>[0-9]{{4}})Z\b)")
SPELLED = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|(?:\bv)?[0-9]+(?:\.[0-9]+)+|[0-9]", re.IGNORECASE)
def pronounced(text: str) -> str:
"""Args:
text: a radio text as displayed.
Returns:
The text as the radio speaks it, in this order: paths as "file" or
"folder" with their name and parent, date-times as DTG converted to
Zulu, times as four digits with Zulu for UTC, a dot between digits
as Decimal, each hex run of 7 or more characters holding a letter
and a digit in the NATO alphabet and every other digit on its own in
ICAO words.
"""
for pattern, speak in ((PATH, path_words), (DATE_TIME, date_words), (TIME, time_words), (SPELLED, spelled)):
text = pattern.sub(speak, text)
return text
def padded(found: re.Match, words: str) -> str:
start, end = found.span()
before = " " if start and found.string[start - 1].isalnum() else ""
after = " " if end < len(found.string) and found.string[end].isalnum() else ""
return before + words + after
def digits(text: str) -> str:
return " ".join(ICAO[char] for char in text)
def spelled(found: re.Match) -> str:
return padded(found, " ".join(SPOKEN[char] for char in found[0].lower().removeprefix("v")))
def segment_words(segment: str) -> str:
return " ".join(SPELLED.sub(spelled, segment.translate(NAME_SPACES)).split())
def path_words(found: re.Match) -> str:
root = found["root"] or ""
chain = found[0].removeprefix(root).rstrip("/").split("/")
typed = EXTENSION.search(chain[-1])
if not root and not typed:
return found[0]
names = [segment_words(segment) for segment in chain]
if root == "/" and chain[0] == "tmp":
names[0] = "temp"
if root == "~/":
names.insert(0, "home")
kind = "file" if typed and not found[0].endswith("/") else "folder"
return padded(found, f"{kind} {names[-1]}" + (f" in {names[-2]}" if len(names) > 1 else ""))
def date_words(found: re.Match) -> str:
if found["year"]:
year, month, day = int(found["year"]), int(found["month"]), int(found["day"])
else:
year, month, day = int(found["named_year"]), MONTH_NUMBER[found["name"][:3].lower()], int(found["named_day"])
zone = (found["zone"] or "").strip()
try:
moment = datetime(year, month, day, int(found["hour"] or 0), int(found["minute"] or 0))
if zone[:1] in ("+", "-"):
shift = timedelta(hours=int(zone[1:3]), minutes=int(zone[3:].lstrip(":") or 0))
moment = moment.replace(tzinfo=timezone(-shift if zone[0] == "-" else shift)).astimezone(UTC)
except (ValueError, OverflowError):
return found[0]
clock = digits(f"{moment:%d%H%M}" if found["hour"] else f"{moment:%d}")
return padded(found, f"{clock}{' Zulu' if zone else ''} {MONTHS[moment.month - 1]} {digits(f'{moment.year % 100:02}')}")
def time_words(found: re.Match) -> str:
clock = found["packed"] or found["hour"].zfill(2) + found["minute"] + (found["second"] or "")
if int(clock[:2]) > 23 or int(clock[2:4]) > 59 or int(clock[4:] or 0) > 59:
return found[0]
return padded(found, digits(clock) + (" Zulu" if found["packed"] or found["zone"] else ""))
def clip(text: str, limit: int) -> str:
words = " ".join(pronounced(text).split())
if len(words) <= limit:
return words
return words[:limit].rsplit(" ", 1)[0] or words[:limit]
def spoken_text(cls: str, text: str) -> str:
body = HUB_MARK.sub("", text)
if body != text:
body = HUB_FILE.sub("", body)
if cls in ("mayday", *ALARM_WORDS):
body = CALLS.sub("", body.strip())
body = clip(body, SPOKEN_TEXT)
return "" if body == "-" else body
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
"""Args:
cls: the radio class of the row, one of council.CLASSES or receipt.
sender: the sender's name valid at the row's time.
called: the names of the called stations valid at the row's time.
text: the radio text.
Returns:
The row in maritime radio phraseology (SMCP style), one line of at
most SPEECH_LIMIT characters.
"""
station = clip(sender, SPOKEN_NAME) or "unknown station"
if cls == "mayday":
head, end = f"MAYDAY MAYDAY MAYDAY, this is {station} {station} {station}", "over"
elif cls in ALARM_WORDS:
word = ALARM_WORDS[cls]
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
elif cls in ADDRESSED and called:
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
head, end = f"{group}, {group}, this is {station}", "over"
else:
head, end = f"all stations, this is {station}", "out"
return ", ".join(part for part in (head, spoken_text(cls, text), end) if part)