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>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-10 22:11:02 +00:00
1 parent 57ab29eb0d
commit 5dbf8d5f51
3 files changed
+182 -16

No files matched your search

@@ -303,6 +303,27 @@ from three views down to any agent, branch or artefact.
time-agent-depth layout.
58. **Steady 3D camera**: the 3D view fits the graph once per build; state
refreshes keep the operator's camera.
59. **Radio numbers and paths** (operator 2026-11-10): only the spoken line
changes, the displayed text stays as written. Digits are spoken in ICAO
words in Title Case, Ze-ro, One, Two, Tree, Fow-er, Fife, Six, Seven,
Eight, Ni-ner, and the NATO letters of hashes too (Alfa to Foxtrot);
this replaces the words of decisions 40 and 41. A time is four digits,
hour padded, no colon, seconds as two more digits; a UTC time ("08:15
UTC", "08:15Z", "0815Z", "08:15 Zulu") ends in one "Zulu", another zone
word follows the digits as written. A date with a time is the DTG
DDHHMM, "Zulu" when zoned, the full month name and the two-digit year
("One Ze-ro One Fow-er Tree Ze-ro Zulu November Two Six"); an explicit
offset is converted to UTC first, no zone drops "Zulu", a date alone is
day, month and year. A dot between digits is "Decimal" and the "v" of a
version is dropped. An absolute, home or relative path (the relative one
with a slash and an extension on its last segment) is "file <name> in
<parent>" when the last segment has an extension (a dot and 1 to 5
alphanumerics), else "folder <name> in <parent>"; a trailing slash means
folder, "." is "dot", "-" and "_" a space, `tmp` at the root "temp", `~`
"home"; a URL is no path. Paths go first, then date-times, times and the
remaining digits and hashes. Rejected for paths: every segment with
"slash", the file name only, mapping to known repositories, not speaking
paths.
## Approach
@@ -764,6 +785,8 @@ flowchart TD
business value signature (forged, replayed, altered), sort orders.
- [x] S5.1: regression and velocity trend with their forecast dates, point
tooltips, ETA in both modes, radio settings stored from the gear.
- [ ] S5.5: ICAO digit words, Zulu times, DTG with offset conversion,
dates, decimals and spoken paths.
- **Integration**
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
entry points (JSON on stdin): the post appears only on the callee's
+82 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta, timezone
from .fields import LINE
@@ -14,9 +15,25 @@ CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", r
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))
HASH = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|[0-9]", re.IGNORECASE)
NATO = dict(zip("abcdef", ("alfa", "bravo", "charlie", "delta", "echo", "foxtrot")))
ICAO = dict(zip("0123456789", ("zero", "one", "two", "tree", "four", "fife", "six", "seven", "eight", "niner")))
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:
@@ -24,18 +41,74 @@ def pronounced(text: str) -> str:
text: a radio text as displayed.
Returns:
The text with each hex run of 7 or more characters holding a letter
and a digit spelled in the NATO alphabet and every other digit
spoken on its own in ICAO pronunciation.
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.
"""
return HASH.sub(spelled, text)
for pattern, speak in ((PATH, path_words), (DATE_TIME, date_words), (TIME, time_words), (SPELLED, spelled)):
text = pattern.sub(speak, text)
return text
def spelled(found: re.Match) -> str:
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 + " ".join(NATO.get(char) or ICAO[char] for char in found[0].lower()) + after
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:
+77 -7
View File
@@ -230,18 +230,88 @@ class TestPhraseology(unittest.TestCase):
with self.subTest(arguments=arguments):
self.assertEqual(phrasing.phrase(*arguments), expected)
def test_hashes_spell_in_nato_and_digits_in_icao_only_when_spoken(self):
cases = (
("8e180be", "eight echo one eight zero bravo echo"),
("42305", "four two tree zero fife"),
("fix 8E180BE, PR #1326", "fix eight echo one eight zero bravo echo, PR #one tree two six"),
("8e180b decade deadbeef", "eight e one eight zero b decade deadbeef"),
)
def assertSpoken(self, cases: tuple[tuple[str, str], ...]) -> None:
for text, expected in cases:
with self.subTest(text=text):
self.assertEqual(phrasing.phrase("broadcast", "Docs", [], text), f"all stations, this is Docs, {expected}, out")
def test_hashes_spell_in_nato_and_digits_in_icao_only_when_spoken(self):
self.assertSpoken((
("8e180be", "Eight Echo One Eight Ze-ro Bravo Echo"),
("42305", "Fow-er Two Tree Ze-ro Fife"),
("0123456789", "Ze-ro One Two Tree Fow-er Fife Six Seven Eight Ni-ner"),
("fix 8E180BE, PR #1326", "fix Eight Echo One Eight Ze-ro Bravo Echo, PR #One Tree Two Six"),
("8e180b decade deadbeef abcdef0", "Eight e One Eight Ze-ro b decade deadbeef Alfa Bravo Charlie Delta Echo Foxtrot Ze-ro"),
))
self.assertEqual(phrasing.pronounced(phrasing.pronounced("8e180be 42305")), phrasing.pronounced("8e180be 42305"))
def test_times_are_four_digits_and_utc_ends_in_zulu(self):
self.assertSpoken((
("08:15 UTC", "Ze-ro Eight One Fife Zulu"),
("23:00 UTC", "Two Tree Ze-ro Ze-ro Zulu"),
("08:15Z", "Ze-ro Eight One Fife Zulu"),
("0815Z", "Ze-ro Eight One Fife Zulu"),
("08:15 Zulu", "Ze-ro Eight One Fife Zulu"),
("08:15:30 utc", "Ze-ro Eight One Fife Tree Ze-ro Zulu"),
("standup at 14:30", "standup at One Fow-er Tree Ze-ro"),
("restart at 9:05.", "restart at Ze-ro Ni-ner Ze-ro Fife."),
("08:15 CET", "Ze-ro Eight One Fife CET"),
("25:00 later", "Two Fife:Ze-ro Ze-ro later"),
))
def test_dates_and_date_time_groups_speak_as_dtg_in_zulu(self):
dtg = "One Ze-ro One Fow-er Tree Ze-ro Zulu November Two Six"
self.assertSpoken((
("2026-11-10T14:30Z", dtg),
("2026-11-10T14:30:00+00:00", dtg),
("2026-11-10T14:30:00.123456+00:00", dtg),
("2026-11-10 14:30 UTC", dtg),
("10 November 2026, 14:30 UTC", dtg),
("2026-11-10T15:30+01:00", dtg),
("2026-11-10T09:30-0500", dtg),
("2026-11-10T00:30+01:00", "Ze-ro Ni-ner Two Tree Tree Ze-ro Zulu November Two Six"),
("2026-01-01T00:30+01:00", "Tree One Two Tree Tree Ze-ro Zulu December Two Fife"),
("2026-11-10T14:30", "One Ze-ro One Fow-er Tree Ze-ro November Two Six"),
("2026-11-10 14:30 CET", "One Ze-ro One Fow-er Tree Ze-ro November Two Six CET"),
("since 2026-11-10.", "since One Ze-ro November Two Six."),
("10 November 2026", "One Ze-ro November Two Six"),
("1st Nov 2026", "Ze-ro One November Two Six"),
))
def test_a_dot_between_digits_is_decimal(self):
self.assertSpoken((
("release v17.0.0", "release One Seven Decimal Ze-ro Decimal Ze-ro"),
("load 3.5.", "load Tree Decimal Fife."),
("python3.12", "python Tree Decimal One Two"),
))
def test_paths_speak_as_file_or_folder_in_their_parent(self):
self.assertSpoken((
("/home/k/Repositories/x/skills/kvb-org-radio/scripts/radio.py", "file radio dot py in scripts"),
("/tmp/make-test-3.log", "file make test Tree dot log in temp"),
("/etc/docker/certs.d/", "folder certs dot d in docker"),
("/tmp/gui-playwright-bump", "folder gui playwright bump in temp"),
("/tmp", "folder temp"),
("/var/tmp/cache_dir", "folder cache dir in tmp"),
("(/x.log)", "(file x dot log)"),
("~/notes.md", "file notes dot md in home"),
("~/a/b", "folder b in a"),
("see skills/kvb-org-radio/SKILL.md.", "see file SKILL dot md in kvb org radio."),
("/tmp/worktree-8e180be", "folder worktree Eight Echo One Eight Ze-ro Bravo Echo in temp"),
("/tmp/report-2026-11-10.md", "file report Two Ze-ro Two Six One One One Ze-ro dot md in temp"),
("and/or", "and/or"),
("https://example.com/a/b.py", "https://example.com/a/b.py"),
("https://github.com/infinito-nexus/core/pull/1326", "https://github.com/infinito-nexus/core/pull/One Tree Two Six"),
))
def test_a_row_with_a_path_and_a_utc_time_reads_in_radio_phraseology(self):
text = "log /tmp/make-test-3.log since 08:15 UTC, fix 8e180be"
self.assertEqual(
phrasing.phrase("call", "Docs", ["Skills"], text),
"Skills, Skills, this is Docs, log file make test Tree dot log in temp since Ze-ro Eight One Fife Zulu, fix Eight Echo One Eight Ze-ro Bravo Echo, over",
)
self.assertEqual(phrasing.pronounced(phrasing.pronounced(text)), phrasing.pronounced(text))
def test_a_phrase_fits_the_speech_limit_on_one_line(self):
for cls in council.CLASSES:
with self.subTest(cls=cls):