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):