Merge feature/orchestrator-radio-numbers into master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/requirements/001-agent-orchestrator.md
This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-10 22:12:34 +00:00
commit 9123e05cd1
3 files changed
+182 -16

No files matched your search

@@ -313,6 +313,27 @@ from three views down to any agent, branch or artefact.
old `#stream/radio` link opens the Stream with the spot open. Rejected:
keeping the Radio tab, the speaker chips (every Agents view already marks
the speaker).
60. **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
@@ -789,6 +810,8 @@ flowchart TD
tooltips, ETA in both modes, radio settings stored from the gear.
- [ ] S5.4: the navigation without the Radio tab, `#stream/radio`
redirecting to `#stream` with the spot flag.
- [ ] 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):