From 5dbf8d5f519ec10c107469cb60bc50b2fd987c0d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 10 Nov 2026 22:11:02 +0000 Subject: [PATCH] feat(orchestrator): speak radio numbers, times and paths the ICAO way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/requirements/001-agent-orchestrator.md | 23 ++++++ orchestrator/kvb_orchestrator/phrasing.py | 91 +++++++++++++++++++-- tests/test_hub_speech.py | 84 +++++++++++++++++-- 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 02475bc..7a9aaba 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -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 in + " when the last segment has an extension (a dot and 1 to 5 + alphanumerics), else "folder in "; 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 diff --git a/orchestrator/kvb_orchestrator/phrasing.py b/orchestrator/kvb_orchestrator/phrasing.py index a2375bc..039d594 100644 --- a/orchestrator/kvb_orchestrator/phrasing.py +++ b/orchestrator/kvb_orchestrator/phrasing.py @@ -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"(?~?/){SEGMENT}(?:/{SEGMENT})*|{SEGMENT}(?:/{SEGMENT})+)/?(?<=[\w@+/-])") +EXTENSION = re.compile(r"\.[0-9A-Za-z]{1,5}$") +NAME_SPACES = str.maketrans({".": " dot ", "-": " ", "_": " "}) +CLOCK = r"(?P[0-9]{1,2}):(?P[0-9]{2})(?::(?P[0-9]{2}))?" +UTC_MARK = r"Z\b|\s*(?i:UTC|Zulu)\b(?![+-][0-9])" +DATE_TIME = re.compile( + r"(?[0-9]{4})-(?P[0-9]{2})-(?P[0-9]{2})" + rf"|(?P[0-9]{{1,2}})(?:st|nd|rd|th)?\s+(?P(?i:{MONTH}))\s+(?P[0-9]{{4}}))" + rf"(?:(?:T|,?\s+(?:at\s+)?){CLOCK}(?:\.[0-9]+)?(?P{UTC_MARK}|[+-][0-9]{{2}}(?::?[0-9]{{2}})?)?)?(?![\w:])" +) +TIME = re.compile(rf"(?{UTC_MARK})?(?![0-9:])|(?P[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: diff --git a/tests/test_hub_speech.py b/tests/test_hub_speech.py index 623ff74..6d11db6 100644 --- a/tests/test_hub_speech.py +++ b/tests/test_hub_speech.py @@ -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):