From 8f90a167e70690d4983916ce7be726e3a4c401a7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:33:39 -0400 Subject: [PATCH 1/8] feat(capture): scrub narration and store it as timed sentences The transcript is scrubbed with openadapt-privacy before it is stored, and recording refuses before the microphone opens when no scrubber is installed. Scrubbed sentences go to narration.json on the recording clock; per-word text is no longer stored. capture transcribe scrubs too. Synthetic WAV fixture. Co-Authored-By: Claude Opus 5.5 --- packages/capture/openadapt_capture/capture.py | 10 + packages/capture/openadapt_capture/cli.py | 176 ++++------- .../capture/openadapt_capture/narration.py | 294 ++++++++++++++++++ .../capture/openadapt_capture/recorder.py | 48 ++- .../fixtures/narration/medicare_copay.wav | Bin 0 -> 150012 bytes packages/capture/tests/test_audio_privacy.py | 22 +- packages/capture/tests/test_narration.py | 237 ++++++++++++++ 7 files changed, 662 insertions(+), 125 deletions(-) create mode 100644 packages/capture/openadapt_capture/narration.py create mode 100644 packages/capture/tests/fixtures/narration/medicare_copay.wav create mode 100644 packages/capture/tests/test_narration.py diff --git a/packages/capture/openadapt_capture/capture.py b/packages/capture/openadapt_capture/capture.py index bcacdfaa8..30189f8f5 100644 --- a/packages/capture/openadapt_capture/capture.py +++ b/packages/capture/openadapt_capture/capture.py @@ -1322,6 +1322,16 @@ def desktop_capture(self) -> dict | None: return config.get("capture_desktop") return None + def narration(self) -> list | None: + """Scrubbed spoken narration as timed sentences, or None. + + Reads ``narration.json`` (see :mod:`openadapt_capture.narration`). + Times are on the recording clock, like action timestamps. + """ + from openadapt_capture.narration import read_narration + + return read_narration(self.capture_dir) + @property def audio_start_time(self) -> float | None: """Start timestamp of the audio recording, or None if unavailable.""" diff --git a/packages/capture/openadapt_capture/cli.py b/packages/capture/openadapt_capture/cli.py index 2cac104cc..214daf409 100644 --- a/packages/capture/openadapt_capture/cli.py +++ b/packages/capture/openadapt_capture/cli.py @@ -101,6 +101,11 @@ def record( except NoLocalTranscriptionBackend as exc: print(str(exc)) raise SystemExit(1) from exc + from openadapt_capture.narration import NO_SCRUBBER_MESSAGE, narration_scrubber + + if narration_scrubber() is None: + print(NO_SCRUBBER_MESSAGE) + raise SystemExit(1) retain = settings.RECORD_AUDIO_RETAIN_WAVEFORM print("MICROPHONE ON: this recording captures spoken narration.") @@ -111,8 +116,8 @@ def record( else " Waveform discarded after transcription; only text is retained." ) print( - " Anything said aloud is retained as text. Do not speak names, " - "dates of birth, or other identifying details." + " The text is scrubbed of patient data before it is stored. Still, " + "do not speak names, dates of birth, or other identifying details." ) print() @@ -328,11 +333,13 @@ def transcribe( model: str = "base", backend: str = "auto", ) -> None: - """Transcribe a capture's audio using an on-device Whisper model. + """Transcribe a capture's retained audio on this machine and store narration. Transcription runs entirely on this machine. There is no remote backend: a raw waveform cannot be sanitized before upload and discloses the - speaker's voice as well as their words. + speaker's voice as well as their words. The transcript is scrubbed with + openadapt-privacy and written to ``narration.json`` (timed sentences) and + ``transcript.txt``. Without the scrubber nothing is written. Args: capture_dir: Path to capture directory. @@ -346,11 +353,17 @@ def transcribe( NoLocalTranscriptionBackend, resolve_transcription_backend, ) + from openadapt_capture.narration import ( + NO_SCRUBBER_MESSAGE, + narration_scrubber, + scrub_utterances, + transcribe_file, + utterances_from_transcript, + write_narration, + ) capture_dir = Path(capture_dir) audio_path = capture_dir / "audio.flac" - transcript_path = capture_dir / "transcript.txt" - transcript_json_path = capture_dir / "transcript.json" if not audio_path.exists(): print(f"No audio file found at: {audio_path}") @@ -361,124 +374,54 @@ def transcribe( except (NoLocalTranscriptionBackend, ValueError) as exc: print(str(exc)) raise SystemExit(1) from exc + scrubber = narration_scrubber() + if scrubber is None: + print(NO_SCRUBBER_MESSAGE) + raise SystemExit(1) - print(f"Transcribing on-device with backend: {backend}") - - if backend == "faster-whisper": - _transcribe_faster_whisper(audio_path, transcript_path, transcript_json_path, model) - else: - _transcribe_local(audio_path, transcript_path, transcript_json_path, model) - - -def _transcribe_local( - audio_path: Path, - transcript_path: Path, - transcript_json_path: Path, - model: str, -) -> None: - """Transcribe using local openai-whisper model.""" - - print(f"Transcribing audio with openai-whisper ({model} model)...") - print("This may take a moment...") - + offset = 0.0 try: - import whisper - except ImportError: - print("Whisper not installed. Install with: pip install openai-whisper") - print("Or use faster-whisper: pip install faster-whisper") - return - - # Load model and transcribe with word timestamps - whisper_model = whisper.load_model(model) - result = whisper_model.transcribe(str(audio_path), fp16=False, word_timestamps=True) - - transcript = result.get("text", "").strip() - - # Extract segments with timestamps - segments = [] - for segment in result.get("segments", []): - segments.append({ - "start": segment["start"], - "end": segment["end"], - "text": segment["text"].strip(), - }) - - # Save transcripts - _save_transcript(transcript, segments, transcript_path, transcript_json_path) - - -def _transcribe_faster_whisper( - audio_path: Path, - transcript_path: Path, - transcript_json_path: Path, - model: str, -) -> None: - """Transcribe using faster-whisper (4x faster than openai-whisper).""" + from openadapt_capture.capture import CaptureSession - print(f"Transcribing audio with faster-whisper ({model} model)...") - print("This is 4x faster than openai-whisper...") + offset = CaptureSession.load(capture_dir).audio_start_time or 0.0 + except Exception: # noqa: BLE001 - a bare audio folder has no recording clock + offset = 0.0 - try: - from faster_whisper import WhisperModel - except ImportError: - print("faster-whisper not installed. Install with: pip install faster-whisper") - print("Or use openai-whisper: pip install openai-whisper") - return - - # Load model with CPU and int8 for efficiency - whisper_model = WhisperModel(model, device="cpu", compute_type="int8") - - # Transcribe with word timestamps - segments_iter, info = whisper_model.transcribe( - str(audio_path), - word_timestamps=True, + print(f"Transcribing on-device with backend: {backend}") + result = transcribe_file(audio_path, backend=backend, model=model) + utterances = scrub_utterances( + utterances_from_transcript(result, offset=offset), scrubber ) + path = write_narration( + capture_dir, + utterances, + scrubber=scrubber, + transcription_backend=backend, + audio_start=offset, + ) + transcript_path = capture_dir / "transcript.txt" + transcript_path.write_text( + " ".join(u.text for u in utterances) + "\n", encoding="utf-8" + ) + print(f"Saved scrubbed narration to: {path}") + print(f"Saved scrubbed transcript to: {transcript_path}") + for u in utterances: + rel = u.start - offset + print(f"[{int(rel // 60)}:{rel % 60:05.2f}] {u.text}") - # Collect segments - segments = [] - full_text_parts = [] - for segment in segments_iter: - segments.append({ - "start": segment.start, - "end": segment.end, - "text": segment.text.strip(), - }) - full_text_parts.append(segment.text) - - transcript = "".join(full_text_parts).strip() - - # Save transcripts - _save_transcript(transcript, segments, transcript_path, transcript_json_path) - - -def _save_transcript( - transcript: str, - segments: list[dict], - transcript_path: Path, - transcript_json_path: Path, -) -> None: - """Save transcript to files and print output.""" - import json - - # Save plain text transcript - transcript_path.write_text(transcript, encoding="utf-8") - # Save JSON with timestamps - transcript_json_path.write_text( - json.dumps({"text": transcript, "segments": segments}, indent=2), - encoding="utf-8" - ) +def narration(capture_dir: str) -> None: + """Print a capture's scrubbed narration, one timed sentence per line.""" + from openadapt_capture.narration import read_narration - print(f"Saved transcript to: {transcript_path}") - print(f"Saved timestamps to: {transcript_json_path}") - print() - print("Transcript:") - print("-" * 40) - for seg in segments: - start = seg["start"] - mins = int(start // 60) - secs = start % 60 - print(f"[{mins}:{secs:05.2f}] {seg['text']}") + utterances = read_narration(capture_dir) + if utterances is None: + print(f"No narration.json in {capture_dir}") + return + first = utterances[0].start if utterances else 0.0 + for u in utterances: + rel = u.start - first + print(f"[{int(rel // 60)}:{rel % 60:05.2f}] {u.text}") def install_ffmpeg( @@ -618,6 +561,7 @@ def main() -> None: "visualize": visualize, "info": info, "transcribe": transcribe, + "narration": narration, "share": share, "install-ffmpeg": install_ffmpeg, "uninstall-ffmpeg": uninstall_ffmpeg, diff --git a/packages/capture/openadapt_capture/narration.py b/packages/capture/openadapt_capture/narration.py new file mode 100644 index 000000000..25586ccc7 --- /dev/null +++ b/packages/capture/openadapt_capture/narration.py @@ -0,0 +1,294 @@ +"""Spoken narration beside a recording: utterances, scrubbing and the sidecar. + +People explain their choices out loud while they demonstrate ("the insurance +is Medicare, so I skip the copay screen"). This module turns an on-device +transcript into timed **utterances**, scrubs patient data out of them with +``openadapt-privacy``, and writes them next to the recording as +``narration.json``. ``openadapt-flow`` reads that file, lines each utterance up +with the recorded steps, and uses it to *propose* (never certify) a branch rule. + +Rules this module enforces: + +1. **Scrub before storing.** :func:`scrub_utterances` runs every utterance + through the scrubber before anything is written. With no scrubber (the + ``privacy`` extra is missing) it raises :class:`NarrationNotScrubbed`, and + the recorder stores no transcript at all. There is no plaintext fallback. +2. **Sentences, not words.** Word-level timings are used only to cut a segment + into sentences, then dropped. A name spoken one word at a time cannot be + recognised by a text scrubber, so per-word text is never stored. +3. **Local only.** :func:`transcribe_file` uses the same on-device backends as + :mod:`openadapt_capture.audio`. Nothing is uploaded. + +Timestamps in ``narration.json`` are on the recording's clock (the same clock +as action events), so an utterance can be matched to the step it explains on +any backend: web, native, RDP or Citrix. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile +from dataclasses import asdict, dataclass +from importlib import metadata +from pathlib import Path +from typing import Any, Optional, Protocol, Sequence, runtime_checkable + +NARRATION_FILE = "narration.json" +NARRATION_SCHEMA = "openadapt.capture.narration/v1" + +_SENTENCE_END = re.compile(r"[.?!]['\")\]]*$") + +NO_SCRUBBER_MESSAGE = ( + "Narration is not stored without scrubbing. Spoken words can name a " + "patient, and openadapt-privacy is not installed to remove that. Install " + "it with:\n" + ' pip install "openadapt-capture[audio,privacy]"\n' + " python -m spacy download en_core_web_sm" +) + + +class NarrationNotScrubbed(RuntimeError): + """The transcript could not be scrubbed, so it was not stored.""" + + def __init__(self, message: str = NO_SCRUBBER_MESSAGE) -> None: + super().__init__(message) + + +@runtime_checkable +class TextScrubber(Protocol): + """The part of an ``openadapt-privacy`` provider narration needs.""" + + def scrub_text(self, text: str, is_separated: bool = False) -> str: ... + + +@dataclass(frozen=True) +class Utterance: + """One spoken sentence, timed on the recording's clock (seconds).""" + + start: float + end: float + text: str + + +def narration_scrubber() -> Optional[TextScrubber]: + """The scrubber narration uses: the same provider ``share send`` uses.""" + from openadapt_capture.share_scrub import get_scrubber + + return get_scrubber() + + +def _scrubber_evidence(scrubber: TextScrubber) -> dict[str, Any]: + try: + version = metadata.version("openadapt-privacy") + except metadata.PackageNotFoundError: + version = None + return { + "class": f"{type(scrubber).__module__}.{type(scrubber).__qualname__}", + "openadapt_privacy_version": version, + } + + +def utterances_from_transcript( + result: dict[str, Any], *, offset: float = 0.0 +) -> list[Utterance]: + """Split a Whisper-style result into sentence utterances. + + ``result`` is what :meth:`openadapt_capture.audio.AudioRecorder.transcribe` + returns: ``{"segments": [{"start", "end", "text", "words"?}]}`` with times + relative to the start of the audio. ``offset`` (the audio's start time on + the recording clock) is added to every time. Every segment is used, not + only the first. When a segment has word timings it is cut at words that + end a sentence; otherwise the whole segment is one utterance. + """ + out: list[Utterance] = [] + for segment in result.get("segments") or []: + words = segment.get("words") or [] + if words: + current: list[dict[str, Any]] = [] + for word in words: + current.append(word) + if _SENTENCE_END.search(str(word.get("word", "")).strip()): + out.append(_join(current, offset)) + current = [] + if current: + out.append(_join(current, offset)) + continue + text = str(segment.get("text") or "").strip() + if text: + out.append( + Utterance( + start=round(float(segment.get("start", 0.0)) + offset, 3), + end=round(float(segment.get("end", 0.0)) + offset, 3), + text=text, + ) + ) + if not out and str(result.get("text") or "").strip(): + # No segments (some engines, or a stub): one utterance at the start. + out.append(Utterance(start=offset, end=offset, text=str(result["text"]).strip())) + return [u for u in out if u.text] + + +def _join(words: Sequence[dict[str, Any]], offset: float) -> Utterance: + text = "".join(str(w.get("word", "")) for w in words).strip() + text = re.sub(r"\s+", " ", text) + return Utterance( + start=round(float(words[0].get("start", 0.0)) + offset, 3), + end=round(float(words[-1].get("end", 0.0)) + offset, 3), + text=text, + ) + + +def scrub_utterances( + utterances: Sequence[Utterance], scrubber: Optional[TextScrubber] +) -> list[Utterance]: + """Return the utterances with patient data scrubbed out. + + Raises :class:`NarrationNotScrubbed` when there is no scrubber or it + fails. The caller must then store nothing. + """ + if scrubber is None: + raise NarrationNotScrubbed() + out: list[Utterance] = [] + for u in utterances: + try: + text = scrubber.scrub_text(u.text) + except Exception as exc: # noqa: BLE001 - any failure means not scrubbed + raise NarrationNotScrubbed( + f"The scrubber failed on the transcript, so it was not stored: {exc}" + ) from exc + out.append(Utterance(start=u.start, end=u.end, text=str(text or "").strip())) + return out + + +def write_narration( + directory: str | Path, + utterances: Sequence[Utterance], + *, + scrubber: TextScrubber, + transcription_backend: Optional[str] = None, + audio_start: Optional[float] = None, +) -> Path: + """Write already-scrubbed utterances to ``/narration.json``. + + The file says which scrubber ran. It is written atomically. + """ + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + payload = { + "schema": NARRATION_SCHEMA, + "clock": "recording", + "audio_start": audio_start, + "transcription": {"backend": transcription_backend, "on_device": True}, + "scrubbed": True, + "scrubbed_by": _scrubber_evidence(scrubber), + "utterances": [asdict(u) for u in utterances], + } + path = directory / NARRATION_FILE + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".narration-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + handle.write("\n") + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return path + + +def read_narration(directory: str | Path) -> Optional[list[Utterance]]: + """Read ``narration.json`` from a capture directory, or ``None``. + + Refuses (``ValueError``) a file that does not say it was scrubbed. + """ + path = Path(directory) / NARRATION_FILE + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") != NARRATION_SCHEMA: + raise ValueError(f"{path} is not a narration file ({data.get('schema')!r})") + if data.get("scrubbed") is not True: + raise ValueError(f"{path} does not say it was scrubbed; refusing to read it") + return [ + Utterance(start=float(u["start"]), end=float(u["end"]), text=str(u["text"])) + for u in data.get("utterances") or [] + ] + + +SAMPLE_RATE = 16000 + + +def load_waveform(path: str | Path) -> Any: + """Read a 16 kHz audio file as a mono float32 array (what Whisper takes). + + WAV is read with the standard library; other formats (FLAC) need + ``soundfile`` from the ``audio`` extra. Other sample rates are refused + rather than resampled silently. + """ + import numpy as np + + path = Path(path) + if path.suffix.lower() == ".wav": + import wave + + with wave.open(str(path), "rb") as handle: + rate = handle.getframerate() + channels = handle.getnchannels() + width = handle.getsampwidth() + raw = handle.readframes(handle.getnframes()) + if width != 2: + raise ValueError(f"{path}: only 16-bit PCM WAV is supported") + data = np.frombuffer(raw, dtype=" 1: + data = data.reshape(-1, channels).mean(axis=1) + else: + import soundfile + + data, rate = soundfile.read(str(path), dtype="float32", always_2d=True) + data = data.mean(axis=1) + if rate != SAMPLE_RATE: + raise ValueError(f"{path}: sample rate {rate} Hz; narration needs 16000 Hz") + return np.ascontiguousarray(data, dtype=np.float32) + + +def transcribe_file( + path: str | Path, *, backend: str = "auto", model: str = "base" +) -> dict[str, Any]: + """Transcribe an audio file on this machine (no network recognizer). + + Returns a Whisper-style result with word timings. Raises + :class:`~openadapt_capture.audio.NoLocalTranscriptionBackend` when no local + backend is installed. + """ + from openadapt_capture.audio import resolve_transcription_backend + + chosen = resolve_transcription_backend(backend) + waveform = load_waveform(path) + if chosen == "faster-whisper": + from faster_whisper import WhisperModel + + engine = WhisperModel(model, device="cpu", compute_type="int8") + segments, info = engine.transcribe(waveform, word_timestamps=True) + out = [] + for seg in segments: + item: dict[str, Any] = {"start": seg.start, "end": seg.end, "text": seg.text} + if seg.words: + item["words"] = [ + {"word": w.word, "start": w.start, "end": w.end} for w in seg.words + ] + out.append(item) + return { + "text": "".join(s["text"] for s in out), + "segments": out, + "language": info.language, + "backend": chosen, + } + import whisper + + result = whisper.load_model(model).transcribe( + waveform, word_timestamps=True, fp16=False + ) + result["backend"] = chosen + return result diff --git a/packages/capture/openadapt_capture/recorder.py b/packages/capture/openadapt_capture/recorder.py index 73c2c3c2c..ace3b7115 100644 --- a/packages/capture/openadapt_capture/recorder.py +++ b/packages/capture/openadapt_capture/recorder.py @@ -2684,6 +2684,10 @@ def record_audio( text is retained by default. - The transcript is never logged. Spoken narration may contain identifying details and must not be copied into logs or terminal scrollback. + - The transcript is scrubbed with openadapt-privacy before it is stored. + Without a scrubber this refuses before the microphone opens. The scrubbed + sentences are written to ``narration.json`` beside the database, timed on + the recording clock; per-word text and timings are not stored. Args: recording: The recording object. @@ -2698,6 +2702,13 @@ def record_audio( # captured, and only then did the missing backend surface -- recording the # operator for nothing and failing the capture at the end. backend = require_local_transcription_backend() + # The transcript is scrubbed before it is stored. Without a scrubber there + # is nothing safe to store, so refuse before the microphone opens too. + from openadapt_capture.narration import NarrationNotScrubbed, narration_scrubber + + scrubber = narration_scrubber() + if scrubber is None: + raise NarrationNotScrubbed() utils.set_start_time(recording.timestamp) @@ -2784,13 +2795,34 @@ def audio_callback( "Transcription complete ({} characters).".format(len(result_info.get("text") or "")) ) - # empty word_list if the user didn't say anything - word_list = [] - # segments could be empty - if len(result_info["segments"]) > 0: - # there won't be a 'words' list if the user didn't say anything - if "words" in result_info["segments"][0]: - word_list = result_info["segments"][0]["words"] + # Cut the transcript into timed sentences and scrub each one before + # anything is stored. Word-level timings are dropped: a name spoken one + # word at a time cannot be recognised by a text scrubber. + from pathlib import Path + + from openadapt_capture.narration import ( + scrub_utterances, + utterances_from_transcript, + write_narration, + ) + + try: + utterances = scrub_utterances( + utterances_from_transcript(result_info, offset=start_timestamp), scrubber + ) + except NarrationNotScrubbed as exc: + logger.error("Narration not stored: {}", type(exc).__name__) + utterances = None + transcript = " ".join(u.text for u in utterances) if utterances else "" + word_list: list = [] + if utterances and db_path not in (None, ":memory:"): + write_narration( + Path(db_path).parent, + utterances, + scrubber=scrubber, + transcription_backend=backend, + audio_start=start_timestamp, + ) if config.RECORD_AUDIO_RETAIN_WAVEFORM: # Explicitly opted in. The retained waveform is biometric identifying @@ -2817,7 +2849,7 @@ def audio_callback( crud.insert_audio_info( session, compressed_audio_bytes, - result_info["text"], + transcript, recording, start_timestamp, sample_rate, diff --git a/packages/capture/tests/fixtures/narration/medicare_copay.wav b/packages/capture/tests/fixtures/narration/medicare_copay.wav new file mode 100644 index 0000000000000000000000000000000000000000..cff2c1e51a9132a8098273bba80053890866e9ca GIT binary patch literal 150012 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�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 ZAAVAzfz3P0cvzBho;+Mi*7*N>{RiY-k`Mp@ literal 0 HcmV?d00001 diff --git a/packages/capture/tests/test_audio_privacy.py b/packages/capture/tests/test_audio_privacy.py index dea53459c..bf670c1f9 100644 --- a/packages/capture/tests/test_audio_privacy.py +++ b/packages/capture/tests/test_audio_privacy.py @@ -37,6 +37,22 @@ PACKAGE_ROOT = Path(audio_mod.__file__).resolve().parent +class _UpperScrubber: + """A stand-in scrubber: replaces the word 'patient' so tests can see it ran.""" + + def scrub_text(self, text: str, is_separated: bool = False) -> str: + return text.replace("patient", "") + + +@pytest.fixture(autouse=True) +def _scrubber(): + from openadapt_capture import share_scrub + + share_scrub.set_scrubber(_UpperScrubber()) + yield + share_scrub.reset_scrubber() + + # --------------------------------------------------------------------------- # 1. On-device only: no network recognizer, and no silent fallback to one. # --------------------------------------------------------------------------- @@ -191,8 +207,10 @@ def test_record_audio_preflight_precedes_stream_construction_in_source() -> None source = inspect.getsource(recorder_mod.record_audio) preflight = source.index("require_local_transcription_backend()") + scrubber = source.index("raise NarrationNotScrubbed()") stream = source.index("InputStream(") assert preflight < stream + assert scrubber < stream # --------------------------------------------------------------------------- @@ -306,7 +324,9 @@ def test_waveform_is_discarded_by_default(monkeypatch) -> None: captured = _run_record_audio(monkeypatch, retain=False) assert captured["audio_data"] == b"", "waveform retained without opt-in" - assert captured["text"] == "patient name spoken aloud" + # The stored transcript is the scrubbed one; per-word text is withheld. + assert captured["text"] == " name spoken aloud" + assert captured["words"] == [] def test_waveform_is_retained_only_on_explicit_opt_in(monkeypatch) -> None: diff --git a/packages/capture/tests/test_narration.py b/packages/capture/tests/test_narration.py new file mode 100644 index 000000000..c25d20f87 --- /dev/null +++ b/packages/capture/tests/test_narration.py @@ -0,0 +1,237 @@ +"""Narration beside a recording: timed sentences, scrubbed before storing. + +The audio fixture ``fixtures/narration/medicare_copay.wav`` is synthetic +speech made on macOS with ``say`` ("Patient John Smith. If the insurance is +Medicare, skip the copay screen."). The name in it is invented. +""" + +from __future__ import annotations + +import importlib.util +import json +import multiprocessing +import sys +import threading +import time +import types +from pathlib import Path + +import pytest + +from openadapt_capture import audio as audio_mod +from openadapt_capture import share_scrub +from openadapt_capture.narration import ( + NARRATION_FILE, + NarrationNotScrubbed, + Utterance, + load_waveform, + read_narration, + scrub_utterances, + utterances_from_transcript, + write_narration, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "narration" / "medicare_copay.wav" + + +class NameScrubber: + """Stand-in for openadapt-privacy: removes the one invented name.""" + + def scrub_text(self, text: str, is_separated: bool = False) -> str: + return text.replace("John Smith", "") + + +@pytest.fixture() +def scrubber(): + share_scrub.set_scrubber(NameScrubber()) + yield share_scrub.get_scrubber() + share_scrub.reset_scrubber() + + +def test_every_segment_is_cut_into_timed_sentences(): + result = { + "segments": [ + { + "start": 0.0, + "end": 4.0, + "text": " Open the chart. If it's Medicare, skip copay.", + "words": [ + {"word": " Open", "start": 0.0, "end": 0.4}, + {"word": " the", "start": 0.4, "end": 0.6}, + {"word": " chart.", "start": 0.6, "end": 1.0}, + {"word": " If", "start": 1.5, "end": 1.7}, + {"word": " it's", "start": 1.7, "end": 2.0}, + {"word": " Medicare,", "start": 2.0, "end": 2.6}, + {"word": " skip", "start": 2.8, "end": 3.1}, + {"word": " copay.", "start": 3.1, "end": 3.6}, + ], + }, + {"start": 6.0, "end": 7.0, "text": " Now save."}, + ] + } + utts = utterances_from_transcript(result, offset=100.0) + assert utts == [ + Utterance(100.0, 101.0, "Open the chart."), + Utterance(101.5, 103.6, "If it's Medicare, skip copay."), + Utterance(106.0, 107.0, "Now save."), + ] + + +def test_scrubbing_is_required_and_removes_the_name(): + utts = [Utterance(0.0, 1.0, "Patient John Smith, insurance Medicare.")] + with pytest.raises(NarrationNotScrubbed): + scrub_utterances(utts, None) + (clean,) = scrub_utterances(utts, NameScrubber()) + assert "John Smith" not in clean.text + assert "Medicare" in clean.text + + +def test_sidecar_records_the_scrubber_and_refuses_unscrubbed_files(tmp_path): + utts = scrub_utterances([Utterance(1.0, 2.0, "John Smith is here.")], NameScrubber()) + path = write_narration(tmp_path, utts, scrubber=NameScrubber(), audio_start=1.0) + data = json.loads(path.read_text()) + assert data["scrubbed"] is True + assert data["scrubbed_by"]["class"].endswith("NameScrubber") + assert "John Smith" not in path.read_text() + assert read_narration(tmp_path) == utts + + data["scrubbed"] = False + path.write_text(json.dumps(data)) + with pytest.raises(ValueError, match="scrubbed"): + read_narration(tmp_path) + + +class _Stream: + samplerate = 16000 + + def __init__(self, callback=None, samplerate=16000, channels=1): + self._cb = callback + self._stop = threading.Event() + + def start(self): + import numpy as np + + def loop(): + while not self._stop.is_set(): + self._cb(np.zeros((160, 1), dtype=np.float32), 160, None, None) + time.sleep(0.005) + + threading.Thread(target=loop, daemon=True).start() + + def stop(self): + self._stop.set() + + def close(self): + pass + + +def _record(monkeypatch, tmp_path, transcript): + from openadapt_capture import recorder as recorder_mod + + fake_sd = types.ModuleType("sounddevice") + fake_sd.InputStream = _Stream + fake_sd.CallbackFlags = object + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + monkeypatch.setattr(audio_mod, "_get_best_transcription_backend", lambda: "faster-whisper") + monkeypatch.setattr(recorder_mod, "_transcribe_on_device", lambda a, b: transcript) + stored: dict = {} + monkeypatch.setattr( + recorder_mod.crud, + "insert_audio_info", + lambda s, a, text, r, ts, rate, words: stored.update(text=text, words=words, ts=ts), + ) + monkeypatch.setattr(recorder_mod, "get_session_for_path", lambda p: None) + terminate = multiprocessing.Event() + threading.Timer(0.1, terminate.set).start() + recorder_mod.record_audio( + recording=types.SimpleNamespace(timestamp=time.time()), + db_path=str(tmp_path / "recording.db"), + terminate_processing=terminate, + started_event=multiprocessing.Event(), + ) + return stored + + +def test_recorded_transcript_is_scrubbed_before_it_is_stored(monkeypatch, tmp_path, scrubber): + transcript = { + "text": "Patient John Smith. If the insurance is Medicare, skip the copay screen.", + "segments": [ + {"start": 0.2, "end": 1.6, "text": " Patient John Smith."}, + {"start": 2.0, "end": 4.4, "text": " If the insurance is Medicare, skip the copay screen."}, + ], + } + stored = _record(monkeypatch, tmp_path, transcript) + assert "John Smith" not in stored["text"] + assert stored["words"] == [] + sidecar = (tmp_path / NARRATION_FILE).read_text() + assert "John Smith" not in sidecar + utts = read_narration(tmp_path) + assert [u.text for u in utts] == [ + "Patient .", + "If the insurance is Medicare, skip the copay screen.", + ] + # Times are on the recording clock: audio start plus the segment offset. + assert utts[1].start == pytest.approx(stored["ts"] + 2.0, abs=1e-3) + + +def test_no_scrubber_means_the_microphone_never_opens(monkeypatch, tmp_path): + from openadapt_capture import recorder as recorder_mod + + share_scrub.set_scrubber(None) + opened: list[str] = [] + + class _Never: + def __init__(self, *a, **k): + opened.append("mic") + + fake_sd = types.ModuleType("sounddevice") + fake_sd.InputStream = _Never + fake_sd.CallbackFlags = object + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + monkeypatch.setattr(audio_mod, "_get_best_transcription_backend", lambda: "faster-whisper") + try: + with pytest.raises(NarrationNotScrubbed): + recorder_mod.record_audio( + recording=object(), + db_path=str(tmp_path / "recording.db"), + terminate_processing=multiprocessing.Event(), + started_event=multiprocessing.Event(), + ) + finally: + share_scrub.reset_scrubber() + assert opened == [] + assert not (tmp_path / NARRATION_FILE).exists() + + +def test_fixture_is_16khz_synthetic_speech(): + wave = load_waveform(FIXTURE) + assert wave.dtype.name == "float32" + assert 2.0 < len(wave) / 16000 < 8.0 + assert float(abs(wave).max()) > 0.05 + + +@pytest.mark.skipif( + importlib.util.find_spec("faster_whisper") is None, + reason="faster-whisper (audio extra) not installed", +) +def test_local_whisper_transcribes_the_fixture(scrubber): + """End to end on this machine: synthetic speech -> local Whisper -> scrub. + + Needs the ``tiny.en`` model in the local Hugging Face cache; skipped when + it is not there, so the test never downloads anything. + """ + try: + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache("Systran/faster-whisper-tiny.en", "model.bin") + except Exception: # noqa: BLE001 + cached = None + if not isinstance(cached, str): + pytest.skip("tiny.en Whisper model is not cached locally") + from openadapt_capture.narration import transcribe_file + + result = transcribe_file(FIXTURE, backend="faster-whisper", model=str(Path(cached).parent)) + utts = scrub_utterances(utterances_from_transcript(result), scrubber) + text = " ".join(u.text for u in utts).lower() + assert "medicare" in text and "copay" in text + assert "john smith" not in text From 6da5aabffba5c3d5c396416707276fc47189969b Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:42:50 -0400 Subject: [PATCH 2/8] feat(flow): narration proposes branch guards; recordings or a person certify them A sentence lined up near a branch point (an optional step or a two-path divergence) is parsed into a guard on a recorded field or a screen element. It certifies only when every recording agrees or a person accepts it; a contradicting recording keeps the point refused. Certified guards carry their sentence in Workflow.guard_sources. Narration sidecar, alignment, attach, and capture-adapter carry-over. Co-Authored-By: Claude Opus 5.5 --- .../flow/openadapt_flow/adapters/capture.py | 35 ++ .../flow/openadapt_flow/compiler/induction.py | 576 +++++++++++++++++- .../compiler/narration_rules.py | 532 ++++++++++++++++ packages/flow/openadapt_flow/ir.py | 28 + packages/flow/openadapt_flow/narration.py | 263 ++++++++ packages/flow/tests/test_narration_rules.py | 429 +++++++++++++ 6 files changed, 1861 insertions(+), 2 deletions(-) create mode 100644 packages/flow/openadapt_flow/compiler/narration_rules.py create mode 100644 packages/flow/openadapt_flow/narration.py create mode 100644 packages/flow/tests/test_narration_rules.py diff --git a/packages/flow/openadapt_flow/adapters/capture.py b/packages/flow/openadapt_flow/adapters/capture.py index 42b370226..c9f4906b0 100644 --- a/packages/flow/openadapt_flow/adapters/capture.py +++ b/packages/flow/openadapt_flow/adapters/capture.py @@ -1198,6 +1198,7 @@ def convert_capture( frames_dir.mkdir(parents=True, exist_ok=True) used_params: dict[str, str] = {} + step_times: list[dict[str, Any]] = [] viewport: Optional[list[int]] = None lines: list[str] = [] @@ -1244,6 +1245,7 @@ def convert_capture( line.update({k: v for k, v in event.items() if k != "_ts"}) line["t"] = round(ts - started_at, 3) lines.append(json.dumps(line)) + step_times.append({"i": i, "t": line["t"], "kind": event["kind"]}) (out_dir / "events.jsonl").write_text("\n".join(lines) + "\n") @@ -1293,6 +1295,39 @@ def convert_capture( elif desktop_provenance is not None: meta["desktop_capture"] = desktop_provenance (out_dir / "meta.json").write_text(json.dumps(meta, indent=2)) + _write_capture_narration(session, out_dir, started_at, step_times) return out_dir finally: session.close() + + +def _write_capture_narration( + session: Any, out_dir: Path, started_at: float, step_times: list[dict[str, Any]] +) -> None: + """Copy capture's scrubbed narration into the recording, lined up by time. + + Capture scrubbed the sentences before storing them (it refuses to store + narration otherwise) and its file says so; this only re-times them to the + recording's ``t`` clock and lines each one up with a step. A capture with + no narration writes nothing. + """ + reader = getattr(session, "narration", None) + if reader is None: + return + utterances = reader() + if not utterances: + return + from openadapt_flow.narration import Narration, align, write_narration + + sentences = [ + (u.start - started_at, u.end - started_at, u.text) for u in utterances + ] + write_narration( + out_dir, + Narration( + scrubbed=True, + scrubbed_by={"by": "openadapt-capture"}, + source="openadapt-capture", + lines=align(sentences, step_times), + ), + ) diff --git a/packages/flow/openadapt_flow/compiler/induction.py b/packages/flow/openadapt_flow/compiler/induction.py index dc6f30065..763b35ec7 100644 --- a/packages/flow/openadapt_flow/compiler/induction.py +++ b/packages/flow/openadapt_flow/compiler/induction.py @@ -94,7 +94,7 @@ import warnings from difflib import SequenceMatcher from pathlib import Path -from typing import Any, Optional, Protocol, Union, runtime_checkable +from typing import Any, Callable, Optional, Protocol, Sequence, Union, runtime_checkable from pydantic import BaseModel, Field @@ -107,6 +107,7 @@ from openadapt_flow.ir import ( ActionKind, Guard, + GuardSource, LoopSpec, ParamKind, ParamSpec, @@ -299,6 +300,39 @@ class ProbeVerdict(BaseModel): detail: str = "" +class NarratedGuard(BaseModel): + """A guard a spoken sentence suggested for one branch point. + + It starts as a flagged proposal. ``status`` is ``certified`` only when + every recording agrees with it (``certified_by="recordings_agree"``), + probing on a declared test copy confirmed it (``"probe"``), or a person + accepted it (``"person"``). A recording that contradicts it keeps the point + refused (``status="contradicted"``); a person cannot accept it then. + """ + + location: str = Field(description="opt_ or divergence_") + sentence: str = Field(description="The scrubbed sentence") + recording: int = Field(description="Index of the recording that said it") + step_id: Optional[str] = None + guard: Predicate = Field(description="Run the step (or arm) when this holds") + rule: str = Field(description="The guard in plain words") + source: str = "narration" + status: str = Field(description="'certified' | 'proposed' | 'contradicted'") + certified_by: Optional[str] = None + accepted_by: Optional[str] = None + agree: list[int] = Field(default_factory=list) + contradict: list[int] = Field(default_factory=list) + unknown: list[int] = Field( + default_factory=list, + description="Recordings where the guard could not be checked", + ) + trace_params: dict[int, dict[str, str]] = Field( + default_factory=dict, + description="Per recording, the run parameter a param guard compares", + ) + detail: str = "" + + class InductionResult(BaseModel): """The induced program (or an honest refusal) plus the full audit trail.""" @@ -327,6 +361,10 @@ class InductionResult(BaseModel): "uncovered-branch notes on an emitted program (non-blocking)." ), ) + narrated_guards: list[NarratedGuard] = Field( + default_factory=list, + description="Guards spoken narration suggested, with how each was decided.", + ) probe_target: Optional[str] = Field( default=None, description="Name of the declared test copy probed (None: no probing).", @@ -362,6 +400,23 @@ def render(self) -> str: lines.append(f" [proposed] {p.target}: {p.content} (NOT trusted)") for u in self.uncertainties: lines.append(f" [REFUSED] {u.location}: {u.detail}") + for ng in self.narrated_guards: + if ng.status == "certified": + how = { + "recordings_agree": "every recording agrees", + "probe": "confirmed on the test copy", + "person": f"accepted by {ng.accepted_by}", + }.get(ng.certified_by or "", ng.certified_by or "") + verdict = f"CERTIFIED ({how})" + elif ng.status == "contradicted": + verdict = "REFUSED (recording " + ", ".join( + str(t) for t in ng.contradict + ) + " contradicts it)" + else: + verdict = "proposed, NOT trusted" + lines.append( + f" [narrated] {ng.location}: {ng.sentence!r} -> {ng.rule} -- {verdict}" + ) if self.probe_target is not None: lines.append( f"probes on test copy {self.probe_target!r}: " @@ -975,6 +1030,10 @@ def induce_program( *, propose: Optional[Proposer] = None, probe_resolutions: Optional[dict[str, str]] = None, + narration: Optional[Sequence[Optional[Any]]] = None, + screen_texts: Optional[Callable[[int, str], Optional[Sequence[str]]]] = None, + interpreter: Optional[Any] = None, + accepted_narration: Optional[dict[str, str]] = None, ) -> InductionResult: """Induce a parameterized :class:`ProgramGraph` from multiple demonstrations of the same task -- the RFC §3 induction loop (step [4] + [5]). @@ -995,6 +1054,21 @@ def induce_program( is the only caller that sets it). ``"incidental"`` drops the step; ``"conditional"`` keeps it guarded on its own presence. A point not listed here is refused exactly as without probing. + ``"narrated"`` certifies the guard narration suggested there. + narration: One :class:`~openadapt_flow.narration.Narration` (or None) + per trace. Default: read ``narration.json`` from each trace that is + a recording or bundle directory. A sentence lined up near a branch + point PROPOSES a guard (:mod:`.narration_rules`); it is certified + only when every recording agrees, probing confirms it, or a person + accepts it (``accepted_narration``). + screen_texts: ``(trace, step_id) -> screen lines`` used to check a + screen-element guard. Default for recording directories: OCR of + the step's before-frame. + interpreter: Optional compile-time + :class:`~.narration_rules.NarrationInterpreter` for sentences the + parser cannot read. Its answer is checked like any narrated guard. + accepted_narration: ``{location: person}`` -- narrated guards a + person accepted. A guard a recording contradicts stays refused. Returns: An :class:`InductionResult` with the induced program (or a refusal) and @@ -1005,6 +1079,10 @@ def induce_program( result = InductionResult(n_traces=n) if n == 0: return result + if narration is None: + narration = _narration_from_traces(traces) + if screen_texts is None: + screen_texts = _default_screen_texts(traces) reduced = [_reduce_trace(wf.steps) for wf in workflows] columns = _align(reduced) @@ -1031,6 +1109,8 @@ def induce_program( data_sources: dict[str, Relation] = {} taken: set[str] = set() seen_div_groups: set[int] = set() + optional_nodes: dict[int, _Node] = {} + divergences: dict[int, tuple[list[_Column], int]] = {} for idx, col in enumerate(columns): present = sorted(col.tokens) @@ -1043,7 +1123,9 @@ def induce_program( seen_div_groups.add(grp) group_cols = [c for c in columns if c.divergent_group == grp] _handle_divergence(idx, group_cols, workflows, result, propose, decisions) - # Underdetermined divergence => refuse to emit (handled below). + # Underdetermined divergence => refuse to emit (handled below), + # unless narration settles it after the loop. + divergences[idx] = (group_cols, len(nodes)) continue # --- loop ---------------------------------------------------------- @@ -1072,8 +1154,27 @@ def induce_program( ) if node is not None: nodes.append(node) + optional_nodes[idx] = node decisions.append(dec) + guard_sources: dict[str, GuardSource] = {} + if narration and any(narration): + _apply_narration( + result=result, + columns=columns, + decisions=decisions, + nodes=nodes, + optional_nodes=optional_nodes, + divergences=divergences, + narration=list(narration), + n=n, + screen_texts=screen_texts, + interpreter=interpreter, + probe_resolutions=probe_resolutions or {}, + accepted=accepted_narration or {}, + guard_sources=guard_sources, + ) + result.column_decisions = decisions result.param_specs = param_specs @@ -1097,6 +1198,7 @@ def induce_program( for key, spec in param_specs.items() }, data_sources=data_sources, + guard_sources=guard_sources, ) result.program = program result.workflow = workflow @@ -1762,6 +1864,476 @@ def _maybe_propose( return None +# =========================================================================== +# Narration: a spoken sentence PROPOSES a guard; the recordings, a probe on a +# test copy, or a person certify it (see compiler.narration_rules). +# =========================================================================== + + +def _narration_from_traces(traces: list[TraceInput]) -> list[Optional[Any]]: + """``narration.json`` of each trace that is a directory (None otherwise). + + A file that does not say it was scrubbed is refused, which here means it + is not used. + """ + from openadapt_flow.narration import NarrationRefused, load_narration + + out: list[Optional[Any]] = [] + for trace in traces: + if isinstance(trace, (str, Path)) and Path(trace).is_dir(): + try: + out.append(load_narration(Path(trace))) + except (NarrationRefused, ValueError): + out.append(None) + else: + out.append(None) + return out + + +def _default_screen_texts(traces: list[TraceInput]): + from openadapt_flow.compiler.narration_rules import recording_screen_texts + + dirs = { + i: Path(t) + for i, t in enumerate(traces) + if isinstance(t, (str, Path)) and (Path(t) / "frames").is_dir() + } + return recording_screen_texts(dirs) if dirs else None + + +def _tok_steps(tok: Any) -> list[Step]: + if tok.kind == "single": + return [tok.step] + return [s for iteration in tok.iterations for s in iteration] + + +def _step_words(step: Step) -> set[str]: + from openadapt_flow.compiler.narration_rules import tokens + + parts = [step.intent or "", step.field_label or "", step.param or ""] + if step.anchor is not None: + parts.append(step.anchor.ocr_text or "") + return tokens(" ".join(parts)) + + +def _window_ids(columns: list[_Column], lo: int, hi: int, t: int) -> set[str]: + """Step ids of trace ``t`` around columns ``lo..hi``: its steps there plus + its nearest step before and after. A sentence lined up with one of these + was spoken at that point.""" + ids: set[str] = set() + for c in columns[lo : hi + 1]: + if t in c.tokens: + ids.update(s.id for s in _tok_steps(c.tokens[t])) + for c in reversed(columns[:lo]): + if t in c.tokens: + ids.add(_tok_steps(c.tokens[t])[-1].id) + break + for c in columns[hi + 1 :]: + if t in c.tokens: + ids.add(_tok_steps(c.tokens[t])[0].id) + break + return ids + + +def _next_step_id(columns: list[_Column], hi: int, t: int) -> Optional[str]: + for c in columns[hi + 1 :]: + if t in c.tokens: + return _tok_steps(c.tokens[t])[0].id + return None + + +def _param_fields(columns, decisions, n): + from openadapt_flow.compiler.narration_rules import FieldValues + + fields = [] + for dec in decisions: + if dec.kind != "param" or dec.param_name is None: + continue + col = columns[dec.index] + fields.append( + FieldValues( + param=dec.param_name, + label=dec.field, + values=[ + _value(col.tokens[t].step) + if t in col.tokens and col.tokens[t].kind == "single" + else None + for t in range(n) + ], + ) + ) + return fields + + +def _narrated_candidate(columns, lo, hi, narration, fields, descriptors, interpreter): + """The first sentence that reads as a rule about this point, or None. + + Sentences lined up at the point come first, then sentences anywhere whose + action names the step. With several arms (a divergence) the sentence must + be about exactly one of them. + """ + from openadapt_flow.compiler import narration_rules as nr + + ranked = [] + for t, nar in enumerate(narration): + if nar is None: + continue + window = _window_ids(columns, lo, hi, t) + for line in nar.lines: + ranked.append((0 if line.step_id in window else 1, t, line.t_start, line)) + ranked.sort(key=lambda r: r[:3]) + for rank, t, _, line in ranked: + rule = nr.parse_rule(line.text) + if rule is None: + continue + about = [ + k + for k, words in enumerate(descriptors) + if nr.is_about(rule, words, in_window=rank == 0) + ] + if len(about) != 1: + continue + cand = nr.candidate_from_rule(rule, trace=t, step_id=line.step_id, fields=fields) + cand.target_arm = about[0] + return cand + if interpreter is None or len(descriptors) != 1: + return None + for rank, t, _, line in ranked: + if rank != 0 or nr.parse_rule(line.text) is not None: + continue + try: + data = interpreter.interpret( + line.text, + { + "fields": [f.param for f in fields], + "step": " ".join(sorted(descriptors[0])), + }, + ) + except Exception: # noqa: BLE001 - advisory path never breaks induction + data = None + if not data: + continue + cand = nr.candidate_from_model( + data, + sentence=line.text, + trace=t, + step_id=line.step_id, + fields=fields, + source=getattr(interpreter, "source", "narration+model"), + ) + if cand is not None: + cand.target_arm = 0 + return cand + return None + + +_HOW = { + "recordings_agree": "every recording agrees", + "probe": "confirmed on the test copy", + "person": "accepted by a person", +} + + +def _decide_narrated( + cand, *, location, rule, expected, screen_step, fields, screen_texts, + probe_resolutions, accepted, +) -> NarratedGuard: + from openadapt_flow.compiler.narration_rules import check_against_recordings + + agreement = check_against_recordings( + cand.guard, + expected, + fields=fields, + screen_step=screen_step, + screen_texts=screen_texts, + ) + certified_by = accepted_by = None + if agreement.contradict: + status = "contradicted" + detail = ( + "recording(s) " + + ", ".join(str(t) for t in agreement.contradict) + + " did the opposite of what the sentence says" + ) + elif agreement.all_agree: + status, certified_by = "certified", "recordings_agree" + detail = f"all {len(agreement.agree)} recordings agree" + elif probe_resolutions.get(location) == "narrated": + status, certified_by = "certified", "probe" + detail = "replaying it on the declared test copy produced every recorded effect" + elif accepted.get(location): + status, certified_by, accepted_by = "certified", "person", accepted[location] + detail = f"accepted by {accepted_by}" + else: + status = "proposed" + detail = ( + "could not be checked in recording(s) " + + ", ".join(str(t) for t in agreement.unknown) + if agreement.unknown + else "not checked" + ) + trace_params: dict[int, dict[str, str]] = {} + cond = cand.condition + if cond.kind is PredicateKind.PARAM_EQUALS: + f = next(x for x in fields if x.param == cond.param) + trace_params = { + t: {f.param: v} for t, v in enumerate(f.values) if v is not None + } + return NarratedGuard( + location=location, + sentence=cand.sentence, + recording=cand.trace, + step_id=cand.step_id, + guard=cand.guard, + rule=rule, + source=cand.source, + status=status, + certified_by=certified_by, + accepted_by=accepted_by, + agree=agreement.agree, + contradict=agreement.contradict, + unknown=agreement.unknown, + trace_params=trace_params, + detail=detail, + ) + + +def _guard_source(ng: NarratedGuard) -> GuardSource: + return GuardSource( + sentence=ng.sentence, + rule=ng.rule, + certified_by=ng.certified_by, # type: ignore[arg-type] + accepted_by=ng.accepted_by, + recording=ng.recording, + source=ng.source, # type: ignore[arg-type] + ) + + +def _flag_on_uncertainty(result, uncertainty_location, ng, proposal) -> None: + for u in result.uncertainties: + if u.location != uncertainty_location: + continue + u.proposal = proposal + if ng.status == "contradicted": + u.detail += ( + f" Narration suggested {ng.rule!r}, but {ng.detail}, so it is " + "not used." + ) + continue + u.detail += f" Narration suggests: {ng.rule} ({ng.detail})." + u.record_next = ( + (u.record_next + " ") if u.record_next else "" + ) + ( + f"Or accept the narrated rule: induce --accept-narrated {ng.location}." + ) + if u.question is not None and not any( + o.key == "narrated" for o in u.question.options + ): + u.question.options.append( + QuestionOption( + key="narrated", + label=f"Use the narrated rule: {ng.rule}", + effect=OptionEffect.NONE, + ) + ) + + +def _divergence_arms(group_cols, n): + """The two paths a divergence takes: (steps per arm, traces per arm). + + None unless there are exactly two, each the same steps with the same + values in every recording that took it. + """ + arms: dict[tuple, list[tuple[int, list[Step]]]] = {} + for t in range(n): + steps: list[Step] = [] + for c in group_cols: + tok = c.tokens.get(t) + if tok is None: + continue + if tok.kind != "single": + return None + steps.append(tok.step) + arms.setdefault(tuple(_sig(s) for s in steps), []).append((t, steps)) + if len(arms) != 2: + return None + arm_steps, members = [], [] + for entries in arms.values(): + first = entries[0][1] + for _, steps in entries[1:]: + if [_value(s) for s in steps] != [_value(s) for s in first]: + return None + arm_steps.append(first) + members.append([t for t, _ in entries]) + return arm_steps, members + + +def _branch_node(idx: int, guard: Predicate, arm_steps, target: int) -> _Node: + bid = f"branch_div_{idx}" + node = _Node(bid) + entries: list[Optional[str]] = [] + for k, steps in enumerate(arm_steps): + ids = [f"div{idx}_{k}_{j}" for j in range(len(steps))] + for j, step in enumerate(steps): + tr = Transition(target=ids[j + 1] if j + 1 < len(ids) else "__PATCH__") + node.states[ids[j]] = State( + id=ids[j], + kind=StateKind.ACTION, + step=step.model_copy(deep=True), + transitions=[tr], + ) + if j + 1 == len(ids): + node.exits.append(tr) + entries.append(ids[0] if ids else None) + other = 1 - target + t_rule = Transition( + guard=guard, target=entries[target] or "__PATCH__", label="narrated rule holds" + ) + t_else = Transition(target=entries[other] or "__PATCH__", label="otherwise") + for tr, entry in ((t_rule, entries[target]), (t_else, entries[other])): + if entry is None: + node.exits.append(tr) + node.states[bid] = State(id=bid, kind=StateKind.BRANCH, transitions=[t_rule, t_else]) + return node + + +def _apply_narration( + *, result, columns, decisions, nodes, optional_nodes, divergences, narration, + n, screen_texts, interpreter, probe_resolutions, accepted, guard_sources, +) -> None: + fields = _param_fields(columns, decisions, n) + + # -- optional steps (present in some recordings, absent in others) ------ + for idx, node in optional_nodes.items(): + col = columns[idx] + present = sorted(col.tokens) + step0 = col.tokens[present[0]].step + sid = f"opt_{idx}" + if _looks_like_dialog(step0) or probe_resolutions.get(sid) in ( + "incidental", + "conditional", + ): + continue + state = node.states.get(sid) + if state is None or state.step is None: + continue + cand = _narrated_candidate( + columns, idx, idx, narration, fields, [_step_words(step0)], interpreter + ) + if cand is None: + continue + field = _field_key(step0) + ng = _decide_narrated( + cand, + location=sid, + rule=cand.describe(f"run {step0.intent!r}"), + expected={t: t in col.tokens for t in range(n)}, + screen_step={ + t: col.tokens[t].step.id if t in col.tokens else _next_step_id(columns, idx, t) + for t in range(n) + }, + fields=fields, + screen_texts=screen_texts, + probe_resolutions=probe_resolutions, + accepted=accepted, + ) + result.narrated_guards.append(ng) + if ng.status == "certified": + state.step.guard = Guard(predicate=cand.guard, on_unmet="skip") + result.uncertainties = [u for u in result.uncertainties if u.location != sid] + result.inferred = [ + line + for line in result.inferred + if not line.startswith(f"optional step '{field}' at column {idx} ") + ] + result.inferred.append( + f"guard at {sid}: {ng.rule} -- suggested by narration, " + f"certified: {_HOW[ng.certified_by]}" + ) + guard_sources[sid] = _guard_source(ng) + for dec in decisions: + if dec.index == idx: + dec.kind = "branch" + dec.note = f"narrated guard on '{field}' ({ng.certified_by})" + continue + proposal = Proposal( + target=sid, + kind="guard", + content=f"{ng.rule} (narration: {ng.sentence!r})", + source=ng.source, + ) + result.proposed.append(proposal) + _flag_on_uncertainty(result, sid, ng, proposal) + + # -- divergences (recordings took different paths at the same point) ---- + for idx, (group_cols, pos) in sorted( + divergences.items(), key=lambda kv: -kv[1][1] + ): + where = f"column {idx}" + location = f"divergence_{idx}" + if not any( + u.location == where and u.kind == "ambiguous_branch" + for u in result.uncertainties + ): + continue + arms = _divergence_arms(group_cols, n) + if arms is None: + continue + arm_steps, members = arms + indices = [i for i, c in enumerate(columns) if any(c is g for g in group_cols)] + lo, hi = min(indices), max(indices) + descriptors = [ + set().union(*(_step_words(s) for s in steps)) if steps else set() + for steps in arm_steps + ] + cand = _narrated_candidate( + columns, lo, hi, narration, fields, descriptors, interpreter + ) + if cand is None: + continue + target = cand.target_arm or 0 + label = " then ".join(s.intent for s in arm_steps[target]) or "empty" + screen_step = {} + for t in members[0] + members[1]: + first = next( + (c.tokens[t].step.id for c in group_cols if t in c.tokens), None + ) + screen_step[t] = first or _next_step_id(columns, hi, t) + ng = _decide_narrated( + cand, + location=location, + rule=cand.describe(f"take the path {label!r}"), + expected={t: t in members[target] for t in members[0] + members[1]}, + screen_step=screen_step, + fields=fields, + screen_texts=screen_texts, + probe_resolutions=probe_resolutions, + accepted=accepted, + ) + result.narrated_guards.append(ng) + if ng.status == "certified": + nodes.insert(pos, _branch_node(idx, cand.guard, arm_steps, target)) + result.uncertainties = [u for u in result.uncertainties if u.location != where] + result.inferred.append( + f"branch at {location}: {ng.rule} -- suggested by narration, " + f"certified: {_HOW[ng.certified_by]}" + ) + guard_sources[f"branch_div_{idx}"] = _guard_source(ng) + for dec in decisions: + if dec.index == idx: + dec.kind = "branch" + dec.note = f"narrated branch ({ng.certified_by})" + continue + proposal = Proposal( + target=location, + kind="guard", + content=f"{ng.rule} (narration: {ng.sentence!r})", + source=ng.source, + ) + result.proposed.append(proposal) + _flag_on_uncertainty(result, where, ng, proposal) + + def _wire(nodes: list[_Node]): """Chain nodes into a single program graph + collect loop-body subflows.""" if not nodes: diff --git a/packages/flow/openadapt_flow/compiler/narration_rules.py b/packages/flow/openadapt_flow/compiler/narration_rules.py new file mode 100644 index 000000000..de9edc337 --- /dev/null +++ b/packages/flow/openadapt_flow/compiler/narration_rules.py @@ -0,0 +1,532 @@ +"""Turn a spoken sentence into a proposed branch guard, and check it. + +When ``induce`` (or ``teach-branch``) finds a branch, the recordings show +*that* the person did something different, not *why*. Narration often says +why: "if the insurance is Medicare, skip the copay screen". This module + +1. parses such a sentence into a condition and an action + (:func:`parse_rule`, deterministic; an optional compile-time + :class:`NarrationInterpreter` may help on the customer's own key), +2. maps the condition onto something the runtime can check without a model: + a recorded field (``param_equals`` on a run parameter) or a screen element + (``text_present``), and +3. checks the proposed guard against every recording + (:func:`check_against_recordings`). + +A narrated guard is a **proposal**. It becomes a certified guard only when +every recording agrees with it, when ``induce --probe-on`` confirms it on a +test copy, or when a person accepts it. A recording that contradicts it keeps +the point refused. At run time the guard is an ordinary predicate: a string +compare or an OCR presence check, never a model call. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Optional, Protocol, Sequence, runtime_checkable + +from openadapt_flow.ir import Predicate, PredicateKind + +_WORD = re.compile(r"[a-z0-9]+") +_STOP = frozenset( + """a an the is are was be to of on in at for and or but so then i we you it + its it's this that they them their my our your just can will should would + do does did dont don't not no need have has had go going now here there + screen page step field box button tab form when if since because unless + as with by from into out up down please also skip skipping skipped ignore + leave omit bypass never always only don t s""".split() +) +_SKIP_VERBS = ( + "skip", + "don't", + "dont", + "do not", + "no need to", + "ignore", + "leave out", + "bypass", + "omit", + "never", + "won't", + "will not", +) +_MARKERS = re.compile( + r"\b(if|when|whenever|unless|since|because|as long as|only if|only when)\b", + re.IGNORECASE, +) +_COPULA = re.compile( + r"^(?P.*?)\s*\b(?Pis not|isn't|isnt|is|are not|aren't|are|was|" + r"equals|says|shows|reads|=)\s+(?P.+)$", + re.IGNORECASE, +) +_PRONOUN_SUBJECTS = frozenset({"", "it", "this", "that", "they", "he", "she", "its", "it's"}) + + +@dataclass(frozen=True) +class ParsedRule: + """A sentence split into a condition and an action.""" + + sentence: str + value: str + subject: Optional[str] + negated: bool + action: str + skip: bool + + @property + def runs_when_condition_holds(self) -> bool: + """True: the action happens when the condition holds. A "skip" (or an + "unless", or "is not") flips it.""" + return self.skip == self.negated + + +def tokens(text: str) -> set[str]: + """Content words, lower-case, with a trailing plural ``s`` dropped.""" + out = set() + for w in _WORD.findall((text or "").lower().replace("_", " ")): + if w in _STOP or len(w) < 2: + continue + out.add(w[:-1] if len(w) > 3 and w.endswith("s") else w) + return out + + +def _norm_value(text: str) -> str: + text = re.sub(r"[\"'“”‘’]", "", text or "").strip().lower() + text = re.sub(r"^(a|an|the)\s+", "", text) + return re.sub(r"\s+", " ", text).strip(" .,;:!?") + + +def _clean_value(text: str) -> str: + text = re.sub(r"[\"“”]", "", text or "").strip() + text = re.sub(r"^(a|an|the)\s+", "", text, flags=re.IGNORECASE) + # "Medicare, so" / "Medicare then" -> "Medicare" + text = re.split(r"\s*(?:,|;|\bthen\b|\bso\b)\s*", text, maxsplit=1)[0] + return text.strip(" .,;:!?") + + +def _split_action(action: str) -> tuple[str, bool]: + a = re.sub(r"^(?:then|so|and)\s+", "", action.strip(" ,.;:!?"), flags=re.I) + a = re.sub( + r"^(?:i|we|you)\s+(?:can\s+|will\s+|should\s+|just\s+|always\s+|need to\s+)*", + "", + a, + flags=re.I, + ).strip() + low = a.lower() + blank = re.match(r"^leave\s+(?P.+?)\s+(?:blank|empty|alone)$", a, flags=re.I) + if blank is not None: + return blank.group("obj"), True + for verb in _SKIP_VERBS: + if low.startswith(verb + " ") or low == verb: + rest = a[len(verb) :].strip() + rest = re.sub(r"^(?:need to|have to|bother|fill in|do)\s+", "", rest, flags=re.I) + return rest, True + return a, False + + +def parse_rule(sentence: str) -> Optional[ParsedRule]: + """Split a narrated sentence into a condition and an action, or None. + + Handles the shapes people use while demonstrating: + + * "If the insurance is Medicare, skip the copay screen." + * "Skip the copay screen when the insurance is Medicare." + * "Since it's Medicare, I don't fill in the copay." + * "The insurance is Medicare, so I skip copay." + * "Unless the payer is Aetna, leave the referral blank." + """ + text = re.sub(r"\s+", " ", (sentence or "").strip()) + if not text: + return None + marker = _MARKERS.search(text) + negated = False + if marker is not None: + word = marker.group(1).lower() + negated = word == "unless" + after = text[marker.end() :] + before = text[: marker.start()].strip(" ,.;:") + boundary = re.search(r",|;|\bthen\b|\bso\b", after) + if boundary is not None: + condition = after[: boundary.start()] + action = after[boundary.end() :] + else: + condition, action = after, "" + if not action.strip(" .,;:!?"): + action = before + elif before and not tokens(action): + action = before + else: + so = re.search(r",?\s*\bso\b\s+", text) + if so is None: + return None + condition, action = text[: so.start()], text[so.end() :] + condition = condition.strip(" ,.;:!?") + match = _COPULA.match(condition) + if match is not None: + subject = match.group("subj").strip() + subject = re.sub(r"^(?:the|a|an|its|their)\s+", "", subject, flags=re.I) + value = _clean_value(match.group("val")) + cop = match.group("cop").lower() + if cop in ("is not", "isn't", "isnt", "are not", "aren't"): + negated = not negated + else: + lowered = condition.lower() + subject = "" + for pron in ("it's ", "it is ", "this is ", "they're ", "they are "): + if lowered.startswith(pron): + condition = condition[len(pron) :] + break + value = _clean_value(condition) + if subject.lower() in _PRONOUN_SUBJECTS: + subject = "" + action, skip = _split_action(action) + if not value or not action and not skip: + return None + return ParsedRule( + sentence=text, + value=value, + subject=subject or None, + negated=negated, + action=action, + skip=skip, + ) + + +# -- the compile-time model, behind an interface ------------------------------ + + +@runtime_checkable +class NarrationInterpreter(Protocol): + """Turns a sentence the parser could not read into a condition. + + Called at COMPILE time only, at most once per branch point. Returns + ``{"field": | None, "text": | + None, "value": str, "step_runs_when_true": bool}`` or None. Whatever it + returns is checked against the recordings like any narrated guard, and it + may only name a field it was offered. + """ + + source: str + + def interpret(self, sentence: str, context: dict[str, Any]) -> Optional[dict[str, Any]]: + ... + + +class AnthropicNarrationInterpreter: + """Compile-time interpreter on the customer's own Anthropic key. + + Imports the SDK lazily; constructing it needs no key. The sentence (already + scrubbed) is scrubbed again on the way out, or the call is refused when + openadapt-privacy is missing (``openadapt_flow.privacy.prepare_outbound``). + Never used at run time. + """ + + source = "narration+model" + + def __init__(self, *, client: Any = None, model: Optional[str] = None) -> None: + self._client = client + self._model = model + + def interpret(self, sentence: str, context: dict[str, Any]) -> Optional[dict[str, Any]]: + import json + + from openadapt_flow import privacy + + payload = privacy.prepare_outbound( + {"sentence": sentence, **context}, "narration rule interpretation" + ) + client = self._client + if client is None: + import anthropic + + from openadapt_flow.benchmark.agent_baseline import load_api_key + + client = anthropic.Anthropic(api_key=load_api_key()) + model = self._model + if model is None: + from openadapt_flow.benchmark.agent_baseline import MODEL + + model = MODEL + prompt = ( + "A person demonstrating a workflow said the sentence below. Say which " + "condition decides whether the step runs. Use only a field from " + "'fields', or text that would be on screen. Reply with JSON only: " + '{"field": str|null, "text": str|null, "value": str, ' + '"step_runs_when_true": bool}. Reply null if the sentence names no ' + "condition.\n" + json.dumps(payload) + ) + response = client.messages.create( + model=model, max_tokens=200, messages=[{"role": "user", "content": prompt}] + ) + raw = "".join( + getattr(b, "text", "") for b in response.content if getattr(b, "type", "") == "text" + ).strip() + try: + data = json.loads(raw) + except ValueError: + return None + return data if isinstance(data, dict) else None + + +# -- mapping a rule onto a checkable guard ------------------------------------ + + +@dataclass +class FieldValues: + """A recorded field that became a run parameter, with each recording's value.""" + + param: str + label: str + values: list[Optional[str]] + + +@dataclass +class Candidate: + """A guard proposed from one sentence, before it is checked.""" + + sentence: str + trace: int + step_id: Optional[str] + condition: Predicate + runs_when_condition_holds: bool + source: str = "narration" + target_arm: Optional[int] = None # divergence: the arm the sentence is about + + @property + def guard(self) -> Predicate: + """The predicate under which the step (or target arm) runs.""" + if self.runs_when_condition_holds: + return self.condition + return Predicate( + kind=PredicateKind.NOT, + operands=[self.condition], + intent=f"not ({self.condition.intent})", + ) + + def describe(self, what: str) -> str: + cond = self.condition + if cond.kind is PredicateKind.PARAM_EQUALS: + fact = f"{cond.param} is {cond.value!r}" + nfact = f"{cond.param} is not {cond.value!r}" + else: + fact = f"the screen shows {cond.text!r}" + nfact = f"the screen does not show {cond.text!r}" + return f"{what} only when {fact if self.runs_when_condition_holds else nfact}" + + +def _match_field( + subject: Optional[str], value: str, fields: Sequence[FieldValues] +) -> Optional[tuple[FieldValues, str]]: + want = _norm_value(value) + with_value = [ + (f, v) + for f in fields + for v in dict.fromkeys(x for x in f.values if x is not None) + if _norm_value(v) == want + ] + if subject: + subj = tokens(subject) + named = [ + (f, v) for f, v in with_value if subj & (tokens(f.param) | tokens(f.label)) + ] + if len({f.param for f, _ in named}) == 1: + return named[0] + if named: + return None + if len({f.param for f, _ in with_value}) == 1: + return with_value[0] + return None + + +def candidate_from_rule( + rule: ParsedRule, + *, + trace: int, + step_id: Optional[str], + fields: Sequence[FieldValues], +) -> Candidate: + """Map a parsed rule onto a recorded field when one matches, else the screen.""" + matched = _match_field(rule.subject, rule.value, fields) + if matched is not None: + f, recorded = matched + condition = Predicate( + kind=PredicateKind.PARAM_EQUALS, + param=f.param, + value=recorded, + intent=f"{f.param} is {recorded}", + ) + else: + condition = Predicate( + kind=PredicateKind.TEXT_PRESENT, + text=rule.value, + intent=f"{rule.value} on screen", + ) + return Candidate( + sentence=rule.sentence, + trace=trace, + step_id=step_id, + condition=condition, + runs_when_condition_holds=rule.runs_when_condition_holds, + ) + + +def candidate_from_model( + data: dict[str, Any], + *, + sentence: str, + trace: int, + step_id: Optional[str], + fields: Sequence[FieldValues], + source: str, +) -> Optional[Candidate]: + """Validate an interpreter's answer. It may only name an offered field and + a value some recording actually had; anything else is dropped.""" + value = str(data.get("value") or "").strip() + if not value: + return None + runs = bool(data.get("step_runs_when_true", True)) + name = data.get("field") + if name: + f = next((x for x in fields if x.param == name), None) + if f is None: + return None + recorded = next( + (v for v in f.values if v is not None and _norm_value(v) == _norm_value(value)), + None, + ) + if recorded is None: + return None + condition = Predicate( + kind=PredicateKind.PARAM_EQUALS, + param=f.param, + value=recorded, + intent=f"{f.param} is {recorded}", + ) + else: + text = str(data.get("text") or value).strip() + condition = Predicate( + kind=PredicateKind.TEXT_PRESENT, text=text, intent=f"{text} on screen" + ) + return Candidate( + sentence=sentence, + trace=trace, + step_id=step_id, + condition=condition, + runs_when_condition_holds=runs, + source=source, + ) + + +def is_about(rule: ParsedRule, descriptor: set[str], *, in_window: bool) -> bool: + """Does the rule's action talk about this step (or arm)? + + An action with content words must share one with the step. An action with + none ("skip it") counts only when the sentence was spoken right there. + """ + words = tokens(rule.action) + if not words: + return in_window + return bool(words & descriptor) + + +# -- checking a guard against the recordings ---------------------------------- + +ScreenTexts = Callable[[int, str], Optional[Sequence[str]]] + + +@dataclass +class Agreement: + agree: list[int] = field(default_factory=list) + contradict: list[int] = field(default_factory=list) + unknown: list[int] = field(default_factory=list) + + @property + def all_agree(self) -> bool: + return bool(self.agree) and not self.contradict and not self.unknown + + +def _holds( + pred: Predicate, + trace: int, + fields: Sequence[FieldValues], + screen: Optional[Sequence[str]], +) -> Optional[bool]: + kind = pred.kind + if kind is PredicateKind.NOT: + inner = _holds(pred.operands[0], trace, fields, screen) + return None if inner is None else not inner + if kind is PredicateKind.PARAM_EQUALS: + f = next((x for x in fields if x.param == pred.param), None) + if f is None or trace >= len(f.values) or f.values[trace] is None: + return None + return _norm_value(f.values[trace] or "") == _norm_value(pred.value or "") + if kind is PredicateKind.TEXT_PRESENT: + if screen is None: + return None + want = _norm_value(pred.text or "") + return any(want and want in _norm_value(line) for line in screen) + return None + + +def check_against_recordings( + guard: Predicate, + expected: dict[int, bool], + *, + fields: Sequence[FieldValues], + screen_step: dict[int, Optional[str]], + screen_texts: Optional[ScreenTexts] = None, +) -> Agreement: + """Evaluate ``guard`` in every recording and compare with what it did. + + ``expected[t]`` is True when recording ``t`` took the guarded path. + ``screen_step[t]`` names the step whose before-screen is where the guard + would be checked in recording ``t`` (for screen-element guards). + """ + out = Agreement() + for t, took in sorted(expected.items()): + screen = None + step_id = screen_step.get(t) + if screen_texts is not None and step_id is not None: + screen = screen_texts(t, step_id) + holds = _holds(guard, t, fields, screen) + if holds is None: + out.unknown.append(t) + elif holds == took: + out.agree.append(t) + else: + out.contradict.append(t) + return out + + +def recording_screen_texts(recording_dirs: dict[int, Any]) -> ScreenTexts: + """Screen text at a step, read by OCR from a recording's before-frame. + + ``recording_dirs`` maps trace index -> recording directory. Steps are + ``step_``. Returns None when the frame or OCR is missing, so + the guard stays unchecked rather than guessed. + """ + from pathlib import Path + + cache: dict[tuple[int, str], Optional[list[str]]] = {} + + def read(trace: int, step_id: str) -> Optional[Sequence[str]]: + key = (trace, step_id) + if key in cache: + return cache[key] + texts: Optional[list[str]] = None + root = recording_dirs.get(trace) + m = re.fullmatch(r"step_(\d+)", step_id or "") + if root is not None and m: + frame = Path(root) / "frames" / f"{int(m.group(1)):04d}_before.png" + if frame.is_file(): + try: + from openadapt_flow.vision.ocr import ocr + + texts = [line.text for line in ocr(frame.read_bytes())] + except Exception: # noqa: BLE001 - no OCR means unchecked + texts = None + cache[key] = texts + return texts + + return read diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 7ae47ffe3..c608a52fd 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -760,6 +760,27 @@ class Guard(BaseModel): Predicate.model_rebuild() # resolve the self-referential `operands` +class GuardSource(BaseModel): + """Why a guard is in the program, when spoken narration suggested it. + + Written by induction (``compiler.narration_rules``) for a guard that + started as a narrated proposal and was then certified. The sentence is + scrubbed narration. It explains the guard to a reviewer; the runtime never + reads it. + """ + + sentence: str = Field(description="The scrubbed sentence that suggested it") + rule: str = Field(default="", description="The guard in plain words") + certified_by: Literal["recordings_agree", "probe", "person"] + accepted_by: Optional[str] = Field( + default=None, description="Who accepted it, when a person certified it" + ) + recording: Optional[int] = Field( + default=None, description="Index of the recording the sentence came from" + ) + source: Literal["narration", "narration+model"] = "narration" + + def predicate_contract_sha256(predicate: Optional[Predicate]) -> str: """Return the canonical contract digest for one program transition guard.""" @@ -2416,6 +2437,11 @@ class Workflow(BaseModel): # review, canary or admission pins. None for every other bundle, and # omitted from its serialized bytes. origin: Optional[Literal["agent_run"]] = None + # Narration that suggested a certified guard, keyed by the program state + # (or step) id the guard sits on. Explanation only: the runtime never reads + # it. Empty for every bundle without narrated guards, and then omitted + # from its serialized bytes. + guard_sources: dict[str, GuardSource] = Field(default_factory=dict) # -- schema v2 integrity + provenance manifest -------------------------- # Sealed on ``save`` (per-asset hashes, a whole-bundle content digest, the # compiler version, and -- for a certified bundle -- the certifying policy + @@ -2451,6 +2477,8 @@ def _serialize_compatible(self, handler: Any) -> dict[str, Any]: data.pop("surfaces", None) if self.origin is None: data.pop("origin", None) + if not self.guard_sources: + data.pop("guard_sources", None) return data @model_validator(mode="after") diff --git a/packages/flow/openadapt_flow/narration.py b/packages/flow/openadapt_flow/narration.py new file mode 100644 index 000000000..fbc7e3c2b --- /dev/null +++ b/packages/flow/openadapt_flow/narration.py @@ -0,0 +1,263 @@ +"""Spoken narration lined up with a recording's steps. + +A recording directory (``meta.json`` + ``events.jsonl`` + ``frames/``) may +carry ``narration.json``: what the person said while demonstrating, cut into +sentences, scrubbed of patient data, and lined up with the recorded steps. +The compiler uses it to **propose** a branch rule (see +:mod:`openadapt_flow.compiler.narration_rules`); a proposal never certifies +itself. + +The file is the same for every backend. It only needs the ``t`` (seconds since +the recording started) that every ``events.jsonl`` line carries, so web, +native, RDP and Citrix recordings get the same treatment. It arrives in one of +two ways: + +* ``openadapt-flow`` converts an openadapt-capture session that recorded with + ``--audio`` (:mod:`openadapt_flow.adapters.capture`); capture has already + scrubbed the text. +* ``openadapt-flow narration attach RECORDING --audio FILE.wav`` (or + ``--transcript FILE.json``) transcribes on this machine and scrubs here. + +Rules: + +* **Scrubbed or not written.** :func:`attach_narration` refuses without an + ``openadapt-privacy`` scrubber. :func:`load_narration` refuses a file that + does not say it was scrubbed. +* **Local only.** Transcription uses openadapt-capture's on-device backends. +* **Alignment is by time.** A sentence belongs to the next action the person + takes (people say what they are about to do), or to the last action when + they speak after the final one. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Iterable, Optional, Sequence + +from pydantic import BaseModel, Field + +NARRATION_FILE = "narration.json" +NARRATION_SCHEMA = "openadapt.flow.narration/v1" + +#: A sentence that starts this soon after an action began still belongs to it +#: (the person kept talking as they clicked). +LEAD_S = 0.3 + +#: Event kinds that are steps (structural events such as switch_surface are not). +_NON_STEP_KINDS = frozenset({"switch_surface"}) + + +class NarrationRefused(RuntimeError): + """Narration was not written or read (not scrubbed, or no scrubber).""" + + +class NarrationLine(BaseModel): + """One spoken sentence, timed from the start of the recording.""" + + t_start: float = Field(description="Seconds since the recording started") + t_end: float + text: str = Field(description="Scrubbed sentence") + step_id: Optional[str] = Field( + default=None, + description="The step it is lined up with (step_)", + ) + + +class Narration(BaseModel): + """The ``narration.json`` sidecar.""" + + schema_id: str = Field(default=NARRATION_SCHEMA, alias="schema") + scrubbed: bool = True + scrubbed_by: dict[str, Any] = Field(default_factory=dict) + source: str = Field(default="", description="openadapt-capture | attach") + lines: list[NarrationLine] = Field(default_factory=list) + + model_config = {"populate_by_name": True} + + def for_step(self, step_id: str) -> list[NarrationLine]: + return [line for line in self.lines if line.step_id == step_id] + + +def step_id_for_event(index: int) -> str: + """The compiler's step id for ``events.jsonl`` event ``index``.""" + return f"step_{index:03d}" + + +def read_events(recording_dir: Path) -> list[dict[str, Any]]: + path = Path(recording_dir) / "events.jsonl" + if not path.is_file(): + raise FileNotFoundError(f"no events.jsonl in {recording_dir}") + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def align( + sentences: Iterable[tuple[float, float, str]], + events: Sequence[dict[str, Any]], +) -> list[NarrationLine]: + """Line each ``(t_start, t_end, text)`` up with a recorded step. + + Times are seconds since the recording started (the events' ``t``). A + sentence goes to the first step whose ``t`` is at or after the sentence's + start minus :data:`LEAD_S`; with none, to the last step. + """ + steps = sorted( + ( + (float(e["t"]), int(e["i"])) + for e in events + if "t" in e and "i" in e and e.get("kind") not in _NON_STEP_KINDS + ), + key=lambda pair: pair[0], + ) + out: list[NarrationLine] = [] + for start, end, text in sentences: + text = str(text or "").strip() + if not text: + continue + chosen: Optional[int] = None + for t, i in steps: + if t >= start - LEAD_S: + chosen = i + break + if chosen is None and steps: + chosen = steps[-1][1] + out.append( + NarrationLine( + t_start=round(float(start), 3), + t_end=round(float(end), 3), + text=text, + step_id=step_id_for_event(chosen) if chosen is not None else None, + ) + ) + return out + + +def write_narration(directory: Path, narration: Narration) -> Path: + """Write ``narration.json`` atomically. Refuses unscrubbed narration.""" + if narration.scrubbed is not True: + raise NarrationRefused("narration is written only after it is scrubbed") + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + path = directory / NARRATION_FILE + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".narration-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(narration.model_dump_json(indent=2, by_alias=True) + "\n") + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return path + + +def load_narration(path: Path | str) -> Optional[Narration]: + """Read ``narration.json`` from a recording or bundle directory, or None.""" + file = Path(path) / NARRATION_FILE + if not file.is_file(): + return None + data = json.loads(file.read_text(encoding="utf-8")) + if data.get("schema") != NARRATION_SCHEMA: + raise NarrationRefused(f"{file} is not a narration file") + if data.get("scrubbed") is not True: + raise NarrationRefused(f"{file} does not say it was scrubbed; not reading it") + return Narration.model_validate(data) + + +def _scrubber_evidence(scrubber: Any) -> dict[str, Any]: + from importlib import metadata + + try: + version = metadata.version("openadapt-privacy") + except metadata.PackageNotFoundError: + version = None + return { + "class": f"{type(scrubber).__module__}.{type(scrubber).__qualname__}", + "openadapt_privacy_version": version, + } + + +def require_scrubber() -> Any: + """The openadapt-privacy scrubber, or :class:`NarrationRefused`.""" + from openadapt_flow import privacy + + scrubber = privacy.get_scrubber() + if scrubber is None: + raise NarrationRefused( + "Narration is not stored without scrubbing: spoken words can name a " + "patient. Install openadapt-privacy with pip install " + "'openadapt-flow[privacy]' and python -m spacy download en_core_web_sm." + ) + return scrubber + + +def attach_narration( + recording_dir: Path | str, + sentences: Iterable[tuple[float, float, str]], + *, + scrubber: Any = None, + source: str = "attach", +) -> Path: + """Scrub ``sentences``, line them up with the recording, and write them. + + ``sentences`` are ``(t_start, t_end, text)`` in seconds since the + recording started. ``scrubber`` defaults to openadapt-privacy; without it + this raises :class:`NarrationRefused` and writes nothing. + """ + recording_dir = Path(recording_dir) + scrubber = scrubber if scrubber is not None else require_scrubber() + events = read_events(recording_dir) + clean = [] + for start, end, text in sentences: + try: + scrubbed = scrubber.scrub_text(str(text)) + except Exception as exc: # noqa: BLE001 - not scrubbed means not written + raise NarrationRefused(f"the scrubber failed, nothing written: {exc}") from exc + clean.append((start, end, scrubbed)) + narration = Narration( + scrubbed=True, + scrubbed_by=_scrubber_evidence(scrubber), + source=source, + lines=align(clean, events), + ) + return write_narration(recording_dir, narration) + + +def sentences_from_transcript( + result: dict[str, Any], *, offset: float = 0.0 +) -> list[tuple[float, float, str]]: + """Whisper-style result -> ``(t_start, t_end, text)`` sentences. + + ``offset`` is where the audio starts, in seconds since the recording + started (0 when audio and recording started together). + """ + try: + from openadapt_capture.narration import utterances_from_transcript + except ImportError: # capture extra missing: one sentence per segment + return [ + ( + float(s.get("start", 0.0)) + offset, + float(s.get("end", 0.0)) + offset, + str(s.get("text") or "").strip(), + ) + for s in result.get("segments") or [] + if str(s.get("text") or "").strip() + ] + return [ + (u.start, u.end, u.text) + for u in utterances_from_transcript(result, offset=offset) + ] + + +def transcribe_audio( + path: Path | str, *, model: str = "base", backend: str = "auto" +) -> dict[str, Any]: + """Transcribe an audio file on this machine (openadapt-capture's backends).""" + from openadapt_capture.narration import transcribe_file + + return transcribe_file(path, backend=backend, model=model) diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py new file mode 100644 index 000000000..4f6772215 --- /dev/null +++ b/packages/flow/tests/test_narration_rules.py @@ -0,0 +1,429 @@ +"""Spoken narration proposes a branch guard; recordings, a probe or a person +certify it. + +Synthetic data only: invented payers and amounts, a fake scrubber, and (in one +optional test) synthetic speech made with macOS ``say``. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +from openadapt_flow.compiler.induction import induce_program +from openadapt_flow.compiler.narration_rules import parse_rule +from openadapt_flow.ir import ActionKind, PredicateKind, StateKind, Step, Workflow +from openadapt_flow.narration import ( + Narration, + NarrationLine, + NarrationRefused, + align, + attach_narration, + load_narration, +) +from tests.test_replayer import FakeBackend, FakeVision +from openadapt_flow.runtime.replayer import Replayer + +RULE = "If the insurance is Medicare, skip the copay screen." + + +def _type(sid: str, field: str, value: str, *, risk: str = "reversible") -> Step: + return Step(id=sid, intent=f"type {field}", action=ActionKind.TYPE, text=value, risk=risk) + + +def _key(sid: str, key: str, intent: str, *, risk: str = "reversible") -> Step: + return Step(id=sid, intent=intent, action=ActionKind.KEY, key=key, risk=risk) + + +def visit(payer: str, copay: bool) -> Workflow: + """Check in a visit. The copay is a consequential write, done for some payers.""" + steps = [_type("step_000", "patient", "Test Patient"), _type("step_001", "insurance", payer)] + if copay: + steps.append(_type("step_002", "copay", "20", risk="irreversible")) + steps.append(_key("step_003", "Enter", "press Enter to finish")) + return Workflow(name="check-in", steps=steps) + + +def said(text: str, step_id: str) -> Narration: + return Narration(lines=[NarrationLine(t_start=1.0, t_end=3.0, text=text, step_id=step_id)]) + + +def _opt(result): + return next( + s + for s in result.program.states.values() + if s.kind is StateKind.ACTION and s.step and s.step.intent == "type copay" + ) + + +def _run(workflow, tmp_path, params): + backend = FakeBackend() + bundle = tmp_path / "bundle" + bundle.mkdir(exist_ok=True) + report = Replayer(backend, vision=FakeVision(), poll_interval_s=0.01).run( + workflow, bundle_dir=bundle, run_dir=tmp_path / f"run-{len(list(tmp_path.iterdir()))}", + params=params, + ) + return backend, report + + +# -- parsing ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "sentence,value,skip,runs", + [ + (RULE, "Medicare", True, False), + ("Skip the copay screen when the insurance is Medicare.", "Medicare", True, False), + ("Since it's Medicare, I don't fill in the copay.", "Medicare", True, False), + ("If the insurance is not Medicare, enter the copay.", "Medicare", False, False), + ("Unless the payer is Aetna, leave the referral blank.", "Aetna", True, True), + ("If it says Prior Auth Required, click Request Auth.", "Prior Auth Required", False, True), + ], +) +def test_sentences_parse_into_condition_and_action(sentence, value, skip, runs): + rule = parse_rule(sentence) + assert rule is not None + assert (rule.value, rule.skip, rule.runs_when_condition_holds) == (value, skip, runs) + + +def test_plain_narration_is_not_a_rule(): + assert parse_rule("Now I click save.") is None + assert parse_rule("Open the chart for the patient.") is None + + +# -- required cases ----------------------------------------------------------- + + +def test_a_narrated_branch_becomes_a_proposed_guard(): + """Insurance is on screen but never typed: the sentence maps to a screen + element. Nothing checked it, so it is a flagged proposal and the point + stays refused.""" + traces = [ + Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _type("step_002", "copay", "20", risk="irreversible"), + _key("step_003", "Enter", "finish"), + ], + ), + ] + result = induce_program(traces, narration=[said(RULE, "step_003"), None]) + (ng,) = result.narrated_guards + assert ng.status == "proposed" and ng.certified_by is None + assert ng.guard.kind is PredicateKind.NOT + assert ng.guard.operands[0].kind is PredicateKind.TEXT_PRESENT + assert ng.guard.operands[0].text == "Medicare" + assert ng.sentence == RULE + assert not result.certified and result.program is None + (u,) = result.uncertainties + assert u.proposal is not None and u.proposal.trusted is False + assert u.proposal.source == "narration" + assert any(o.key == "narrated" for o in u.question.options) + assert "--accept-narrated opt_" in u.record_next + + +def test_the_guard_certifies_when_every_recording_agrees(tmp_path): + traces = [visit("Medicare", False), visit("Aetna", True), visit("Cigna", True)] + result = induce_program(traces, narration=[said(RULE, "step_003"), None, None]) + (ng,) = result.narrated_guards + assert ng.status == "certified" and ng.certified_by == "recordings_agree" + assert ng.agree == [0, 1, 2] + assert result.certified, result.render() + state = _opt(result) + guard = state.step.guard + assert guard.on_unmet == "skip" + assert guard.predicate.kind is PredicateKind.NOT + inner = guard.predicate.operands[0] + assert (inner.kind, inner.value) == (PredicateKind.PARAM_EQUALS, "Medicare") + source = result.workflow.guard_sources[state.id] + assert source.sentence == RULE and source.certified_by == "recordings_agree" + + # The compiled program follows the rule on new runs. + pname = inner.param + params = {k: v for k, v in result.workflow.params.items()} + backend, report = _run(result.workflow, tmp_path, {**params, pname: "Medicare"}) + assert report.success and ("type", "20") not in backend.actions + backend, report = _run(result.workflow, tmp_path, {**params, pname: "Humana"}) + assert report.success and ("type", "20") in backend.actions + + +def test_a_contradicting_recording_keeps_it_refused(): + traces = [ + visit("Medicare", False), + visit("Aetna", True), + visit("Medicare", True), # did the copay for Medicare after all + ] + result = induce_program( + traces, + narration=[said(RULE, "step_003"), None, None], + accepted_narration={"opt_2": "reviewer"}, + ) + (ng,) = result.narrated_guards + assert ng.status == "contradicted" and ng.contradict == [2] + assert not result.certified and result.program is None + (u,) = result.uncertainties + assert u.kind == "unconfirmed_optional" + assert "not used" in u.detail + assert not any(o.key == "narrated" for o in u.question.options) + + +def test_healthy_runs_make_no_model_calls(tmp_path): + class CountingInterpreter: + source = "narration+model" + calls = 0 + + def interpret(self, sentence, context): + CountingInterpreter.calls += 1 + return None + + traces = [visit("Medicare", False), visit("Aetna", True)] + result = induce_program( + traces, narration=[said(RULE, "step_003"), None], interpreter=CountingInterpreter() + ) + assert result.certified + # The parser read the sentence, so the compile-time model was not asked. + assert CountingInterpreter.calls == 0 + pname = result.narrated_guards[0].guard.operands[0].param + for payer in ("Medicare", "Aetna"): + _, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: payer}) + assert report.success + assert report.model_calls == 0 + assert report.est_model_cost_usd == 0.0 + + +def test_the_transcript_is_scrubbed_before_it_is_stored(tmp_path): + rec = tmp_path / "rec" + rec.mkdir() + (rec / "events.jsonl").write_text( + "\n".join(json.dumps(e) for e in [{"i": 0, "kind": "type", "t": 1.0}, {"i": 1, "kind": "key", "t": 4.0}]) + + "\n" + ) + + class Scrubber: + def scrub_text(self, text, is_separated=False): + return text.replace("John Smith", "") + + sentences = [(0.2, 0.9, "Patient John Smith."), (2.0, 3.5, RULE)] + from openadapt_flow import privacy + + privacy.set_text_scrubber(None) + try: + with pytest.raises(NarrationRefused): + attach_narration(rec, sentences) + finally: + privacy.reset_scrubbers() + assert not (rec / "narration.json").exists() + + attach_narration(rec, sentences, scrubber=Scrubber()) + raw = (rec / "narration.json").read_text() + assert "John Smith" not in raw + nar = load_narration(rec) + assert [(line.text, line.step_id) for line in nar.lines] == [ + ("Patient .", "step_000"), + (RULE, "step_001"), + ] + data = json.loads(raw) + data["scrubbed"] = False + (rec / "narration.json").write_text(json.dumps(data)) + with pytest.raises(NarrationRefused): + load_narration(rec) + + +# -- the other ways to certify -------------------------------------------------- + + +def test_a_person_can_accept_a_guard_nothing_contradicts(): + traces = [ + Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _type("step_002", "copay", "20", risk="irreversible"), + _key("step_003", "Enter", "finish"), + ], + ), + ] + result = induce_program( + traces, narration=[said(RULE, "step_003"), None], accepted_narration={"opt_1": "R. Reviewer"} + ) + (ng,) = result.narrated_guards + assert (ng.status, ng.certified_by, ng.accepted_by) == ("certified", "person", "R. Reviewer") + assert result.certified + assert result.workflow.guard_sources["opt_1"].accepted_by == "R. Reviewer" + + +def test_a_screen_element_guard_certifies_from_the_recorded_screens(): + traces = [ + Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _type("step_002", "copay", "20", risk="irreversible"), + _key("step_003", "Enter", "finish"), + ], + ), + ] + screens = {0: ["Coverage: Medicare Part B"], 1: ["Coverage: Aetna PPO"]} + result = induce_program( + traces, + narration=[said(RULE, "step_003"), None], + screen_texts=lambda t, step_id: screens[t], + ) + (ng,) = result.narrated_guards + assert ng.status == "certified" and ng.certified_by == "recordings_agree" + assert result.certified + + +def test_a_narrated_divergence_becomes_a_branch(tmp_path): + def trace(payer: str) -> Workflow: + decide = ( + _key("step_002", "M", "press M to bill medicare", risk="irreversible") + if payer == "Medicare" + else _key("step_002", "C", "press C to collect the copay", risk="irreversible") + ) + return Workflow( + name="bill", + steps=[_type("step_001", "insurance", payer), decide, _key("step_003", "Enter", "finish")], + ) + + plain = induce_program([trace("Medicare"), trace("Aetna")]) + assert not plain.certified # a divergence with no detectable condition + + rule = "If the insurance is Medicare, bill Medicare directly." + result = induce_program( + [trace("Medicare"), trace("Aetna"), trace("Cigna")], + narration=[said(rule, "step_002"), None, None], + ) + (ng,) = result.narrated_guards + assert ng.location.startswith("divergence_") and ng.status == "certified" + assert result.certified, result.render() + branch = next(s for s in result.program.states.values() if s.kind is StateKind.BRANCH) + assert branch.id in result.workflow.guard_sources + pname = ng.guard.param + backend, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: "Medicare"}) + assert report.success and ("press", "M") in backend.actions + backend, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: "Humana"}) + assert report.success and ("press", "C") in backend.actions + + +def test_a_sentence_about_another_step_is_not_used(): + traces = [visit("Medicare", False), visit("Aetna", True)] + result = induce_program( + traces, narration=[said("If the insurance is Medicare, skip the referral.", "step_003"), None] + ) + assert result.narrated_guards == [] + assert not result.certified + + +# -- every backend -------------------------------------------------------------- + + +@pytest.mark.parametrize("backend", ["web", "native", "rdp", "citrix"]) +def test_narration_is_the_same_for_every_backend(tmp_path, backend): + """Narration only uses the events' ``t`` clock, so a web, native, RDP or + Citrix recording of the same demonstration lines up and proposes the same + guard.""" + + class Scrubber: + def scrub_text(self, text, is_separated=False): + return text + + def recording(name: str, wf: Workflow, sentences) -> Path: + rec = tmp_path / backend / name + rec.mkdir(parents=True) + (rec / "meta.json").write_text(json.dumps({"id": name, "backend": backend})) + events = [ + {"i": int(s.id.split("_")[1]), "kind": s.action.value, "t": 2.0 * k} + for k, s in enumerate(wf.steps) + ] + (rec / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n") + if sentences: + attach_narration(rec, sentences, scrubber=Scrubber()) + return rec + + traces = [visit("Medicare", False), visit("Aetna", True)] + recs = [ + recording("medicare", traces[0], [(3.2, 4.0, RULE)]), + recording("aetna", traces[1], []), + ] + narration = [load_narration(r) for r in recs] + assert narration[0].lines[0].step_id == "step_003" + result = induce_program(traces, narration=narration) + (ng,) = result.narrated_guards + assert (ng.status, ng.certified_by, ng.location) == ("certified", "recordings_agree", "opt_2") + + +def test_alignment_attaches_a_sentence_to_the_next_action(): + events = [{"i": 0, "t": 1.0}, {"i": 1, "t": 5.0}, {"i": 2, "t": 9.0}] + lines = align([(0.0, 0.5, "a."), (4.9, 6.0, "b."), (12.0, 13.0, "c.")], events) + assert [line.step_id for line in lines] == ["step_000", "step_001", "step_002"] + + +def test_capture_narration_is_carried_into_the_recording(tmp_path): + from types import SimpleNamespace + + from openadapt_flow.adapters.capture import _write_capture_narration + + session = SimpleNamespace( + narration=lambda: [SimpleNamespace(start=103.0, end=104.0, text=RULE)] + ) + _write_capture_narration( + session, tmp_path, 100.0, [{"i": 0, "t": 1.0, "kind": "type"}, {"i": 1, "t": 3.5, "kind": "key"}] + ) + nar = load_narration(tmp_path) + assert nar.source == "openadapt-capture" + assert [(line.t_start, line.step_id) for line in nar.lines] == [(3.0, "step_001")] + + +# -- real local speech (optional) --------------------------------------------- + +FIXTURE = ( + Path(__file__).resolve().parents[2] + / "capture" + / "tests" + / "fixtures" + / "narration" + / "medicare_copay.wav" +) + + +@pytest.mark.skipif( + importlib.util.find_spec("faster_whisper") is None or not FIXTURE.is_file(), + reason="faster-whisper or the synthetic speech fixture is not available", +) +def test_synthetic_speech_end_to_end(tmp_path): + """Synthetic speech -> local Whisper -> scrub -> align -> induce. Skipped + unless the tiny.en model is already cached (never downloads).""" + try: + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache("Systran/faster-whisper-tiny.en", "model.bin") + except Exception: # noqa: BLE001 + cached = None + if not isinstance(cached, str): + pytest.skip("tiny.en Whisper model is not cached locally") + from openadapt_flow.narration import sentences_from_transcript, transcribe_audio + + class Scrubber: + def scrub_text(self, text, is_separated=False): + return text.replace("John Smith", "") + + traces = [visit("Medicare", False), visit("Aetna", True)] + rec = tmp_path / "rec" + rec.mkdir() + events = [{"i": int(s.id.split("_")[1]), "kind": "x", "t": 3.0 * k} for k, s in enumerate(traces[0].steps)] + (rec / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n") + result = transcribe_audio(FIXTURE, model=str(Path(cached).parent), backend="faster-whisper") + attach_narration(rec, sentences_from_transcript(result, offset=1.5), scrubber=Scrubber()) + assert "john smith" not in (rec / "narration.json").read_text().lower() + induced = induce_program(traces, narration=[load_narration(rec), None]) + (ng,) = induced.narrated_guards + assert ng.status == "certified" and induced.certified From 3824549149f18a58858174475b4925d05baf8e58 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:46:12 -0400 Subject: [PATCH 3/8] feat(flow): probe the narrated guard on a test copy; induce and narration CLI induce --probe-on also replays the narrated reading where narration proposed a guard nobody contradicted; it certifies when that reading produces every recorded effect and dropping the step does not. induce --accept-narrated LOCATION --accepted-by NAME, --narration-model (opt-in, own key). New narration attach/show command. Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/__main__.py | 164 ++++++++++++++++- .../flow/openadapt_flow/compiler/probing.py | 95 ++++++++-- packages/flow/tests/test_narration_rules.py | 167 ++++++++++++++++++ 3 files changed, 406 insertions(+), 20 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 6cf824934..d458b166a 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1795,13 +1795,16 @@ def _cmd_induce(args: argparse.Namespace) -> int: else: traces.append(path) + spoken = _induce_narration_args(args) + if isinstance(spoken, int): + return spoken if args.probe_on: - probed = _induce_with_probes(args, traces) + probed = _induce_with_probes(args, traces, spoken) if isinstance(probed, int): return probed result = probed else: - result = induce_program(traces) + result = induce_program(traces, **spoken) print(result.render()) if args.report: report_path = Path(args.report) @@ -1835,7 +1838,44 @@ def _cmd_induce(args: argparse.Namespace) -> int: return 0 -def _induce_with_probes(args: argparse.Namespace, traces: list) -> Any: +def _induce_narration_args(args: argparse.Namespace) -> Any: + """Narration arguments for induce: each trace's narration.json, the + narrated guards a person accepted, and the opt-in compile-time model.""" + from openadapt_flow.compiler.narration_rules import ( + AnthropicNarrationInterpreter, + recording_screen_texts, + ) + from openadapt_flow.narration import NarrationRefused, load_narration + + narration = [] + for d in args.recording: + try: + narration.append(load_narration(Path(d))) + except NarrationRefused as exc: + print(f"Narration not used for {d}: {exc}") + narration.append(None) + accepted = {} + if args.accept_narrated: + if not (args.accepted_by or "").strip(): + print("--accept-narrated needs --accepted-by NAME (who accepted it)") + return 2 + accepted = {loc: args.accepted_by.strip() for loc in args.accept_narrated} + frames = { + i: Path(d) + for i, d in enumerate(args.recording) + if (Path(d) / "frames").is_dir() + } + return { + "narration": narration, + "screen_texts": recording_screen_texts(frames) if frames else None, + "interpreter": AnthropicNarrationInterpreter() if args.narration_model else None, + "accepted_narration": accepted or None, + } + + +def _induce_with_probes( + args: argparse.Namespace, traces: list, spoken: Optional[dict] = None +) -> Any: """``induce --probe-on``: refuse a non-test target, else probe and induce. Returns the InductionResult, or an exit code when the target is refused. @@ -1882,7 +1922,11 @@ def _run(work: Path) -> Any: target = probing.DeploymentProbeTarget(cfg) print(f"Probing on declared test copy {cfg.name!r} (read-back: {contract})") return probing.probe_and_induce( - probe_traces, target=target, reward_contract=contract, work_dir=work + probe_traces, + target=target, + reward_contract=contract, + work_dir=work, + **(spoken or {}), ) if args.probe_work_dir: @@ -1893,6 +1937,53 @@ def _run(work: Path) -> Any: return _run(Path(tmp)) +def _cmd_narration_attach(args: argparse.Namespace) -> int: + import json + + from openadapt_flow.narration import ( + NarrationRefused, + attach_narration, + sentences_from_transcript, + transcribe_audio, + ) + + recording = Path(args.recording) + try: + from openadapt_flow.narration import require_scrubber + + scrubber = require_scrubber() # refuse before any transcription + if args.audio: + result = transcribe_audio(args.audio, model=args.model, backend=args.backend) + else: + result = json.loads(Path(args.transcript).read_text(encoding="utf-8")) + path = attach_narration( + recording, + sentences_from_transcript(result, offset=args.offset), + scrubber=scrubber, + ) + except (NarrationRefused, FileNotFoundError, ValueError, ImportError) as exc: + print(f"Narration not written: {exc}") + return 2 + print(f"Scrubbed narration written to {path}") + return _cmd_narration_show(args) + + +def _cmd_narration_show(args: argparse.Namespace) -> int: + from openadapt_flow.narration import NarrationRefused, load_narration + + try: + narration = load_narration(Path(args.recording)) + except NarrationRefused as exc: + print(str(exc)) + return 2 + if narration is None: + print(f"No narration.json in {args.recording}") + return 1 + for line in narration.lines: + print(f"[{line.t_start:7.2f}s] {line.step_id or '-':>9} {line.text}") + return 0 + + def _cmd_for_each(args: argparse.Namespace) -> int: import shutil @@ -7424,8 +7515,73 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Write the induction report (JSON, every probe run included) here", ) + p.add_argument( + "--accept-narrated", + action="append", + default=[], + metavar="LOCATION", + help=( + "Accept the guard narration proposed at LOCATION (opt_N or " + "divergence_N, as the report names it). A guard any recording " + "contradicts stays refused. Repeatable; needs --accepted-by" + ), + ) + p.add_argument( + "--accepted-by", + default=None, + help="Who accepted the narrated guard(s); recorded in the program", + ) + p.add_argument( + "--narration-model", + action="store_true", + help=( + "Let a compile-time model (your own Anthropic key) read narrated " + "sentences the built-in parser cannot. Its answer is a flagged " + "proposal, checked like any other; never used at run time. The " + "sentence is scrubbed before it is sent. See docs/NARRATION.md" + ), + ) p.set_defaults(func=_cmd_induce) + p = sub.add_parser( + "narration", + help=( + "Spoken narration for a recording: transcribe on this machine, " + "scrub, line up with the steps (narration.json), or show it" + ), + ) + nsub = p.add_subparsers(dest="narration_cmd", required=True) + pa = nsub.add_parser( + "attach", + help=( + "Transcribe audio on this machine (or read a transcript), scrub it " + "with openadapt-privacy, and line it up with the recording's steps" + ), + ) + pa.add_argument("recording", help="Recording directory (events.jsonl)") + src = pa.add_mutually_exclusive_group(required=True) + src.add_argument("--audio", help="16 kHz audio file (WAV, or FLAC with soundfile)") + src.add_argument( + "--transcript", + help="Whisper-style JSON transcript ({'segments': [{start, end, text}]})", + ) + pa.add_argument( + "--offset", + type=float, + default=0.0, + help="Seconds after the recording started that the audio starts (default 0)", + ) + pa.add_argument("--model", default="base", help="Whisper model (default: base)") + pa.add_argument( + "--backend", + default="auto", + help="On-device backend: auto | faster-whisper | openai-whisper", + ) + pa.set_defaults(func=_cmd_narration_attach) + ps = nsub.add_parser("show", help="Print a recording's narration by step") + ps.add_argument("recording", help="Recording or bundle directory") + ps.set_defaults(func=_cmd_narration_show) + p = sub.add_parser( "for-each", help=( diff --git a/packages/flow/openadapt_flow/compiler/probing.py b/packages/flow/openadapt_flow/compiler/probing.py index bcef2d9fd..cfba4afb4 100644 --- a/packages/flow/openadapt_flow/compiler/probing.py +++ b/packages/flow/openadapt_flow/compiler/probing.py @@ -12,6 +12,11 @@ * ``incidental``: the step is noise. Every recording runs without it. * ``conditional``: the step is a required branch. Every recording runs it, guarded to skip when its target (or its dialog) is not on screen. +* ``narrated`` (only when narration proposed a guard there and no recording + contradicts it): every recording runs the step guarded by the narrated + condition (:mod:`openadapt_flow.compiler.narration_rules`). It is confirmed + when it produces the recorded effect in every context and ``incidental`` + conclusively fails in at least one. Each run is scored by reading the system of record back through the reward contract's oracle and judging it with @@ -46,6 +51,7 @@ from openadapt_flow.compiler.induction import ( InductionResult, + NarratedGuard, OptionalPoint, ProbeRun, ProbeVerdict, @@ -55,9 +61,10 @@ optional_guard, optional_points, ) -from openadapt_flow.ir import Step, Workflow +from openadapt_flow.ir import Guard, Step, Workflow INTERPRETATIONS: tuple[str, ...] = ("incidental", "conditional") +NARRATED = "narrated" #: Runtime signals the serve-reward judge accepts. RUNTIME_SIGNALS = frozenset( @@ -180,14 +187,18 @@ def require_test_copy(cfg: Any) -> None: # --------------------------------------------------------------------------- -def _guarded_copy(step: Step) -> Step: +def _guarded_copy(step: Step, guard: Optional[Guard] = None) -> Step: copy = step.model_copy(deep=True) - copy.guard = optional_guard(step) + copy.guard = guard if guard is not None else optional_guard(step) return copy def interpretation_workflow( - traces: list[ProbeTrace], point: OptionalPoint, t: int, interpretation: str + traces: list[ProbeTrace], + point: OptionalPoint, + t: int, + interpretation: str, + narrated: Optional[NarratedGuard] = None, ) -> tuple[Workflow, Optional[int]]: """Recording ``t`` replayed under ``interpretation`` of ``point``. @@ -203,13 +214,18 @@ def interpretation_workflow( if interpretation == "incidental": if t in point.index_in_trace: del steps[point.index_in_trace[t]] - elif interpretation == "conditional": + elif interpretation in ("conditional", NARRATED): + guard = None + if interpretation == NARRATED: + if narrated is None: + raise ValueError("the narrated interpretation needs a narrated guard") + guard = Guard(predicate=narrated.guard, on_unmet="skip") if t in point.index_in_trace: k = point.index_in_trace[t] - steps[k] = _guarded_copy(wf.steps[k]) + steps[k] = _guarded_copy(wf.steps[k], guard) else: inserted = point.insert_at[t] - steps.insert(inserted, _guarded_copy(point.step)) + steps.insert(inserted, _guarded_copy(point.step, guard)) else: raise ValueError(f"unknown interpretation {interpretation!r}") out = wf.model_copy(update={"steps": steps, "program": None}) @@ -310,9 +326,17 @@ def _probe_once( interpretation: str, identity: dict[str, str], work_dir: Path, + narrated: Optional[NarratedGuard] = None, ) -> ProbeRun: slug = f"{point.location}-{interpretation}-{t}" - workflow, inserted = interpretation_workflow(traces, point, t, interpretation) + workflow, inserted = interpretation_workflow( + traces, point, t, interpretation, narrated + ) + params = _params(traces[t]) + if interpretation == NARRATED and narrated is not None: + # A guard on a recorded field compares the run parameter induction + # names; give each run its recording's value for it. + params.update(narrated.trace_params.get(t, {})) base: dict[str, Any] = { "location": point.location, "interpretation": interpretation, @@ -338,7 +362,7 @@ def _probe_once( workflow, bundle_dir=work_dir / "bundles" / slug, run_dir=work_dir / "runs" / slug, - params=_params(traces[t]), + params=params, ) except Exception as exc: # noqa: BLE001 - a crashed replay is a platform failure signal = RunSignal("failed_platform", detail=f"replay raised: {exc}") @@ -373,9 +397,11 @@ def _probe_point( point: OptionalPoint, work_dir: Path, runs: list[ProbeRun], + narrated: Optional[NarratedGuard] = None, ) -> ProbeVerdict: status: dict[str, str] = {} - for interpretation in INTERPRETATIONS: + readings = INTERPRETATIONS + ((NARRATED,) if narrated is not None else ()) + for interpretation in readings: state = "passed" for t in range(len(traces)): run = _probe_once( @@ -387,6 +413,7 @@ def _probe_point( interpretation=interpretation, identity=identities[t], work_dir=work_dir, + narrated=narrated, ) runs.append(run) if run.produced_recorded_effect: @@ -396,8 +423,22 @@ def _probe_point( break # one conclusive miss rules this interpretation out state = "inconclusive" # keep looking for a conclusive miss status[interpretation] = state - passed = [k for k, v in status.items() if v == "passed"] - others_failed = all(v == "failed" for k, v in status.items() if k not in passed) + if narrated is not None: + if status[NARRATED] == "passed" and status["incidental"] == "failed": + return ProbeVerdict( + location=point.location, + kind=point.kind, + interpretations=status, + certified=NARRATED, + detail=( + f"the narrated guard ({narrated.rule}) produced the recorded " + f"effect in all {len(traces)} recording contexts and dropping " + "the step did not" + ), + ) + base = {k: v for k, v in status.items() if k in INTERPRETATIONS} + passed = [k for k, v in base.items() if v == "passed"] + others_failed = all(v == "failed" for k, v in base.items() if k not in passed) if len(passed) == 1 and others_failed: return ProbeVerdict( location=point.location, @@ -411,7 +452,7 @@ def _probe_point( ) if len(passed) > 1: detail = "more than one interpretation produced the recorded effect" - elif not passed and all(v == "failed" for v in status.values()): + elif not passed and all(v == "failed" for v in base.values()): detail = "no interpretation produced the recorded effect" else: detail = "a read-back could not decide" @@ -430,9 +471,17 @@ def probe_and_induce( reward_contract: Union[Any, str, Path], work_dir: Path, propose: Optional[Proposer] = None, + narration: Optional[list[Any]] = None, + screen_texts: Any = None, + interpreter: Any = None, + accepted_narration: Optional[dict[str, str]] = None, ) -> InductionResult: """Induce, then probe each refused optional point on ``target``. + ``narration`` (one per trace) and the other narration arguments are passed + to :func:`induce_program`. Where narration proposed a guard that no + recording contradicts, the ``narrated`` reading is probed as well. + ``target`` must already have passed :func:`require_test_copy` (the CLI does this from the deployment file). ``reward_contract`` is a ``serve-reward`` contract bundle (a directory or a loaded @@ -453,10 +502,18 @@ def probe_and_induce( workflows: list[TraceInput] = [tr.workflow for tr in traces] # The proposer is advisory and never decides a probe, so the first pass # runs without it; it is consulted once, in the pass whose result returns. - first = induce_program(workflows) + spoken: dict[str, Any] = { + "narration": narration if narration is not None else [None] * len(traces), + "screen_texts": screen_texts, + "interpreter": interpreter, + "accepted_narration": accepted_narration, + } + first = induce_program(workflows, **spoken) name = str(getattr(target, "name", "test copy")) if not first.uncertainties: - result = induce_program(workflows, propose=propose) if propose else first + result = ( + induce_program(workflows, propose=propose, **spoken) if propose else first + ) result.probe_target = name return result @@ -481,6 +538,11 @@ def probe_and_induce( if identities: scorer = _Scorer(bundle) points = optional_points(workflows) + narrated = { + ng.location: ng + for ng in first.narrated_guards + if ng.status == "proposed" + } for u in first.uncertainties: point = points.get(u.location) if point is None or point.kind != u.kind: @@ -503,6 +565,7 @@ def probe_and_induce( point=point, work_dir=Path(work_dir), runs=runs, + narrated=narrated.get(point.location), ) verdicts.append(verdict) if verdict.certified: @@ -513,7 +576,7 @@ def probe_and_induce( pass result = induce_program( - workflows, propose=propose, probe_resolutions=resolutions or None + workflows, propose=propose, probe_resolutions=resolutions or None, **spoken ) result.probe_target = name result.probe_runs = runs diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py index 4f6772215..1fed175f0 100644 --- a/packages/flow/tests/test_narration_rules.py +++ b/packages/flow/tests/test_narration_rules.py @@ -427,3 +427,170 @@ def scrub_text(self, text, is_separated=False): induced = induce_program(traces, narration=[load_narration(rec), None]) (ng,) = induced.narrated_guards assert ng.status == "certified" and induced.certified + + +# -- probing on a test copy ----------------------------------------------------- + + +def test_probing_confirms_a_narrated_guard_the_recordings_cannot_check(tmp_path, monkeypatch): + """Coverage shows on screen and is never typed, so the recordings cannot + check "skip the copay when it's Medicare". The scripted test copy can: + the narrated reading produces the recorded effect in both contexts and + dropping the copay does not.""" + from types import SimpleNamespace + + from openadapt_flow.compiler import probing + from openadapt_flow.reward.models import RewardBundle + + coverage = {"p-med": "Medicare", "p-aet": "Aetna"} + + class ScreenVision(FakeVision): + def __init__(self, patient): + super().__init__() + self.patient = patient + + def text_present(self, screen_png, text, **_kw): + return text.lower() in f"coverage: {coverage[self.patient]}".lower() + + class TestCopy: + name = "scripted-test-copy" + + def __init__(self): + self.last: dict[str, bool] = {} + + def reset(self): + pass + + def run(self, workflow, *, bundle_dir, run_dir, params): + patient = params["patient_id"] + backend = FakeBackend() + report = Replayer(backend, vision=ScreenVision(patient), poll_interval_s=0.01).run( + workflow, bundle_dir=bundle_dir, run_dir=run_dir, params=params + ) + copay = ("type", "20") in backend.actions + # The recorded effect: a copay exactly when coverage is not Medicare. + self.last[patient] = report.success and copay == (coverage[patient] != "Medicare") + return probing.RunSignal(probing.runtime_signal_of(report), report.model_calls) + + target = TestCopy() + + class Scorer: + def __init__(self, bundle): + pass + + def baseline(self, identity): + return None + + def judge(self, identity, signal, before): + ok = target.last.get(identity["patient_id"], False) + return SimpleNamespace( + outcome=SimpleNamespace(value="verified" if ok else "wrong_effect"), reason="" + ) + + bundle = SimpleNamespace(identity_keys=("patient_id",), check_identity=lambda i: None) + monkeypatch.setattr(probing, "_Scorer", Scorer) + monkeypatch.setattr(RewardBundle, "load", classmethod(lambda cls, x: x)) + + def recording(patient, copay): + steps = [_type("step_000", "patient", patient)] + if copay: + steps.append(_type("step_002", "copay", "20", risk="irreversible")) + steps.append(_key("step_003", "Enter", "finish")) + return probing.ProbeTrace( + workflow=Workflow(name="c", steps=steps, params={"patient_id": patient}), + bundle_dir=tmp_path / patient, + label=patient, + ) + + traces = [recording("p-med", False), recording("p-aet", True)] + plain = induce_program([t.workflow for t in traces], narration=[said(RULE, "step_003"), None]) + assert plain.narrated_guards[0].status == "proposed" and not plain.certified + + result = probing.probe_and_induce( + traces, + target=target, + reward_contract=bundle, + work_dir=tmp_path / "probe", + narration=[said(RULE, "step_003"), None], + ) + (verdict,) = result.probe_verdicts + assert verdict.certified == "narrated", verdict + assert verdict.interpretations["narrated"] == "passed" + assert verdict.interpretations["incidental"] == "failed" + (ng,) = result.narrated_guards + assert (ng.status, ng.certified_by) == ("certified", "probe") + assert result.certified + assert all(run.model_calls == 0 for run in result.probe_runs) + + +# -- CLI ------------------------------------------------------------------------ + + +def _screen_only_traces(): + return [ + Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _type("step_002", "copay", "20", risk="irreversible"), + _key("step_003", "Enter", "finish"), + ], + ), + ] + + +def test_cli_induce_accepts_a_narrated_guard_only_with_a_name(tmp_path, capsys): + from openadapt_flow.__main__ import main + from openadapt_flow.narration import write_narration + + dirs = [] + for i, wf in enumerate(_screen_only_traces()): + d = tmp_path / f"b{i}" + wf.save(d) + dirs.append(str(d)) + write_narration(Path(dirs[0]), said(RULE, "step_003")) + out = tmp_path / "out" + + assert main(["induce", *dirs, "--out", str(out)]) == 2 + assert "[narrated]" in capsys.readouterr().out and not out.exists() + + assert main(["induce", *dirs, "--out", str(out), "--accept-narrated", "opt_1"]) == 2 + assert "--accepted-by" in capsys.readouterr().out + + rc = main( + ["induce", *dirs, "--out", str(out), "--accept-narrated", "opt_1", "--accepted-by", "R. Reviewer"] + ) + assert rc == 0, capsys.readouterr().out + written = Workflow.load(out) + (source,) = written.guard_sources.values() + assert (source.sentence, source.accepted_by) == (RULE, "R. Reviewer") + + +def test_cli_narration_attach_scrubs_a_transcript(tmp_path, capsys): + from openadapt_flow import privacy + from openadapt_flow.__main__ import main + + rec = tmp_path / "rec" + rec.mkdir() + (rec / "events.jsonl").write_text(json.dumps({"i": 0, "kind": "type", "t": 2.0}) + "\n") + transcript = tmp_path / "t.json" + transcript.write_text( + json.dumps({"segments": [{"start": 0.5, "end": 1.5, "text": "Patient John Smith is Medicare."}]}) + ) + + class Scrubber: + def scrub_text(self, text, is_separated=False): + return text.replace("John Smith", "") + + privacy.set_text_scrubber(None) + try: + assert main(["narration", "attach", str(rec), "--transcript", str(transcript)]) == 2 + assert not (rec / "narration.json").exists() + privacy.set_text_scrubber(Scrubber()) + assert main(["narration", "attach", str(rec), "--transcript", str(transcript)]) == 0 + finally: + privacy.reset_scrubbers() + out = capsys.readouterr().out + assert "John Smith" not in out and "" in out + assert "John Smith" not in (rec / "narration.json").read_text() From f3e4a50cc446a7e255d65deb5a3815f293d7a33b Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:51:17 -0400 Subject: [PATCH 4/8] feat(flow): show narrated guards in visualize and repair show; narration in teach-branch visualize (HTML, Mermaid, JSON) shows each guard with the sentence that suggested it and what certified it; non-local profiles drop the sentence. teach-branch uses a narrated sentence to pick the narrowing parameter only when every example and normal run agrees. repair show lists the sentences. Co-Authored-By: Claude Opus 5.5 --- .../openadapt_flow/learning/branch_teach.py | 148 ++++++++++++++++-- packages/flow/openadapt_flow/repair/cli.py | 20 +++ .../flow/openadapt_flow/visualize/builder.py | 7 + .../openadapt_flow/visualize/projection.py | 8 +- .../flow/openadapt_flow/visualize/render.py | 9 +- .../flow/openadapt_flow/visualize/spec.py | 12 ++ .../visualize/static/program_graph.js | 25 +++ packages/flow/schemas/program-graph-v2.json | 39 +++++ packages/flow/tests/test_narration_rules.py | 53 +++++++ .../flow/tests/test_teach_branch_narration.py | 144 +++++++++++++++++ 10 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 packages/flow/tests/test_teach_branch_narration.py diff --git a/packages/flow/openadapt_flow/learning/branch_teach.py b/packages/flow/openadapt_flow/learning/branch_teach.py index 00c09a974..5cefa02a3 100644 --- a/packages/flow/openadapt_flow/learning/branch_teach.py +++ b/packages/flow/openadapt_flow/learning/branch_teach.py @@ -33,6 +33,13 @@ lifecycle as any repair: campaigns, human approval and a canary (``openadapt-flow repair``). +Narration (``narration.json`` beside a fix recording or a halted run, see +:mod:`openadapt_flow.narration`) can name the narrowing parameter: "if the +payer type is medicaid, dismiss the notice". It is used only when every example +has that value and every normal run that shows the signature has another one. +Otherwise it is reported as contradicted and teach-branch decides as before. +The sentence is kept beside the guard (``Workflow.guard_sources``). + At runtime the branch is an ordinary guarded program state: an OCR text check and, when narrowed, a parameter compare. It makes zero model calls. """ @@ -49,6 +56,7 @@ from openadapt_flow.ir import ( Guard, + GuardSource, ParamKind, Predicate, PredicateKind, @@ -156,6 +164,12 @@ class BranchTeachResult(BaseModel): negatives: int = 0 outcome: Optional[LearnOutcome] = None out_bundle: Optional[Path] = None + narration: Optional[str] = Field( + default=None, description="The narrated sentence that named the condition" + ) + narration_status: Optional[str] = Field( + default=None, description="'certified' (every run agrees) | 'contradicted'" + ) def summary(self) -> str: lines = [ @@ -165,6 +179,13 @@ def summary(self) -> str: ] if self.signature is not None: lines.append(f" condition: {self.signature.describe()}") + if self.narration is not None: + verdict = ( + "every example and normal run agrees" + if self.narration_status == "certified" + else "a run contradicts it, so it was not used" + ) + lines.append(f" narration: {self.narration!r} ({verdict})") if self.reason: lines.append(f" reason: {self.reason}") if self.outcome is not None and self.outcome.gate is not None: @@ -408,6 +429,71 @@ def _narrowing_param( return usable[0], "" +def _example_narration(example: BranchExample, extra: Sequence) -> list[str]: + """Sentences spoken while demonstrating this example's fix (or its run).""" + from openadapt_flow.narration import NarrationRefused, load_narration + + texts: list[str] = [] + for where in (Path(example.fix), Path(example.run_dir)): + if not where.is_dir(): + continue + try: + nar = load_narration(where) + except NarrationRefused: + nar = None + if nar is not None: + texts.extend(line.text for line in nar.lines) + for nar in extra: + texts.extend(line.text for line in nar.lines) + return texts + + +def _narrated_param( + sentences: Sequence[str], + examples: Sequence["_Example"], + base: Workflow, + others: Sequence[dict[str, str]] = (), +) -> Optional[tuple[str, str, str]]: + """``(param, value, sentence)`` a narrated rule names, or None. + + Only a categorical parameter (see :func:`_categorical`) whose value some + example or normal run (``others``) had can be named. Whether the runs + agree is checked by the caller. + """ + from openadapt_flow.compiler.narration_rules import ( + FieldValues, + _match_field, + parse_rule, + ) + + runs = [ex.params for ex in examples] + list(others) + names = sorted({k for r in runs for k in r if _categorical(base, k)}) + fields = [ + FieldValues(param=k, label=k, values=[r.get(k) for r in runs]) for k in names + ] + for sentence in sentences: + rule = parse_rule(sentence) + if rule is None or not rule.runs_when_condition_holds: + continue + matched = _match_field(rule.subject, rule.value, fields) + if matched is not None: + return matched[0].param, matched[1], rule.sentence + return None + + +def _narrated_guard_state(workflow: Workflow, predicate: Predicate) -> Optional[str]: + graph = workflow.program + if graph is None: + return None + for sid, state in graph.states.items(): + if any(tr.guard == predicate for tr in state.transitions): + return sid + if state.step is not None and state.step.guard is not None: + if state.step.guard.predicate == predicate: + return sid + return None + + def _happy_path(program: ProgramGraph) -> list[str]: """Intents along the unconditional first edges from the entry.""" intents: list[str] = [] @@ -442,13 +528,16 @@ def teach_branch( skill_id: Optional[str] = None, library_dir: Optional[Path | str] = None, screen_texts: Optional[Callable[[Path, RunReport], list[str]]] = None, + narration: Sequence = (), ) -> BranchTeachResult: """Learn one guarded branch from two or more resolved halts. Returns a :class:`BranchTeachResult`. ``promoted`` False is a governed refusal: nothing is written to ``out``. Raises :class:`TeachError` for unusable inputs. ``screen_texts`` reads the text of a normal run's screens - (default: OCR of its recorded frames). + (default: OCR of its recorded frames). ``narration`` adds + :class:`~openadapt_flow.narration.Narration` objects to the + ``narration.json`` files found beside the fixes and halted runs. """ if len(examples) < 2: raise TeachError( @@ -508,18 +597,39 @@ def teach_branch( clean = [ c for c in candidates if not any(_contains(t, c) for _, t in negative_runs) ] - if clean: - signature = BranchSignature(text=clean[0]) + text = clean[0] if clean else candidates[0] + overlapping = [ + runtime_params_for_gui(r.params) + for r, t in negative_runs + if _contains(t, text) + ] + sentences = [ + line for ex in examples for line in _example_narration(ex, ()) + ] + [line.text for nar in narration for line in nar.lines] + spoken = _narrated_param( + sentences, + loaded, + base, + [runtime_params_for_gui(r.params) for r, _ in negative_runs], + ) + spoken_ok = spoken is not None and all( + ex.params.get(spoken[0]) == spoken[1] for ex in loaded + ) and all(n.get(spoken[0]) not in (None, spoken[1]) for n in overlapping) + said = { + "narration": spoken[2] if spoken else None, + "narration_status": ( + None if spoken is None else "certified" if spoken_ok else "contradicted" + ), + } + if spoken_ok: + assert spoken is not None + signature = BranchSignature(text=text, param=spoken[0], value=spoken[1]) + elif clean: + signature = BranchSignature(text=text) else: - text = candidates[0] - overlapping = [ - runtime_params_for_gui(r.params) - for r, t in negative_runs - if _contains(t, text) - ] narrowed, why = _narrowing_param(loaded, overlapping, base) if narrowed is None: - return _refused(why, examples=len(loaded), negatives=n_neg) + return _refused(why, examples=len(loaded), negatives=n_neg, **said) signature = BranchSignature(text=text, param=narrowed[0], value=narrowed[1]) # One correction trace per example, all keyed on the shared signature. @@ -626,6 +736,22 @@ def teach_branch( "data_sources": dict(base.data_sources), } ) + if spoken_ok: + assert spoken is not None + where = _narrated_guard_state(updated, signature.predicate()) + if where is not None: + updated = updated.model_copy( + update={ + "guard_sources": { + **updated.guard_sources, + where: GuardSource( + sentence=spoken[2], + rule=f"take the fix only when {signature.describe()}", + certified_by="recordings_agree", + ), + } + } + ) out_bundle = Path(out) _copy_templates(Path(bundle), out_bundle) _copy_carried(carried, out_bundle) @@ -638,6 +764,7 @@ def teach_branch( "condition": { "text_sha256": _digest(signature.text), "param": signature.param, + "narration_sha256": _digest(spoken[2]) if spoken_ok and spoken else None, }, "example_run_reports_sha256": [ _digest((ex.run_dir / "report.json").read_text(encoding="utf-8")) @@ -663,4 +790,5 @@ def teach_branch( negatives=n_neg, outcome=outcome, out_bundle=out_bundle, + **said, ) diff --git a/packages/flow/openadapt_flow/repair/cli.py b/packages/flow/openadapt_flow/repair/cli.py index e5787ec3f..30a2c5c79 100644 --- a/packages/flow/openadapt_flow/repair/cli.py +++ b/packages/flow/openadapt_flow/repair/cli.py @@ -130,12 +130,32 @@ def cmd_show(args: argparse.Namespace) -> int: except RepairLifecycleError as exc: return _print_refusal(exc) print(candidate.diff_summary()) + _print_narrated_guards(candidate) if args.json: print() print(candidate.model_dump_json(indent=2)) return 0 +def _print_narrated_guards(candidate: RepairCandidate) -> None: + """Show each guard the proposed bundle took from narration, with the + sentence (read from the local bundle; the candidate record holds digests).""" + path = candidate.proposed_bundle_path + if not path or not (Path(path) / "workflow.json").is_file(): + return + try: + workflow = Workflow.load(Path(path)) + except Exception: # noqa: BLE001 - the summary above is still valid + return + if not workflow.guard_sources: + return + print(" guards suggested by narration:") + for where, source in sorted(workflow.guard_sources.items()): + who = f" by {source.accepted_by}" if source.accepted_by else "" + print(f" - {where}: {source.rule or 'guard'}") + print(f" said: {source.sentence!r} (certified: {source.certified_by}{who})") + + def cmd_review(args: argparse.Namespace) -> int: store = _store(args) try: diff --git a/packages/flow/openadapt_flow/visualize/builder.py b/packages/flow/openadapt_flow/visualize/builder.py index 69480983b..01a93637b 100644 --- a/packages/flow/openadapt_flow/visualize/builder.py +++ b/packages/flow/openadapt_flow/visualize/builder.py @@ -518,6 +518,11 @@ def _add_edge( ] ) node.halts.append("halts until an authenticated answer is signed") + source = workflow.guard_sources.get(sid) if not prefix else None + if source is not None: + node.guard_reason = source.sentence + node.guard_certified_by = source.certified_by + node.badges.append("narrated rule") nodes.append(node) # exception handler edge if state.on_exception: @@ -557,6 +562,8 @@ def _add_edge( tr.label or (guard_summary or ""), guard=guard_summary, ) + if source is not None and tr.guard is not None: + edges[-1].guard_reason = source.sentence def _ordered_state_ids(graph: "ProgramGraph") -> list[str]: diff --git a/packages/flow/openadapt_flow/visualize/projection.py b/packages/flow/openadapt_flow/visualize/projection.py index 83b3b1f90..647c78d51 100644 --- a/packages/flow/openadapt_flow/visualize/projection.py +++ b/packages/flow/openadapt_flow/visualize/projection.py @@ -89,6 +89,7 @@ class ProjectionBoundaryError(RuntimeError): _RISKS: Final = frozenset({"reversible", "irreversible"}) _OUTCOMES: Final = frozenset({"success", "halt", "escalate"}) _GUARD_ON_UNMET: Final = frozenset({"halt", "skip"}) +_GUARD_CERTIFIED_BY: Final = frozenset({"recordings_agree", "probe", "person"}) _POSTCONDITION_KINDS: Final = frozenset( { "text_present", @@ -124,6 +125,7 @@ class ProjectionBoundaryError(RuntimeError): "API", "secret", "optional (skippable)", + "narrated rule", "loop", "human decision", "halt", @@ -175,6 +177,7 @@ class ProjectionBoundaryError(RuntimeError): "has_api_binding", # bool "postconditions", # closed vocabulary "guard_on_unmet", # closed vocabulary + "guard_certified_by", # closed vocabulary "outcome", # closed vocabulary "halts", # REPLACED by _safe_halts (positional, no content) "badges", # closed literal set + bounded count template @@ -188,6 +191,7 @@ class ProjectionBoundaryError(RuntimeError): "key", # recorded key "api_summary", # method + URL template "guard", # free-text predicate summary + "guard_reason", # spoken sentence (scrubbed, still recorded text) "wait_until", # free-text predicate summary "reason", # free-text terminal reason } @@ -245,7 +249,8 @@ class ProjectionBoundaryError(RuntimeError): _BUNDLE_LOCAL: Final = frozenset({"created_at", "viewport"}) _EDGE_PUBLIC: Final = frozenset({"source", "target", "kind", "label"}) -_EDGE_LOCAL: Final = frozenset({"guard"}) # free-text predicate summary +# guard: free-text predicate summary; guard_reason: spoken sentence. +_EDGE_LOCAL: Final = frozenset({"guard", "guard_reason"}) _SPEC_PUBLIC: Final = frozenset({"spec_version", "bundle", "nodes", "edges"}) _SPEC_LOCAL: Final[frozenset[str]] = frozenset() @@ -467,6 +472,7 @@ def _project_node(node: GraphNode) -> GraphNode: kind for kind in node.postconditions if kind in _POSTCONDITION_KINDS ], guard_on_unmet=_in_vocabulary(node.guard_on_unmet, _GUARD_ON_UNMET), + guard_certified_by=_in_vocabulary(node.guard_certified_by, _GUARD_CERTIFIED_BY), outcome=_in_vocabulary(node.outcome, _OUTCOMES), halts=_safe_halts(node), badges=_safe_badges(node.badges), diff --git a/packages/flow/openadapt_flow/visualize/render.py b/packages/flow/openadapt_flow/visualize/render.py index bed680ed8..28f8bd2a0 100644 --- a/packages/flow/openadapt_flow/visualize/render.py +++ b/packages/flow/openadapt_flow/visualize/render.py @@ -102,6 +102,7 @@ def render_mermaid(spec: ProgramGraphSpec) -> str: irrev: list[str] = [] halt: list[str] = [] id_map: dict[str, str] = {} + reasons: list[str] = [] for i, node in enumerate(spec.nodes): nid = f"n{i}" id_map[node.id] = nid @@ -141,6 +142,8 @@ def render_mermaid(spec: ProgramGraphSpec) -> str: if node.risk == "irreversible": badges.append("irreversible") irrev.append(nid) + if node.guard_reason: + badges.append("narrated rule") suffix = ( f"
{_mm(' · '.join(badges), 40)}" if badges else "" ) @@ -155,9 +158,12 @@ def render_mermaid(spec: ProgramGraphSpec) -> str: if node.outcome in ("halt", "escalate"): halt.append(nid) else: - lines.append(f' {nid}{{"{label}"}}') + extra = "
narrated rule" if node.guard_reason else "" + lines.append(f' {nid}{{"{label}{extra}"}}') if node.halts: halt.append(nid) + if node.guard_reason: + reasons.append(f" %% {nid} because: {_mm(node.guard_reason, 160)}") for edge in spec.edges: src = id_map.get(edge.source) tgt = id_map.get(edge.target) @@ -168,6 +174,7 @@ def render_mermaid(spec: ProgramGraphSpec) -> str: lines.append(f" {src} -->|{label}| {tgt}") else: lines.append(f" {src} --> {tgt}") + lines.extend(reasons) lines.append(" classDef irreversible stroke:#b4530a,stroke-width:2px;") lines.append(" classDef halt stroke:#b21f2d,stroke-width:2px;") if irrev: diff --git a/packages/flow/openadapt_flow/visualize/spec.py b/packages/flow/openadapt_flow/visualize/spec.py index a78100816..d488641e4 100644 --- a/packages/flow/openadapt_flow/visualize/spec.py +++ b/packages/flow/openadapt_flow/visualize/spec.py @@ -251,6 +251,14 @@ class GraphNode(BaseModel): guard_on_unmet: Optional[str] = Field( default=None, description="halt|skip when guard unmet" ) + guard_reason: Optional[str] = Field( + default=None, + description="The scrubbed spoken sentence that suggested this guard", + ) + guard_certified_by: Optional[str] = Field( + default=None, + description="recordings_agree|probe|person: what certified a narrated guard", + ) wait_until: Optional[str] = Field( default=None, description="Readiness predicate summary" ) @@ -282,6 +290,10 @@ class GraphEdge(BaseModel): guard: Optional[str] = Field( default=None, description="Guard summary for a branch edge" ) + guard_reason: Optional[str] = Field( + default=None, + description="The scrubbed spoken sentence that suggested this branch", + ) class ProgramGraphSpec(BaseModel): diff --git a/packages/flow/openadapt_flow/visualize/static/program_graph.js b/packages/flow/openadapt_flow/visualize/static/program_graph.js index 977e397d7..21914daf9 100644 --- a/packages/flow/openadapt_flow/visualize/static/program_graph.js +++ b/packages/flow/openadapt_flow/visualize/static/program_graph.js @@ -182,6 +182,7 @@ "guard", node.guard + " → " + (node.guard_on_unmet || "halt") ); + narratedRow(detail, node); if (node.param) detailRow(detail, "input", "parameter " + node.param); if (detail.childNodes.length) card.appendChild(detail); @@ -211,6 +212,25 @@ return card; } + var CERTIFIED_BY = { + recordings_agree: "every recording agrees", + probe: "confirmed on a test copy", + person: "accepted by a person", + }; + + function narratedRow(detail, node) { + if (!node.guard_reason) return; + detailRow( + detail, + "because", + "\u201c" + + node.guard_reason + + "\u201d (said while recording; certified: " + + (CERTIFIED_BY[node.guard_certified_by] || "unknown") + + ")" + ); + } + function renderControlNode(node) { var card = el("div", "opg-node"); var head = el("div", "opg-node-head"); @@ -231,6 +251,11 @@ }); card.appendChild(chips); } + if (node.guard_reason) { + var why = el("div", "opg-detail"); + narratedRow(why, node); + card.appendChild(why); + } return card; } diff --git a/packages/flow/schemas/program-graph-v2.json b/packages/flow/schemas/program-graph-v2.json index 2293a76b8..71f1aca99 100644 --- a/packages/flow/schemas/program-graph-v2.json +++ b/packages/flow/schemas/program-graph-v2.json @@ -224,6 +224,19 @@ "default": null, "description": "Guard summary for a branch edge", "title": "Guard" + }, + "guard_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The scrubbed spoken sentence that suggested this branch", + "title": "Guard Reason" } }, "required": [ @@ -409,6 +422,32 @@ "description": "halt|skip when guard unmet", "title": "Guard On Unmet" }, + "guard_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The scrubbed spoken sentence that suggested this guard", + "title": "Guard Reason" + }, + "guard_certified_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "recordings_agree|probe|person: what certified a narrated guard", + "title": "Guard Certified By" + }, "wait_until": { "anyOf": [ { diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py index 1fed175f0..f9c453658 100644 --- a/packages/flow/tests/test_narration_rules.py +++ b/packages/flow/tests/test_narration_rules.py @@ -594,3 +594,56 @@ def scrub_text(self, text, is_separated=False): out = capsys.readouterr().out assert "John Smith" not in out and "" in out assert "John Smith" not in (rec / "narration.json").read_text() + + +# -- explain the program back ------------------------------------------------- + + +def test_visualize_shows_each_guard_with_the_sentence_that_suggested_it(tmp_path): + from openadapt_flow.visualize import ( + PresentationProfile, + build_program_graph, + project_program_graph, + render_html, + render_mermaid, + ) + + traces = [visit("Medicare", False), visit("Aetna", True)] + result = induce_program(traces, narration=[said(RULE, "step_003"), None]) + result.workflow.save(tmp_path / "prog") + spec = build_program_graph(Workflow.load(tmp_path / "prog")) + (node,) = [n for n in spec.nodes if n.guard_reason] + assert node.guard_reason == RULE + assert node.guard_certified_by == "recordings_agree" + assert "narrated rule" in node.badges + assert "insurance" in node.guard and node.guard_on_unmet == "skip" + assert RULE in render_html(spec) + assert "narrated rule" in render_mermaid(spec) and "because:" in render_mermaid(spec) + + # Off this machine the sentence is recorded text, so it is dropped. + for profile in ("remote-safe", "public-synthetic"): + safe = project_program_graph(spec, PresentationProfile(profile)) + assert all(n.guard_reason is None for n in safe.nodes) + assert all(e.guard_reason is None for e in safe.edges) + assert any(n.guard_certified_by == "recordings_agree" for n in safe.nodes) + + +def test_visualize_shows_a_narrated_branch_on_its_edge(): + from openadapt_flow.visualize import build_program_graph + + def trace(payer: str) -> Workflow: + decide = ( + _key("step_002", "M", "press M to bill medicare", risk="irreversible") + if payer == "Medicare" + else _key("step_002", "C", "press C to collect the copay", risk="irreversible") + ) + return Workflow(name="b", steps=[_type("step_001", "insurance", payer), decide]) + + rule = "If the insurance is Medicare, bill Medicare directly." + result = induce_program( + [trace("Medicare"), trace("Aetna")], narration=[said(rule, "step_002"), None] + ) + spec = build_program_graph(result.workflow) + (branch,) = [n for n in spec.nodes if n.kind.value == "branch"] + assert branch.guard_reason == rule + assert [e.guard_reason for e in spec.edges if e.guard_reason] == [rule] diff --git a/packages/flow/tests/test_teach_branch_narration.py b/packages/flow/tests/test_teach_branch_narration.py new file mode 100644 index 000000000..bbc4a36d7 --- /dev/null +++ b/packages/flow/tests/test_teach_branch_narration.py @@ -0,0 +1,144 @@ +"""teach-branch: narration names the parameter that narrows the branch. + +Same MockMed prior-auth scenario as ``test_teach_branch``. Two enum +parameters separate the examples from the normal run, so without narration +teach-branch refuses. A narrated sentence picks one, and is used only when +every example and normal run agrees with it. Synthetic data only. +""" + +from __future__ import annotations + +from openadapt_flow.ir import ParamKind, ParamSpec, Workflow +from openadapt_flow.learning.branch_teach import BranchExample, teach_branch +from openadapt_flow.narration import Narration, NarrationLine, write_narration +from tests.test_teach_branch import ( + AETNA, + CIGNA, + HUMANA, + _bundle, + _dismissed, + _fix, + _halt, + _normal_texts, + _replay, + _workflow, +) + +RULE = "If the payer type is medicaid, dismiss the prior auth notice." + + +def _two_param_workflow() -> Workflow: + wf = _workflow() + return wf.model_copy( + update={ + "param_specs": { + "payer_type": ParamSpec( + name="payer_type", + type=ParamKind.ENUM, + choices=["medicaid", "commercial"], + example="commercial", + ), + "plan_tier": ParamSpec( + name="plan_tier", + type=ParamKind.ENUM, + choices=["silver", "gold"], + example="gold", + ), + } + } + ) + + +def _setup(tmp_path, sentence): + wf = _two_param_workflow() + bundle = _bundle(tmp_path, wf) + fix = _fix(tmp_path) + branch = {"payer_type": "medicaid", "plan_tier": "silver"} + normal = {"payer_type": "commercial", "plan_tier": "gold"} + r1 = _halt(wf, bundle, tmp_path / "r1", AETNA, branch) + r2 = _halt(wf, bundle, tmp_path / "r2", CIGNA, branch) + if sentence: + write_narration( + r1, Narration(lines=[NarrationLine(t_start=0.0, t_end=2.0, text=sentence)]) + ) + normal_dir = tmp_path / "normal" + report, _ = _replay(wf, bundle, normal_dir, text=AETNA, blocking=False, params=normal) + assert report.success + return wf, bundle, [BranchExample(run_dir=r1, fix=fix), BranchExample(run_dir=r2, fix=fix)], normal_dir + + +def test_without_narration_two_separating_parameters_are_refused(tmp_path): + _, bundle, examples, normal_dir = _setup(tmp_path, None) + result = teach_branch( + examples, + tmp_path / "out", + bundle=bundle, + negatives=[normal_dir], + screen_texts=_normal_texts({normal_dir: [AETNA]}), + ) + assert not result.promoted + assert "several parameters" in result.reason + + +def test_narration_picks_the_parameter_when_every_run_agrees(tmp_path): + _, bundle, examples, normal_dir = _setup(tmp_path, RULE) + out = tmp_path / "out" + result = teach_branch( + examples, + out, + bundle=bundle, + negatives=[normal_dir], + screen_texts=_normal_texts({normal_dir: [AETNA]}), + ) + assert result.promoted, result.summary() + assert (result.signature.param, result.signature.value) == ("payer_type", "medicaid") + assert (result.narration, result.narration_status) == (RULE, "certified") + assert "narration:" in result.summary() + learned = Workflow.load(out) + (source,) = learned.guard_sources.values() + assert source.sentence == RULE and source.certified_by == "recordings_agree" + report, backend = _replay( + learned, out, tmp_path / "m", text=HUMANA, + params={"payer_type": "medicaid", "plan_tier": "gold"}, + ) + assert report.success and _dismissed(backend) + assert report.model_calls == 0 + + +def test_narration_a_run_contradicts_is_not_used(tmp_path): + # The examples were silver; the sentence says gold. + _, bundle, examples, normal_dir = _setup( + tmp_path, "If the plan tier is gold, dismiss the notice." + ) + result = teach_branch( + examples, + tmp_path / "out", + bundle=bundle, + negatives=[normal_dir], + screen_texts=_normal_texts({normal_dir: [AETNA]}), + ) + assert not result.promoted + assert result.narration_status == "contradicted" + assert "several parameters" in result.reason + + +def test_repair_review_shows_the_sentence_beside_the_guard(tmp_path, capsys): + from openadapt_flow.repair.cli import _print_narrated_guards + from openadapt_flow.repair.registration import load_detached_candidate + + _, bundle, examples, normal_dir = _setup(tmp_path, RULE) + out = tmp_path / "out" + assert teach_branch( + examples, + out, + bundle=bundle, + negatives=[normal_dir], + screen_texts=_normal_texts({normal_dir: [AETNA]}), + ).promoted + candidate = load_detached_candidate(out) + _print_narrated_guards(candidate) + shown = capsys.readouterr().out + assert "guards suggested by narration" in shown + assert RULE in shown and "recordings_agree" in shown + # The candidate record itself still carries digests, not the sentence. + assert RULE not in candidate.model_dump_json() From 3d5f3d58557d8f6a10c87f8b06cbfacca8301198 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:54:08 -0400 Subject: [PATCH 5/8] docs: narration in capture and flow, capability row in review Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 10 +- packages/capture/README.md | 22 ++- packages/capture/docs/COMPLIANCE.md | 16 +- packages/capture/docs/DESIGN.md | 10 +- packages/flow/docs/CAPABILITIES.md | 3 + packages/flow/docs/LEARN_FROM_MANY_RUNS.md | 10 +- packages/flow/docs/NARRATION.md | 173 +++++++++++++++++++++ packages/flow/docs/PROBE_INDUCTION.md | 13 +- 8 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 packages/flow/docs/NARRATION.md diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 65b5091a7..b77f32cfa 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -145,7 +145,7 @@ improvising or retrying when something is off. | Typed business decisions inside a run | Released | Synthetic | `business_decision` states, `openadapt-flow business-decisions` | | Watch many real runs in the background and mine candidate branches (shadow mode) | Capture released; drafts in review ([#1212](https://github.com/OpenAdaptAI/OpenAdapt/pull/1212)) | Synthetic | [SHADOW_MINING.md](packages/capture/docs/SHADOW_MINING.md) | | Fill a workflow's inputs from a referral PDF, fax image or email | Released | Synthetic | `openadapt-flow intake`, [INTAKE.md](packages/flow/docs/INTAKE.md) | -| Use spoken narration while recording to learn why a branch was taken | Planned | Untested | Capture records audio (`--audio`, local Whisper). The compiler doesn't use it yet. | +| Use spoken narration while recording to learn why a branch was taken | In review | Synthetic (generated speech, local Whisper) | `induce`, `teach-branch`, `narration attach`; [NARRATION.md](packages/flow/docs/NARRATION.md) | | Settle a disagreement between recordings by trying both paths on a test copy of the app and reading the record back | In review ([#1276](https://github.com/OpenAdaptAI/OpenAdapt/pull/1276)) | Synthetic (MockMed fault server) | `openadapt-flow induce --probe-on`, [PROBE_INDUCTION.md](packages/flow/docs/PROBE_INDUCTION.md) | ## Surfaces @@ -206,7 +206,7 @@ improvising or retrying when something is off. Each `openadapt-flow` command, grouped by the capability it serves. A test fails when a command is added without being listed here. -- **Record and build a program:** `record`, `demo-record`, `compile`, `induce`, `disambiguate`, `for-each`, `compose`, `process`, `intake`, `visualize`, `pull`. +- **Record and build a program:** `record`, `demo-record`, `compile`, `induce`, `narration`, `disambiguate`, `for-each`, `compose`, `process`, `intake`, `visualize`, `pull`. - **Check and admit:** `lint`, `scaffold-verifier`, `check`, `bind-record-checks`, `certify`, `qualify`, `propose-qualification`, `known-ids`, `jit`, `admit`, `approver-grant`. - **Run and answer:** `run`, `replay`, `resume`, `approve`, `item-approval`, `triage`, `business-decisions`, `explain`, `console`. - **Judge any executor's write:** `verify-run`, `serve-reward` (scores agent training episodes by reading the system of record). @@ -237,8 +237,8 @@ fails when a command is added without being listed here. - No commercial EMR, claims system or real Citrix session has been tested. - Not built yet: record checks built from the app's own reads, self-repair - across several new steps, settling a disagreement between recordings on a - test copy, and narration-driven rules. Network capture to API calls and - scrub-by-default are in review. + across several new steps, and settling a disagreement between recordings on + a test copy. Network capture to API calls, scrub-by-default and + narration-driven rules are in review. - Several capabilities have run only on OpenAdapt's synthetic apps. The Evidence column says which. diff --git a/packages/capture/README.md b/packages/capture/README.md index c18c2d582..4ca20f57f 100644 --- a/packages/capture/README.md +++ b/packages/capture/README.md @@ -165,16 +165,30 @@ encode-and-decode test and refuses to start if it fails. ## Microphone narration `capture record --audio` also records your voice and transcribes it on this -computer. It's off by default. It needs a local transcription backend: +computer. It's off by default. It needs a local transcription backend and +openadapt-privacy, because the transcript is scrubbed before it is stored: ```bash -pip install "openadapt-capture[transcribe-fast]" +pip install "openadapt-capture[audio,privacy]" +python -m spacy download en_core_web_sm ``` -Capture refuses `--audio` before it opens the microphone if no local backend -is installed, and it deletes the audio after transcription unless you set +Capture refuses `--audio` before it opens the microphone if either is +missing, and it deletes the audio after transcription unless you set `RECORD_AUDIO_RETAIN_WAVEFORM`. +The scrubbed transcript is saved as `narration.json` in the capture folder: +one entry per sentence, timed on the same clock as your clicks and keys. Word +by word text is not kept. `capture narration ./my_recording` prints it, and +`capture transcribe ./my_recording` builds it from a retained `audio.flac`. + +OpenAdapt Flow uses these sentences when it learns a program from several +recordings. If you say why you take a branch ("if the insurance is Medicare, +skip the copay screen"), Flow proposes that condition. It becomes part of the +program only when every recording agrees with it, a test copy confirms it, or +a person accepts it. See Flow's +[NARRATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/NARRATION.md). + ## Privacy A recording can contain anything that was on screen or typed, including diff --git a/packages/capture/docs/COMPLIANCE.md b/packages/capture/docs/COMPLIANCE.md index b7f485757..bacc7dc8f 100644 --- a/packages/capture/docs/COMPLIANCE.md +++ b/packages/capture/docs/COMPLIANCE.md @@ -96,9 +96,19 @@ for egress. These boundaries are in code: - The HTML viewer doesn't embed audio by default, because that file is self-contained and easy to forward. - `--audio` prints a microphone notice before recording starts. - -Transcript text is unscrubbed free text. Treat it as at least as sensitive as -the rest of the capture. Nothing downstream reads it today. +- The transcript is scrubbed with openadapt-privacy before it is stored, in + the database and in `narration.json`. `--audio` and `capture transcribe` + refuse when openadapt-privacy is not installed, so there is no unscrubbed + fallback. +- Word-level text and timings are not stored. A name spoken one word at a + time can't be recognized by a text scrubber, so only whole sentences are + kept. + +Scrubbing is automatic and can miss things, so treat the scrubbed transcript +as sensitive, like the rest of the capture. OpenAdapt Flow reads +`narration.json` to propose branch conditions; it never sends the sentences +anywhere unless you opt in to `induce --narration-model`, which scrubs them +again first. ## Recorder control channel diff --git a/packages/capture/docs/DESIGN.md b/packages/capture/docs/DESIGN.md index 519cf042b..2c155f7c9 100644 --- a/packages/capture/docs/DESIGN.md +++ b/packages/capture/docs/DESIGN.md @@ -247,8 +247,14 @@ installed on-device transcription backend before it opens the microphone. It does not use a remote fallback. It discards the waveform after transcription unless the operator explicitly enables waveform retention. -Transcript text is unsanitized. A retained waveform is biometric data. Neither -is safe for automatic egress. +Capture also requires openadapt-privacy before it opens the microphone, and +scrubs the transcript before storing it. The scrubbed sentences go to +`narration.json` (`openadapt_capture.narration`), timed on the recording +clock so a consumer can line each one up with an action. Per-word text is not +stored. + +Scrubbing can miss data, so the transcript is still sensitive. A retained +waveform is biometric data. Neither is safe for automatic egress. ## Browser boundary diff --git a/packages/flow/docs/CAPABILITIES.md b/packages/flow/docs/CAPABILITIES.md index 753b9ba9c..e43e45b53 100644 --- a/packages/flow/docs/CAPABILITIES.md +++ b/packages/flow/docs/CAPABILITIES.md @@ -51,6 +51,9 @@ at replaying one. These capabilities layer onto the same $0, model-free runtime. underdetermined. The healthy replay path still makes no model call. [`LEARN_FROM_MANY_RUNS.md`](LEARN_FROM_MANY_RUNS.md) covers `induce`, `teach-branch` and shadow mining together, with each one's status. + Spoken narration recorded with the demonstration proposes branch + conditions; a proposal becomes a guard only when every recording agrees, a + probe confirms it, or a person accepts it. See [`NARRATION.md`](NARRATION.md). With `--probe-on test-copy.yaml`, a refused optional step is instead replayed both ways on a declared test copy and scored by reading the record back; it is certified only when exactly one reading works for every diff --git a/packages/flow/docs/LEARN_FROM_MANY_RUNS.md b/packages/flow/docs/LEARN_FROM_MANY_RUNS.md index a4eec454c..d828fd91b 100644 --- a/packages/flow/docs/LEARN_FROM_MANY_RUNS.md +++ b/packages/flow/docs/LEARN_FROM_MANY_RUNS.md @@ -45,6 +45,12 @@ step dropped and with it kept, scores each run by reading the record back, and certifies the step only when exactly one reading works for every recording. See [PROBE_INDUCTION.md](PROBE_INDUCTION.md). +If the person said why while recording ("if the insurance is Medicare, skip +the copay screen"), the sentence proposes the condition. It becomes a guard +only when every recording agrees with it, a probe confirms it, or a person +accepts it; a recording that contradicts it keeps the point refused. See +[NARRATION.md](NARRATION.md). + `admit rec-1 rec-2` runs the same induction as its first step, then proposes the checks and stops once for a decision (see [SHADOW_CANDIDATES.md](SHADOW_CANDIDATES.md#how-a-candidate-is-admitted)). @@ -62,7 +68,9 @@ which may be a name or a reference number. `teach-branch` needs at least two halts at the same step, fixed with the same corrective steps. It keys the branch on screen text that every example showed and no earlier screen did. If a normal run also shows that text, it narrows the guard with a run -parameter that separates the two cases, or refuses when none does. +parameter that separates the two cases, or refuses when none does. A +narrated sentence that names the parameter settles a tie between several, if +every run agrees with it ([NARRATION.md](NARRATION.md#teach-branch)). The result goes through the same learning loop as any repair (induction, regression gate, held-out check) and is written as a repair candidate. It is diff --git a/packages/flow/docs/NARRATION.md b/packages/flow/docs/NARRATION.md new file mode 100644 index 000000000..780b43ae5 --- /dev/null +++ b/packages/flow/docs/NARRATION.md @@ -0,0 +1,173 @@ +# Narration: why a branch was taken + +People often say why they do something while they demonstrate it: "if the +insurance is Medicare, skip the copay screen." When `induce` or `teach-branch` +finds a branch, the recordings show *that* the person did something different, +but not *why*. OpenAdapt uses the spoken sentence to propose the condition. + +A narrated condition is only a proposal. It becomes a guard in the program +only when one of these holds: + +- every recording agrees with it; +- `induce --probe-on` confirms it on a declared test copy + ([PROBE_INDUCTION.md](PROBE_INDUCTION.md)); +- a person accepts it (`induce --accept-narrated`). + +If a recording contradicts it, the point stays refused, as it would without +narration. At run time the guard is an ordinary predicate (a string compare on +a run parameter, or an OCR check for text on screen). Runs make no model calls. + +## Getting narration into a recording + +Narration is stored beside a recording as `narration.json`: scrubbed +sentences, each timed from the start of the recording and lined up with a +step. The file is the same for web, native, RDP and Citrix recordings, because +it only uses the `t` that every line in `events.jsonl` carries. + +There are two ways to get it. + +**Record with openadapt-capture.** `capture record --audio` transcribes on +this computer, scrubs the text with openadapt-privacy, and writes +`narration.json` in the capture folder. Converting the capture to a Flow +recording carries the sentences over and lines them up with the steps. + +```bash +pip install "openadapt-capture[audio,privacy]" +python -m spacy download en_core_web_sm +capture record ./visit-1 --audio +``` + +**Attach audio or a transcript to any recording.** Use this for recordings +made by Flow's own recorder (web), or for RDP and Citrix sessions recorded +alongside a separate microphone track. + +```bash +openadapt-flow narration attach ./recordings/visit-1 --audio visit-1.wav +openadapt-flow narration attach ./recordings/visit-1 --transcript visit-1.json --offset 2.5 +openadapt-flow narration show ./recordings/visit-1 +``` + +`--offset` is how many seconds after the recording started the audio starts. +Audio must be 16 kHz. Transcription uses openadapt-capture's on-device Whisper +backends and never uploads audio. + +A sentence is lined up with the next step the person takes after they start +speaking (people say what they're about to do), or with the last step if they +speak after it. + +## Privacy + +- **Scrubbed or not written.** Capture refuses `--audio` before the microphone + opens when openadapt-privacy is not installed. `narration attach` refuses + before transcribing. Neither has a plaintext fallback. +- **Sentences only.** Word timings are used to cut sentences and then dropped, + because a name spoken one word at a time can't be recognized by a text + scrubber. +- **Not shared off the machine by `visualize`.** The `remote-safe`, + `public-synthetic` and `sanitized-derivative` profiles drop the sentence and + keep only what certified the guard. +- **The compile-time model is opt-in.** `induce --narration-model` sends a + sentence the built-in parser can't read to Anthropic on your own key. The + text is scrubbed again on the way out, or the call is refused. The model's + answer can only name a recorded field and a value some recording had, and it + is checked against the recordings like any other proposal. + +## How a sentence becomes a guard + +1. **Find the sentence.** For each branch point, induction looks at sentences + lined up with the steps around it in every recording, then at sentences + elsewhere whose action names the step ("skip the copay screen" names a step + labeled "copay"). +2. **Read it.** The parser handles the usual shapes: "If X is Y, skip Z", + "Skip Z when X is Y", "Since it's Y, I don't fill in Z", "X is Y, so I skip + Z", "Unless X is Y, leave Z blank". "Skip", "don't", "unless" and "is not" + flip the condition. +3. **Map it.** If X names a recorded field that became a run parameter (for + example `insurance`) and Y is a value some recording typed, the guard is + `param_equals(insurance, "Medicare")`. Otherwise it is + `text_present("Medicare")`: the value must be on screen. +4. **Check it.** In each recording, induction evaluates the guard and compares + it with what the person did. A parameter guard uses the value that + recording typed. A screen guard uses OCR of the recording's frame at that + point. + +| Result | What happens | +| --- | --- | +| Every recording agrees | Certified. The step (or path) runs only when the guard holds. | +| A recording contradicts it | Refused, as without narration. The report names the recording. | +| It can't be checked in some recording (no frame, no OCR) | Proposed. A probe or a person can certify it. | + +Two shapes of branch are supported: + +- **An optional step** that some recordings have and others don't. The step + gets the narrated guard and is skipped when it does not hold. +- **A divergence** where recordings take one of two different paths at the + same point. The program gets a branch: the path the sentence names when the + guard holds, the other path otherwise. + +## Example + +Three recordings of checking in a visit. The Medicare one has no copay step, +and the person said "If the insurance is Medicare, skip the copay screen." + +```text +$ openadapt-flow induce rec-medicare rec-aetna rec-cigna --out ./checkin +Induction over 3 trace(s): 3 inferred, 0 proposed (flagged), 0 underdetermined. + [inferred] param 'insurance' -- 'insurance' varies across traces ['Medicare', 'Aetna', 'Cigna'] + [inferred] guard at opt_2: run 'type copay' only when insurance is not 'Medicare' -- suggested by narration, certified: every recording agrees + [narrated] opt_2: 'If the insurance is Medicare, skip the copay screen.' -> run 'type copay' only when insurance is not 'Medicare' -- CERTIFIED (every recording agrees) +Certification: CERTIFIED +``` + +Add a fourth recording where the person entered a copay for a Medicare +patient, and the guard is refused: + +```text + [narrated] opt_2: ... -- REFUSED (recording 3 contradicts it) +Certification: NOT CERTIFIED -- resolve the point(s) above. +``` + +When the guard could not be checked, a person can accept it. Their name is +stored with the guard: + +```bash +openadapt-flow induce rec-a rec-b --out ./prog --accept-narrated opt_2 --accepted-by "R. Reviewer" +``` + +## Explaining the program back + +A certified guard keeps its sentence in the bundle (`Workflow.guard_sources`, +keyed by program state). The runtime never reads it. + +- `openadapt-flow visualize BUNDLE` shows a "narrated rule" badge on the step + or branch, and in the step's details: *because "If the insurance is + Medicare, skip the copay screen." (said while recording; certified: every + recording agrees)*. The Mermaid output lists the sentences as comments. +- `openadapt-flow repair show CANDIDATE` lists the narrated guards of a + taught candidate with their sentences. +- The `induce` report (`--report`) lists every narrated proposal, what it was + checked against, and how it was decided. + +## teach-branch + +`teach-branch` reads `narration.json` beside each fix recording and halted +run. When a sentence names an enum or boolean parameter ("if the payer type +is medicaid, dismiss the notice"), teach-branch narrows the branch to that +parameter, but only if every example has that value and every normal run that +shows the same notice has a different one. That settles the case where +several parameters separate the examples and teach-branch would otherwise +refuse. A sentence a run contradicts is reported and not used. + +## Limits + +- The parser reads simple conditional sentences in English. Anything else is + ignored unless you opt in to `--narration-model`. +- A condition must be a recorded field that became a run parameter, or text + on screen. "Patients over 65" and other computed conditions are not + supported. +- Two recordings that agree is weak evidence. Record one on each side of the + condition at least; more is better. +- A screen guard depends on OCR at run time. Prefer conditions on a recorded + field when you can say them that way. +- Branches on optional dialogs keep their own guard (the dialog being on + screen); narration is not applied to them. diff --git a/packages/flow/docs/PROBE_INDUCTION.md b/packages/flow/docs/PROBE_INDUCTION.md index 1ad8d1c74..33d4b0cd4 100644 --- a/packages/flow/docs/PROBE_INDUCTION.md +++ b/packages/flow/docs/PROBE_INDUCTION.md @@ -22,6 +22,7 @@ For each refused point, probing tries two readings: |---|---| | `incidental` | The recording without the step. | | `conditional` | The recording with the step, set to run only when its target (or its dialog) is on screen and to skip otherwise. This is the guard plain `induce` would put on the step. | +| `narrated` | Only when spoken narration proposed a guard here and no recording contradicts it: the recording with the step, set to run only when the narrated condition holds ([NARRATION.md](NARRATION.md)). | Each reading replays in every recording's context: that recording's own steps, values and subject, with only the point in question changed. Before @@ -44,6 +45,11 @@ exactly as without probing: - A read-back couldn't decide (store unreachable, effect uncertain, replay crashed). Such a run is logged as inconclusive and never counts as a miss. +The `narrated` reading has its own rule: it is certified when it produced the +recorded effect in every context and `incidental` conclusively failed in at +least one. The program then gets the narrated guard, and the sentence is kept +beside it. + Probing covers the points plain `induce` reports as `unconfirmed_optional` and `unconfirmed_branch`. Other refusals (contradicting paths, step order, loops with a consequential body) are logged as not probed and stay refused. @@ -148,9 +154,10 @@ refused, 3 when the target is refused. - One point at a time. While a point is probed, every other point keeps what each recording did. -- Only two readings per point. A step that should run under a condition other - than its own target being on screen (say, a field value) needs a recording, - as before. +- Two readings per point, three with narration. A step that should run under + a condition other than its own target being on screen (say, a field value) + needs a narrated sentence that names the condition, or a recording, as + before. - The check is only as good as the reward contract. A contract with no forbidden effects can't tell a harmless extra write from a harmful one. - The CI tests drive the real replayer against a scripted MockMed screen. The From 9058afbce85e19a8d04da206007c602a3a9907df Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 14:56:46 -0400 Subject: [PATCH 6/8] fix(flow): convert a capture without narration when its file is refused Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/adapters/capture.py | 7 ++++++- packages/flow/tests/test_narration_rules.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/adapters/capture.py b/packages/flow/openadapt_flow/adapters/capture.py index c9f4906b0..ead1a7b70 100644 --- a/packages/flow/openadapt_flow/adapters/capture.py +++ b/packages/flow/openadapt_flow/adapters/capture.py @@ -1314,7 +1314,12 @@ def _write_capture_narration( reader = getattr(session, "narration", None) if reader is None: return - utterances = reader() + try: + utterances = reader() + except ValueError: + # Capture refuses a narration file that does not say it was scrubbed. + # The recording converts without narration rather than carrying it. + return if not utterances: return from openadapt_flow.narration import Narration, align, write_narration diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py index f9c453658..65dd803d2 100644 --- a/packages/flow/tests/test_narration_rules.py +++ b/packages/flow/tests/test_narration_rules.py @@ -647,3 +647,15 @@ def trace(payer: str) -> Workflow: (branch,) = [n for n in spec.nodes if n.kind.value == "branch"] assert branch.guard_reason == rule assert [e.guard_reason for e in spec.edges if e.guard_reason] == [rule] + + +def test_a_refused_capture_narration_is_left_out_not_fatal(tmp_path): + from types import SimpleNamespace + + from openadapt_flow.adapters.capture import _write_capture_narration + + def refuse(): + raise ValueError("narration.json does not say it was scrubbed") + + _write_capture_narration(SimpleNamespace(narration=refuse), tmp_path, 0.0, []) + assert load_narration(tmp_path) is None From 1b15d546995d018b31bc62e1291f3edea1f15a05 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 18 Oct 2026 15:07:37 -0400 Subject: [PATCH 7/8] fix(flow): review findings on narrated guards - check a param guard against the recordings with the runtime's exact compare - leave a divergence refused when another step sits inside it, and insert branches at the same position in column order - pin acceptances and probe results to the guard id (LOCATION@GUARD_ID) - accept only a JSON boolean from the model, and screen text the person said - parser: stop at 'otherwise', flip 'it's not X', read 'isn't needed' as skip, drop double negatives and unplaced negations - skip a malformed narration.json instead of failing - link the capability row to #1291 Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 2 +- packages/flow/docs/NARRATION.md | 15 ++- packages/flow/openadapt_flow/__main__.py | 18 ++- .../flow/openadapt_flow/compiler/induction.py | 34 ++++-- .../compiler/narration_rules.py | 55 ++++++++- .../flow/openadapt_flow/compiler/probing.py | 7 +- .../openadapt_flow/learning/branch_teach.py | 2 +- packages/flow/tests/test_narration_rules.py | 105 +++++++++++++++++- 8 files changed, 205 insertions(+), 33 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index b77f32cfa..eefe1e440 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -145,7 +145,7 @@ improvising or retrying when something is off. | Typed business decisions inside a run | Released | Synthetic | `business_decision` states, `openadapt-flow business-decisions` | | Watch many real runs in the background and mine candidate branches (shadow mode) | Capture released; drafts in review ([#1212](https://github.com/OpenAdaptAI/OpenAdapt/pull/1212)) | Synthetic | [SHADOW_MINING.md](packages/capture/docs/SHADOW_MINING.md) | | Fill a workflow's inputs from a referral PDF, fax image or email | Released | Synthetic | `openadapt-flow intake`, [INTAKE.md](packages/flow/docs/INTAKE.md) | -| Use spoken narration while recording to learn why a branch was taken | In review | Synthetic (generated speech, local Whisper) | `induce`, `teach-branch`, `narration attach`; [NARRATION.md](packages/flow/docs/NARRATION.md) | +| Use spoken narration while recording to learn why a branch was taken | In review ([#1291](https://github.com/OpenAdaptAI/OpenAdapt/pull/1291)) | Synthetic (generated speech, local Whisper) | `induce`, `teach-branch`, `narration attach`; [NARRATION.md](packages/flow/docs/NARRATION.md) | | Settle a disagreement between recordings by trying both paths on a test copy of the app and reading the record back | In review ([#1276](https://github.com/OpenAdaptAI/OpenAdapt/pull/1276)) | Synthetic (MockMed fault server) | `openadapt-flow induce --probe-on`, [PROBE_INDUCTION.md](packages/flow/docs/PROBE_INDUCTION.md) | ## Surfaces diff --git a/packages/flow/docs/NARRATION.md b/packages/flow/docs/NARRATION.md index 780b43ae5..f5081472d 100644 --- a/packages/flow/docs/NARRATION.md +++ b/packages/flow/docs/NARRATION.md @@ -88,7 +88,7 @@ speak after it. `text_present("Medicare")`: the value must be on screen. 4. **Check it.** In each recording, induction evaluates the guard and compares it with what the person did. A parameter guard uses the value that - recording typed. A screen guard uses OCR of the recording's frame at that + recording typed, compared exactly as the runtime compares it. A screen guard uses OCR of the recording's frame at that point. | Result | What happens | @@ -103,7 +103,8 @@ Two shapes of branch are supported: gets the narrated guard and is skipped when it does not hold. - **A divergence** where recordings take one of two different paths at the same point. The program gets a branch: the path the sentence names when the - guard holds, the other path otherwise. + guard holds, the other path otherwise. A divergence with another step inside + it stays refused. ## Example @@ -127,13 +128,17 @@ patient, and the guard is refused: Certification: NOT CERTIFIED -- resolve the point(s) above. ``` -When the guard could not be checked, a person can accept it. Their name is -stored with the guard: +When the guard could not be checked, a person can accept it. The report +prints each proposal as `LOCATION@GUARD_ID`; accepting by that id pins the +exact guard you read, so a different guard proposed later at the same place is +not accepted. Their name is stored with the guard: ```bash -openadapt-flow induce rec-a rec-b --out ./prog --accept-narrated opt_2 --accepted-by "R. Reviewer" +openadapt-flow induce rec-a rec-b --out ./prog --accept-narrated opt_2@3fa9c1d2e4b5 --accepted-by "R. Reviewer" ``` +A probe result is pinned the same way. + ## Explaining the program back A certified guard keeps its sentence in the bundle (`Workflow.guard_sources`, diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index d458b166a..b44554a45 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1851,7 +1851,7 @@ def _induce_narration_args(args: argparse.Namespace) -> Any: for d in args.recording: try: narration.append(load_narration(Path(d))) - except NarrationRefused as exc: + except (NarrationRefused, ValueError) as exc: print(f"Narration not used for {d}: {exc}") narration.append(None) accepted = {} @@ -1859,6 +1859,13 @@ def _induce_narration_args(args: argparse.Namespace) -> Any: if not (args.accepted_by or "").strip(): print("--accept-narrated needs --accepted-by NAME (who accepted it)") return 2 + unpinned = [loc for loc in args.accept_narrated if "@" not in loc] + if unpinned: + print( + "--accept-narrated takes LOCATION@GUARD_ID as the report prints " + f"it (got {', '.join(unpinned)}), so it accepts that exact guard" + ) + return 2 accepted = {loc: args.accepted_by.strip() for loc in args.accept_narrated} frames = { i: Path(d) @@ -7519,11 +7526,12 @@ def build_parser() -> argparse.ArgumentParser: "--accept-narrated", action="append", default=[], - metavar="LOCATION", + metavar="LOCATION@GUARD_ID", help=( - "Accept the guard narration proposed at LOCATION (opt_N or " - "divergence_N, as the report names it). A guard any recording " - "contradicts stays refused. Repeatable; needs --accepted-by" + "Accept the guard narration proposed at LOCATION, pinned by its " + "GUARD_ID, exactly as the report prints it (e.g. opt_2@3fa9c1d2e4b5). " + "A guard any recording contradicts stays refused. Repeatable; " + "needs --accepted-by" ), ) p.add_argument( diff --git a/packages/flow/openadapt_flow/compiler/induction.py b/packages/flow/openadapt_flow/compiler/induction.py index 763b35ec7..c5d9f83c1 100644 --- a/packages/flow/openadapt_flow/compiler/induction.py +++ b/packages/flow/openadapt_flow/compiler/induction.py @@ -311,6 +311,10 @@ class NarratedGuard(BaseModel): """ location: str = Field(description="opt_ or divergence_") + guard_id: str = Field( + default="", + description="Digest of the guard; accept it as @", + ) sentence: str = Field(description="The scrubbed sentence") recording: int = Field(description="Index of the recording that said it") step_id: Optional[str] = None @@ -415,7 +419,8 @@ def render(self) -> str: else: verdict = "proposed, NOT trusted" lines.append( - f" [narrated] {ng.location}: {ng.sentence!r} -> {ng.rule} -- {verdict}" + f" [narrated] {ng.location}@{ng.guard_id}: {ng.sentence!r} -> " + f"{ng.rule} -- {verdict}" ) if self.probe_target is not None: lines.append( @@ -1054,7 +1059,7 @@ def induce_program( is the only caller that sets it). ``"incidental"`` drops the step; ``"conditional"`` keeps it guarded on its own presence. A point not listed here is refused exactly as without probing. - ``"narrated"`` certifies the guard narration suggested there. + ``"narrated@"`` certifies that narrated guard. narration: One :class:`~openadapt_flow.narration.Narration` (or None) per trace. Default: read ``narration.json`` from each trace that is a recording or bundle directory. A sentence lined up near a branch @@ -1067,8 +1072,10 @@ def induce_program( interpreter: Optional compile-time :class:`~.narration_rules.NarrationInterpreter` for sentences the parser cannot read. Its answer is checked like any narrated guard. - accepted_narration: ``{location: person}`` -- narrated guards a - person accepted. A guard a recording contradicts stays refused. + accepted_narration: ``{"@": person}`` -- + narrated guards a person accepted. The guard id pins the exact + guard the report showed; a different guard at that location is + not accepted. A guard a recording contradicts stays refused. Returns: An :class:`InductionResult` with the induced program (or a refusal) and @@ -2048,6 +2055,7 @@ def _decide_narrated( screen_texts=screen_texts, ) certified_by = accepted_by = None + pinned = f"{location}@{cand.guard_id}" if agreement.contradict: status = "contradicted" detail = ( @@ -2058,11 +2066,11 @@ def _decide_narrated( elif agreement.all_agree: status, certified_by = "certified", "recordings_agree" detail = f"all {len(agreement.agree)} recordings agree" - elif probe_resolutions.get(location) == "narrated": + elif probe_resolutions.get(location) == f"narrated@{cand.guard_id}": status, certified_by = "certified", "probe" detail = "replaying it on the declared test copy produced every recorded effect" - elif accepted.get(location): - status, certified_by, accepted_by = "certified", "person", accepted[location] + elif accepted.get(pinned): + status, certified_by, accepted_by = "certified", "person", accepted[pinned] detail = f"accepted by {accepted_by}" else: status = "proposed" @@ -2081,6 +2089,7 @@ def _decide_narrated( } return NarratedGuard( location=location, + guard_id=cand.guard_id, sentence=cand.sentence, recording=cand.trace, step_id=cand.step_id, @@ -2124,7 +2133,8 @@ def _flag_on_uncertainty(result, uncertainty_location, ng, proposal) -> None: u.record_next = ( (u.record_next + " ") if u.record_next else "" ) + ( - f"Or accept the narrated rule: induce --accept-narrated {ng.location}." + "Or accept the narrated rule: induce --accept-narrated " + f"{ng.location}@{ng.guard_id} --accepted-by NAME." ) if u.question is not None and not any( o.key == "narrated" for o in u.question.options @@ -2266,8 +2276,10 @@ def _apply_narration( _flag_on_uncertainty(result, sid, ng, proposal) # -- divergences (recordings took different paths at the same point) ---- + # Insert from the back so earlier positions stay valid; at equal positions + # the later column goes in first, so the earlier one ends up in front. for idx, (group_cols, pos) in sorted( - divergences.items(), key=lambda kv: -kv[1][1] + divergences.items(), key=lambda kv: (-kv[1][1], -kv[0]) ): where = f"column {idx}" location = f"divergence_{idx}" @@ -2282,6 +2294,10 @@ def _apply_narration( arm_steps, members = arms indices = [i for i, c in enumerate(columns) if any(c is g for g in group_cols)] lo, hi = min(indices), max(indices) + if indices != list(range(lo, hi + 1)): + # Another column sits inside the divergence; one branch node at + # its first position would reorder steps. Leave it refused. + continue descriptors = [ set().union(*(_step_words(s) for s in steps)) if steps else set() for steps in arm_steps diff --git a/packages/flow/openadapt_flow/compiler/narration_rules.py b/packages/flow/openadapt_flow/compiler/narration_rules.py index de9edc337..60fc3e8e9 100644 --- a/packages/flow/openadapt_flow/compiler/narration_rules.py +++ b/packages/flow/openadapt_flow/compiler/narration_rules.py @@ -105,7 +105,18 @@ def _clean_value(text: str) -> str: return text.strip(" .,;:!?") -def _split_action(action: str) -> tuple[str, bool]: +_OTHERWISE = re.compile(r",?\s*\b(?:otherwise|or else|else)\b", re.I) +_UNCLEAR_NEGATION = re.compile(r"\b(?:not|no|never|n't|isn't|aren't|won't|don't)\b", re.I) +_NOT_NEEDED = re.compile( + r"^(?P.+?)\s+(?:is not|isn't|are not|aren't)\s+(?:needed|required|necessary)$" + r"|^(?P.+?)\s+(?:is|are)\s+(?:not needed|not required|unnecessary)$", + re.I, +) + + +def _split_action(action: str) -> tuple[str, Optional[bool]]: + """``(object, skip)``; ``skip`` is None when the negation is unclear.""" + action = _OTHERWISE.split(action, maxsplit=1)[0] a = re.sub(r"^(?:then|so|and)\s+", "", action.strip(" ,.;:!?"), flags=re.I) a = re.sub( r"^(?:i|we|you)\s+(?:can\s+|will\s+|should\s+|just\s+|always\s+|need to\s+)*", @@ -117,11 +128,21 @@ def _split_action(action: str) -> tuple[str, bool]: blank = re.match(r"^leave\s+(?P.+?)\s+(?:blank|empty|alone)$", a, flags=re.I) if blank is not None: return blank.group("obj"), True + needless = _NOT_NEEDED.match(a) + if needless is not None: + return needless.group("obj") or needless.group("obj2"), True for verb in _SKIP_VERBS: if low.startswith(verb + " ") or low == verb: rest = a[len(verb) :].strip() rest = re.sub(r"^(?:need to|have to|bother|fill in|do)\s+", "", rest, flags=re.I) + # "don't skip X": a double negative is too easy to misread. + if any(rest.lower().startswith(v + " ") for v in _SKIP_VERBS): + return rest, None + if _UNCLEAR_NEGATION.search(rest): + return rest, None return rest, True + if _UNCLEAR_NEGATION.search(a): + return a, None return a, False @@ -152,6 +173,8 @@ def parse_rule(sentence: str) -> Optional[ParsedRule]: action = after[boundary.end() :] else: condition, action = after, "" + # "..., otherwise enter it": only the first action is the rule. + action = _OTHERWISE.split(action, maxsplit=1)[0] if not action.strip(" .,;:!?"): action = before elif before and not tokens(action): @@ -177,11 +200,17 @@ def parse_rule(sentence: str) -> Optional[ParsedRule]: if lowered.startswith(pron): condition = condition[len(pron) :] break + flipped = re.match(r"^(?:not|n't)\s+", condition, flags=re.I) + if flipped is not None: + condition = condition[flipped.end() :] + negated = not negated value = _clean_value(condition) + if re.match(r"^(?:not|no)\b", value, flags=re.I): + return None # a negation the parser did not place if subject.lower() in _PRONOUN_SUBJECTS: subject = "" action, skip = _split_action(action) - if not value or not action and not skip: + if skip is None or not value or not action and not skip: return None return ParsedRule( sentence=text, @@ -293,6 +322,14 @@ class Candidate: source: str = "narration" target_arm: Optional[int] = None # divergence: the arm the sentence is about + @property + def guard_id(self) -> str: + """Short digest of the guard, so an acceptance or a probe result pins + this exact guard and not whatever a later pass proposes.""" + from openadapt_flow.ir import predicate_contract_sha256 + + return predicate_contract_sha256(self.guard)[:12] + @property def guard(self) -> Predicate: """The predicate under which the step (or target arm) runs.""" @@ -383,9 +420,9 @@ def candidate_from_model( """Validate an interpreter's answer. It may only name an offered field and a value some recording actually had; anything else is dropped.""" value = str(data.get("value") or "").strip() - if not value: - return None - runs = bool(data.get("step_runs_when_true", True)) + runs = data.get("step_runs_when_true") + if not value or not isinstance(runs, bool): + return None # only a real JSON boolean says which way the guard goes name = data.get("field") if name: f = next((x for x in fields if x.param == name), None) @@ -405,6 +442,9 @@ def candidate_from_model( ) else: text = str(data.get("text") or value).strip() + # The screen text must be words the person actually said. + if not text or _norm_value(text) not in _norm_value(sentence): + return None condition = Predicate( kind=PredicateKind.TEXT_PRESENT, text=text, intent=f"{text} on screen" ) @@ -460,7 +500,10 @@ def _holds( f = next((x for x in fields if x.param == pred.param), None) if f is None or trace >= len(f.values) or f.values[trace] is None: return None - return _norm_value(f.values[trace] or "") == _norm_value(pred.value or "") + # The run-time check is an exact string compare + # (runtime.program_predicates); check the recordings the same way, so + # "medicare" and "Medicare" are different values here too. + return f.values[trace] == pred.value if kind is PredicateKind.TEXT_PRESENT: if screen is None: return None diff --git a/packages/flow/openadapt_flow/compiler/probing.py b/packages/flow/openadapt_flow/compiler/probing.py index cfba4afb4..9e6b4b8eb 100644 --- a/packages/flow/openadapt_flow/compiler/probing.py +++ b/packages/flow/openadapt_flow/compiler/probing.py @@ -568,7 +568,12 @@ def probe_and_induce( narrated=narrated.get(point.location), ) verdicts.append(verdict) - if verdict.certified: + if verdict.certified == NARRATED: + # Pin the exact guard that was probed; a later pass that + # proposes a different one is not certified by this probe. + ng = narrated[point.location] + resolutions[point.location] = f"{NARRATED}@{ng.guard_id}" + elif verdict.certified: resolutions[point.location] = verdict.certified try: target.reset() # leave the copy at its baseline diff --git a/packages/flow/openadapt_flow/learning/branch_teach.py b/packages/flow/openadapt_flow/learning/branch_teach.py index 5cefa02a3..896a9a229 100644 --- a/packages/flow/openadapt_flow/learning/branch_teach.py +++ b/packages/flow/openadapt_flow/learning/branch_teach.py @@ -439,7 +439,7 @@ def _example_narration(example: BranchExample, extra: Sequence) -> list[str]: continue try: nar = load_narration(where) - except NarrationRefused: + except (NarrationRefused, ValueError): nar = None if nar is not None: texts.extend(line.text for line in nar.lines) diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py index 65dd803d2..35e60cc9a 100644 --- a/packages/flow/tests/test_narration_rules.py +++ b/packages/flow/tests/test_narration_rules.py @@ -159,10 +159,11 @@ def test_a_contradicting_recording_keeps_it_refused(): visit("Aetna", True), visit("Medicare", True), # did the copay for Medicare after all ] + first = induce_program(traces, narration=[said(RULE, "step_003"), None, None]) result = induce_program( traces, narration=[said(RULE, "step_003"), None, None], - accepted_narration={"opt_2": "reviewer"}, + accepted_narration={f"opt_2@{first.narrated_guards[0].guard_id}": "reviewer"}, ) (ng,) = result.narrated_guards assert ng.status == "contradicted" and ng.contradict == [2] @@ -250,9 +251,16 @@ def test_a_person_can_accept_a_guard_nothing_contradicts(): ], ), ] - result = induce_program( + first = induce_program(traces, narration=[said(RULE, "step_003"), None]) + pinned = f"opt_1@{first.narrated_guards[0].guard_id}" + # A bare location does not accept anything: the id pins the exact guard. + bare = induce_program( traces, narration=[said(RULE, "step_003"), None], accepted_narration={"opt_1": "R. Reviewer"} ) + assert not bare.certified + result = induce_program( + traces, narration=[said(RULE, "step_003"), None], accepted_narration={pinned: "R. Reviewer"} + ) (ng,) = result.narrated_guards assert (ng.status, ng.certified_by, ng.accepted_by) == ("certified", "person", "R. Reviewer") assert result.certified @@ -553,14 +561,22 @@ def test_cli_induce_accepts_a_narrated_guard_only_with_a_name(tmp_path, capsys): out = tmp_path / "out" assert main(["induce", *dirs, "--out", str(out)]) == 2 - assert "[narrated]" in capsys.readouterr().out and not out.exists() + report = capsys.readouterr().out + assert "[narrated]" in report and not out.exists() + import re - assert main(["induce", *dirs, "--out", str(out), "--accept-narrated", "opt_1"]) == 2 - assert "--accepted-by" in capsys.readouterr().out + pinned = re.search(r"--accept-narrated (opt_1@[0-9a-f]+)", report).group(1) + assert main(["induce", *dirs, "--out", str(out), "--accept-narrated", pinned]) == 2 + assert "--accepted-by" in capsys.readouterr().out rc = main( ["induce", *dirs, "--out", str(out), "--accept-narrated", "opt_1", "--accepted-by", "R. Reviewer"] ) + assert rc == 2 and "LOCATION@GUARD_ID" in capsys.readouterr().out + + rc = main( + ["induce", *dirs, "--out", str(out), "--accept-narrated", pinned, "--accepted-by", "R. Reviewer"] + ) assert rc == 0, capsys.readouterr().out written = Workflow.load(out) (source,) = written.guard_sources.values() @@ -659,3 +675,82 @@ def refuse(): _write_capture_narration(SimpleNamespace(narration=refuse), tmp_path, 0.0, []) assert load_narration(tmp_path) is None + + +# -- regressions from review ---------------------------------------------------- + + +def test_recorded_values_are_compared_exactly_like_the_runtime(): + """"medicare" and "Medicare" are different run-time values, so a recording + that typed one must not vouch for the other.""" + traces = [visit("Medicare", False), visit("medicare", False), visit("Aetna", True)] + result = induce_program(traces, narration=[said(RULE, "step_003"), None, None]) + (ng,) = result.narrated_guards + assert ng.status == "contradicted" and ng.contradict == [1] + assert not result.certified + + +def test_a_divergence_with_a_step_inside_it_stays_refused(): + def trace(steps): + return Workflow(name="d", steps=[_type("step_000", "insurance", steps[0])] + steps[1]) + + a = _key("step_001", "B", "press B for beta", risk="irreversible") + z = _key("step_003", "Z", "press Z for zeta", risk="irreversible") + al = _key("step_001", "A", "press A for alpha", risk="irreversible") + om = _key("step_003", "O", "press O for omega", risk="irreversible") + note = _type("step_002", "note", "n") + traces = [ + trace(("Medicare", [a, z])), + trace(("Aetna", [al, om])), + trace(("Medicare", [a, note, z])), + ] + rule = "If the insurance is Medicare, press beta and zeta." + result = induce_program(traces, narration=[said(rule, "step_001"), None, None]) + assert not result.certified + + +@pytest.mark.parametrize( + "sentence,runs", + [ + ("Skip the copay if the insurance is Medicare, otherwise enter it.", False), + ("If it's not Medicare, skip the copay.", True), + ("If it's Medicare, the copay isn't needed.", False), + ], +) +def test_negations_parse_the_right_way_round(sentence, runs): + rule = parse_rule(sentence) + assert rule is not None and rule.value == "Medicare" + assert rule.runs_when_condition_holds is runs + + +def test_an_unclear_double_negative_is_not_a_rule(): + assert parse_rule("Don't skip the copay if it's Medicare.") is None + + +def test_model_answers_are_validated(): + from openadapt_flow.compiler.narration_rules import FieldValues, candidate_from_model + + fields = [FieldValues(param="insurance", label="insurance", values=["Medicare", "Aetna"])] + kw = dict(sentence="Medicare ones go straight through", trace=0, step_id=None, fields=fields, source="m") + # A string "false" is not a boolean; the answer is dropped. + assert candidate_from_model({"field": "insurance", "value": "Medicare", "step_runs_when_true": "false"}, **kw) is None + # A field that was not offered, or a value no recording had, is dropped. + assert candidate_from_model({"field": "payer", "value": "Medicare", "step_runs_when_true": False}, **kw) is None + assert candidate_from_model({"field": "insurance", "value": "Cigna", "step_runs_when_true": False}, **kw) is None + # Screen text must be words the person said. + assert candidate_from_model({"text": "Prior auth", "value": "x", "step_runs_when_true": True}, **kw) is None + ok = candidate_from_model({"field": "insurance", "value": "medicare", "step_runs_when_true": False}, **kw) + assert ok is not None and ok.condition.value == "Medicare" and not ok.runs_when_condition_holds + + +def test_a_malformed_narration_file_is_skipped_by_the_cli(tmp_path, capsys): + from openadapt_flow.__main__ import main + + dirs = [] + for i, wf in enumerate([visit("Medicare", False), visit("Aetna", True)]): + d = tmp_path / f"b{i}" + wf.save(d) + dirs.append(str(d)) + (Path(dirs[0]) / "narration.json").write_text('{"schema": "openadapt.flow.narration/v1", "scrubbed": true, "lines": [{"t_start": "x"}]}') + assert main(["induce", *dirs, "--out", str(tmp_path / "out")]) == 2 + assert "Narration not used" in capsys.readouterr().out From 534265b531e0ef467141215c286c4f12c4610efe Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 08:36:46 -0400 Subject: [PATCH 8/8] fix(flow): lint and artifact inventory for narration Regenerate the public artifact inventory for the changed visualize files, apply ruff format, and fix three mypy errors. Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/__main__.py | 8 +- .../flow/openadapt_flow/adapters/capture.py | 4 +- .../flow/openadapt_flow/compiler/induction.py | 75 +++-- .../compiler/narration_rules.py | 31 +- .../flow/openadapt_flow/compiler/probing.py | 4 +- .../openadapt_flow/learning/branch_teach.py | 22 +- packages/flow/openadapt_flow/narration.py | 4 +- packages/flow/openadapt_flow/repair/cli.py | 4 +- packages/flow/public-artifacts.json | 4 +- packages/flow/tests/test_narration_rules.py | 315 ++++++++++++++---- .../flow/tests/test_teach_branch_narration.py | 21 +- 11 files changed, 374 insertions(+), 118 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 8244a614d..4677586d7 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1942,7 +1942,9 @@ def _induce_narration_args(args: argparse.Namespace) -> Any: return { "narration": narration, "screen_texts": recording_screen_texts(frames) if frames else None, - "interpreter": AnthropicNarrationInterpreter() if args.narration_model else None, + "interpreter": AnthropicNarrationInterpreter() + if args.narration_model + else None, "accepted_narration": accepted or None, } @@ -2027,7 +2029,9 @@ def _cmd_narration_attach(args: argparse.Namespace) -> int: scrubber = require_scrubber() # refuse before any transcription if args.audio: - result = transcribe_audio(args.audio, model=args.model, backend=args.backend) + result = transcribe_audio( + args.audio, model=args.model, backend=args.backend + ) else: result = json.loads(Path(args.transcript).read_text(encoding="utf-8")) path = attach_narration( diff --git a/packages/flow/openadapt_flow/adapters/capture.py b/packages/flow/openadapt_flow/adapters/capture.py index ead1a7b70..55d3d964a 100644 --- a/packages/flow/openadapt_flow/adapters/capture.py +++ b/packages/flow/openadapt_flow/adapters/capture.py @@ -1324,9 +1324,7 @@ def _write_capture_narration( return from openadapt_flow.narration import Narration, align, write_narration - sentences = [ - (u.start - started_at, u.end - started_at, u.text) for u in utterances - ] + sentences = [(u.start - started_at, u.end - started_at, u.text) for u in utterances] write_narration( out_dir, Narration( diff --git a/packages/flow/openadapt_flow/compiler/induction.py b/packages/flow/openadapt_flow/compiler/induction.py index c5d9f83c1..08c33ca17 100644 --- a/packages/flow/openadapt_flow/compiler/induction.py +++ b/packages/flow/openadapt_flow/compiler/induction.py @@ -413,9 +413,11 @@ def render(self) -> str: }.get(ng.certified_by or "", ng.certified_by or "") verdict = f"CERTIFIED ({how})" elif ng.status == "contradicted": - verdict = "REFUSED (recording " + ", ".join( - str(t) for t in ng.contradict - ) + " contradicts it)" + verdict = ( + "REFUSED (recording " + + ", ".join(str(t) for t in ng.contradict) + + " contradicts it)" + ) else: verdict = "proposed, NOT trusted" lines.append( @@ -2000,7 +2002,9 @@ def _narrated_candidate(columns, lo, hi, narration, fields, descriptors, interpr ] if len(about) != 1: continue - cand = nr.candidate_from_rule(rule, trace=t, step_id=line.step_id, fields=fields) + cand = nr.candidate_from_rule( + rule, trace=t, step_id=line.step_id, fields=fields + ) cand.target_arm = about[0] return cand if interpreter is None or len(descriptors) != 1: @@ -2020,7 +2024,7 @@ def _narrated_candidate(columns, lo, hi, narration, fields, descriptors, interpr data = None if not data: continue - cand = nr.candidate_from_model( + answer = nr.candidate_from_model( data, sentence=line.text, trace=t, @@ -2028,9 +2032,9 @@ def _narrated_candidate(columns, lo, hi, narration, fields, descriptors, interpr fields=fields, source=getattr(interpreter, "source", "narration+model"), ) - if cand is not None: - cand.target_arm = 0 - return cand + if answer is not None: + answer.target_arm = 0 + return answer return None @@ -2042,8 +2046,16 @@ def _narrated_candidate(columns, lo, hi, narration, fields, descriptors, interpr def _decide_narrated( - cand, *, location, rule, expected, screen_step, fields, screen_texts, - probe_resolutions, accepted, + cand, + *, + location, + rule, + expected, + screen_step, + fields, + screen_texts, + probe_resolutions, + accepted, ) -> NarratedGuard: from openadapt_flow.compiler.narration_rules import check_against_recordings @@ -2125,14 +2137,11 @@ def _flag_on_uncertainty(result, uncertainty_location, ng, proposal) -> None: u.proposal = proposal if ng.status == "contradicted": u.detail += ( - f" Narration suggested {ng.rule!r}, but {ng.detail}, so it is " - "not used." + f" Narration suggested {ng.rule!r}, but {ng.detail}, so it is not used." ) continue u.detail += f" Narration suggests: {ng.rule} ({ng.detail})." - u.record_next = ( - (u.record_next + " ") if u.record_next else "" - ) + ( + u.record_next = ((u.record_next + " ") if u.record_next else "") + ( "Or accept the narrated rule: induce --accept-narrated " f"{ng.location}@{ng.guard_id} --accepted-by NAME." ) @@ -2203,13 +2212,27 @@ def _branch_node(idx: int, guard: Predicate, arm_steps, target: int) -> _Node: for tr, entry in ((t_rule, entries[target]), (t_else, entries[other])): if entry is None: node.exits.append(tr) - node.states[bid] = State(id=bid, kind=StateKind.BRANCH, transitions=[t_rule, t_else]) + node.states[bid] = State( + id=bid, kind=StateKind.BRANCH, transitions=[t_rule, t_else] + ) return node def _apply_narration( - *, result, columns, decisions, nodes, optional_nodes, divergences, narration, - n, screen_texts, interpreter, probe_resolutions, accepted, guard_sources, + *, + result, + columns, + decisions, + nodes, + optional_nodes, + divergences, + narration, + n, + screen_texts, + interpreter, + probe_resolutions, + accepted, + guard_sources, ) -> None: fields = _param_fields(columns, decisions, n) @@ -2239,7 +2262,9 @@ def _apply_narration( rule=cand.describe(f"run {step0.intent!r}"), expected={t: t in col.tokens for t in range(n)}, screen_step={ - t: col.tokens[t].step.id if t in col.tokens else _next_step_id(columns, idx, t) + t: col.tokens[t].step.id + if t in col.tokens + else _next_step_id(columns, idx, t) for t in range(n) }, fields=fields, @@ -2250,7 +2275,9 @@ def _apply_narration( result.narrated_guards.append(ng) if ng.status == "certified": state.step.guard = Guard(predicate=cand.guard, on_unmet="skip") - result.uncertainties = [u for u in result.uncertainties if u.location != sid] + result.uncertainties = [ + u for u in result.uncertainties if u.location != sid + ] result.inferred = [ line for line in result.inferred @@ -2258,7 +2285,7 @@ def _apply_narration( ] result.inferred.append( f"guard at {sid}: {ng.rule} -- suggested by narration, " - f"certified: {_HOW[ng.certified_by]}" + f"certified: {_HOW.get(ng.certified_by or '', '')}" ) guard_sources[sid] = _guard_source(ng) for dec in decisions: @@ -2329,10 +2356,12 @@ def _apply_narration( result.narrated_guards.append(ng) if ng.status == "certified": nodes.insert(pos, _branch_node(idx, cand.guard, arm_steps, target)) - result.uncertainties = [u for u in result.uncertainties if u.location != where] + result.uncertainties = [ + u for u in result.uncertainties if u.location != where + ] result.inferred.append( f"branch at {location}: {ng.rule} -- suggested by narration, " - f"certified: {_HOW[ng.certified_by]}" + f"certified: {_HOW.get(ng.certified_by or '', '')}" ) guard_sources[f"branch_div_{idx}"] = _guard_source(ng) for dec in decisions: diff --git a/packages/flow/openadapt_flow/compiler/narration_rules.py b/packages/flow/openadapt_flow/compiler/narration_rules.py index 60fc3e8e9..1599aa9d3 100644 --- a/packages/flow/openadapt_flow/compiler/narration_rules.py +++ b/packages/flow/openadapt_flow/compiler/narration_rules.py @@ -60,7 +60,9 @@ r"equals|says|shows|reads|=)\s+(?P.+)$", re.IGNORECASE, ) -_PRONOUN_SUBJECTS = frozenset({"", "it", "this", "that", "they", "he", "she", "its", "it's"}) +_PRONOUN_SUBJECTS = frozenset( + {"", "it", "this", "that", "they", "he", "she", "its", "it's"} +) @dataclass(frozen=True) @@ -106,7 +108,9 @@ def _clean_value(text: str) -> str: _OTHERWISE = re.compile(r",?\s*\b(?:otherwise|or else|else)\b", re.I) -_UNCLEAR_NEGATION = re.compile(r"\b(?:not|no|never|n't|isn't|aren't|won't|don't)\b", re.I) +_UNCLEAR_NEGATION = re.compile( + r"\b(?:not|no|never|n't|isn't|aren't|won't|don't)\b", re.I +) _NOT_NEEDED = re.compile( r"^(?P.+?)\s+(?:is not|isn't|are not|aren't)\s+(?:needed|required|necessary)$" r"|^(?P.+?)\s+(?:is|are)\s+(?:not needed|not required|unnecessary)$", @@ -134,7 +138,9 @@ def _split_action(action: str) -> tuple[str, Optional[bool]]: for verb in _SKIP_VERBS: if low.startswith(verb + " ") or low == verb: rest = a[len(verb) :].strip() - rest = re.sub(r"^(?:need to|have to|bother|fill in|do)\s+", "", rest, flags=re.I) + rest = re.sub( + r"^(?:need to|have to|bother|fill in|do)\s+", "", rest, flags=re.I + ) # "don't skip X": a double negative is too easy to misread. if any(rest.lower().startswith(v + " ") for v in _SKIP_VERBS): return rest, None @@ -238,8 +244,9 @@ class NarrationInterpreter(Protocol): source: str - def interpret(self, sentence: str, context: dict[str, Any]) -> Optional[dict[str, Any]]: - ... + def interpret( + self, sentence: str, context: dict[str, Any] + ) -> Optional[dict[str, Any]]: ... class AnthropicNarrationInterpreter: @@ -257,7 +264,9 @@ def __init__(self, *, client: Any = None, model: Optional[str] = None) -> None: self._client = client self._model = model - def interpret(self, sentence: str, context: dict[str, Any]) -> Optional[dict[str, Any]]: + def interpret( + self, sentence: str, context: dict[str, Any] + ) -> Optional[dict[str, Any]]: import json from openadapt_flow import privacy @@ -289,7 +298,9 @@ def interpret(self, sentence: str, context: dict[str, Any]) -> Optional[dict[str model=model, max_tokens=200, messages=[{"role": "user", "content": prompt}] ) raw = "".join( - getattr(b, "text", "") for b in response.content if getattr(b, "type", "") == "text" + getattr(b, "text", "") + for b in response.content + if getattr(b, "type", "") == "text" ).strip() try: data = json.loads(raw) @@ -429,7 +440,11 @@ def candidate_from_model( if f is None: return None recorded = next( - (v for v in f.values if v is not None and _norm_value(v) == _norm_value(value)), + ( + v + for v in f.values + if v is not None and _norm_value(v) == _norm_value(value) + ), None, ) if recorded is None: diff --git a/packages/flow/openadapt_flow/compiler/probing.py b/packages/flow/openadapt_flow/compiler/probing.py index 9e6b4b8eb..bfe68b08f 100644 --- a/packages/flow/openadapt_flow/compiler/probing.py +++ b/packages/flow/openadapt_flow/compiler/probing.py @@ -539,9 +539,7 @@ def probe_and_induce( scorer = _Scorer(bundle) points = optional_points(workflows) narrated = { - ng.location: ng - for ng in first.narrated_guards - if ng.status == "proposed" + ng.location: ng for ng in first.narrated_guards if ng.status == "proposed" } for u in first.uncertainties: point = points.get(u.location) diff --git a/packages/flow/openadapt_flow/learning/branch_teach.py b/packages/flow/openadapt_flow/learning/branch_teach.py index 896a9a229..7301b8abe 100644 --- a/packages/flow/openadapt_flow/learning/branch_teach.py +++ b/packages/flow/openadapt_flow/learning/branch_teach.py @@ -50,7 +50,7 @@ import json import re from pathlib import Path -from typing import Callable, Optional, Sequence +from typing import Any, Callable, Optional, Sequence from pydantic import BaseModel, Field @@ -599,23 +599,23 @@ def teach_branch( ] text = clean[0] if clean else candidates[0] overlapping = [ - runtime_params_for_gui(r.params) - for r, t in negative_runs - if _contains(t, text) + runtime_params_for_gui(r.params) for r, t in negative_runs if _contains(t, text) + ] + sentences = [line for ex in examples for line in _example_narration(ex, ())] + [ + line.text for nar in narration for line in nar.lines ] - sentences = [ - line for ex in examples for line in _example_narration(ex, ()) - ] + [line.text for nar in narration for line in nar.lines] spoken = _narrated_param( sentences, loaded, base, [runtime_params_for_gui(r.params) for r, _ in negative_runs], ) - spoken_ok = spoken is not None and all( - ex.params.get(spoken[0]) == spoken[1] for ex in loaded - ) and all(n.get(spoken[0]) not in (None, spoken[1]) for n in overlapping) - said = { + spoken_ok = ( + spoken is not None + and all(ex.params.get(spoken[0]) == spoken[1] for ex in loaded) + and all(n.get(spoken[0]) not in (None, spoken[1]) for n in overlapping) + ) + said: dict[str, Any] = { "narration": spoken[2] if spoken else None, "narration_status": ( None if spoken is None else "certified" if spoken_ok else "contradicted" diff --git a/packages/flow/openadapt_flow/narration.py b/packages/flow/openadapt_flow/narration.py index fbc7e3c2b..9936c73d9 100644 --- a/packages/flow/openadapt_flow/narration.py +++ b/packages/flow/openadapt_flow/narration.py @@ -217,7 +217,9 @@ def attach_narration( try: scrubbed = scrubber.scrub_text(str(text)) except Exception as exc: # noqa: BLE001 - not scrubbed means not written - raise NarrationRefused(f"the scrubber failed, nothing written: {exc}") from exc + raise NarrationRefused( + f"the scrubber failed, nothing written: {exc}" + ) from exc clean.append((start, end, scrubbed)) narration = Narration( scrubbed=True, diff --git a/packages/flow/openadapt_flow/repair/cli.py b/packages/flow/openadapt_flow/repair/cli.py index 0e9a56b1b..cff7af507 100644 --- a/packages/flow/openadapt_flow/repair/cli.py +++ b/packages/flow/openadapt_flow/repair/cli.py @@ -153,7 +153,9 @@ def _print_narrated_guards(candidate: RepairCandidate) -> None: for where, source in sorted(workflow.guard_sources.items()): who = f" by {source.accepted_by}" if source.accepted_by else "" print(f" - {where}: {source.rule or 'guard'}") - print(f" said: {source.sentence!r} (certified: {source.certified_by}{who})") + print( + f" said: {source.sentence!r} (certified: {source.certified_by}{who})" + ) def cmd_review(args: argparse.Namespace) -> int: diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index 6a8e5de47..2128da9a9 100644 --- a/packages/flow/public-artifacts.json +++ b/packages/flow/public-artifacts.json @@ -1965,7 +1965,7 @@ }, { "path": "openadapt_flow/visualize/static/program_graph.js", - "sha256": "468a2849dea3027ca170429c320898c9a5406db5c44087706c349f8f9cb6c047" + "sha256": "35ac4a521833d86bfb351d7dcc9deec5a990166389697fb88bea577278290fc9" }, { "path": "public-demo/evidence-packs/mockmed-triage-v1/artifacts/bundle/manifest.json", @@ -6009,7 +6009,7 @@ }, { "path": "schemas/program-graph-v2.json", - "sha256": "bd675b831d87b2f57e619fdff1f405d89465225b6ce193e0e983e256a38054b8" + "sha256": "ea1089dfb385f1bbb74f6ba25253ad3d38f6f7ccb470e526e8bbb6a6e4b60b26" }, { "path": "schemas/public-demo-evidence-v1.json", diff --git a/packages/flow/tests/test_narration_rules.py b/packages/flow/tests/test_narration_rules.py index 35e60cc9a..a7222c06d 100644 --- a/packages/flow/tests/test_narration_rules.py +++ b/packages/flow/tests/test_narration_rules.py @@ -24,14 +24,16 @@ attach_narration, load_narration, ) -from tests.test_replayer import FakeBackend, FakeVision from openadapt_flow.runtime.replayer import Replayer +from tests.test_replayer import FakeBackend, FakeVision RULE = "If the insurance is Medicare, skip the copay screen." def _type(sid: str, field: str, value: str, *, risk: str = "reversible") -> Step: - return Step(id=sid, intent=f"type {field}", action=ActionKind.TYPE, text=value, risk=risk) + return Step( + id=sid, intent=f"type {field}", action=ActionKind.TYPE, text=value, risk=risk + ) def _key(sid: str, key: str, intent: str, *, risk: str = "reversible") -> Step: @@ -40,7 +42,10 @@ def _key(sid: str, key: str, intent: str, *, risk: str = "reversible") -> Step: def visit(payer: str, copay: bool) -> Workflow: """Check in a visit. The copay is a consequential write, done for some payers.""" - steps = [_type("step_000", "patient", "Test Patient"), _type("step_001", "insurance", payer)] + steps = [ + _type("step_000", "patient", "Test Patient"), + _type("step_001", "insurance", payer), + ] if copay: steps.append(_type("step_002", "copay", "20", risk="irreversible")) steps.append(_key("step_003", "Enter", "press Enter to finish")) @@ -48,7 +53,9 @@ def visit(payer: str, copay: bool) -> Workflow: def said(text: str, step_id: str) -> Narration: - return Narration(lines=[NarrationLine(t_start=1.0, t_end=3.0, text=text, step_id=step_id)]) + return Narration( + lines=[NarrationLine(t_start=1.0, t_end=3.0, text=text, step_id=step_id)] + ) def _opt(result): @@ -64,7 +71,9 @@ def _run(workflow, tmp_path, params): bundle = tmp_path / "bundle" bundle.mkdir(exist_ok=True) report = Replayer(backend, vision=FakeVision(), poll_interval_s=0.01).run( - workflow, bundle_dir=bundle, run_dir=tmp_path / f"run-{len(list(tmp_path.iterdir()))}", + workflow, + bundle_dir=bundle, + run_dir=tmp_path / f"run-{len(list(tmp_path.iterdir()))}", params=params, ) return backend, report @@ -77,17 +86,36 @@ def _run(workflow, tmp_path, params): "sentence,value,skip,runs", [ (RULE, "Medicare", True, False), - ("Skip the copay screen when the insurance is Medicare.", "Medicare", True, False), + ( + "Skip the copay screen when the insurance is Medicare.", + "Medicare", + True, + False, + ), ("Since it's Medicare, I don't fill in the copay.", "Medicare", True, False), - ("If the insurance is not Medicare, enter the copay.", "Medicare", False, False), + ( + "If the insurance is not Medicare, enter the copay.", + "Medicare", + False, + False, + ), ("Unless the payer is Aetna, leave the referral blank.", "Aetna", True, True), - ("If it says Prior Auth Required, click Request Auth.", "Prior Auth Required", False, True), + ( + "If it says Prior Auth Required, click Request Auth.", + "Prior Auth Required", + False, + True, + ), ], ) def test_sentences_parse_into_condition_and_action(sentence, value, skip, runs): rule = parse_rule(sentence) assert rule is not None - assert (rule.value, rule.skip, rule.runs_when_condition_holds) == (value, skip, runs) + assert (rule.value, rule.skip, rule.runs_when_condition_holds) == ( + value, + skip, + runs, + ) def test_plain_narration_is_not_a_rule(): @@ -103,7 +131,13 @@ def test_a_narrated_branch_becomes_a_proposed_guard(): element. Nothing checked it, so it is a flagged proposal and the point stays refused.""" traces = [ - Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _key("step_003", "Enter", "finish"), + ], + ), Workflow( name="c", steps=[ @@ -185,14 +219,18 @@ def interpret(self, sentence, context): traces = [visit("Medicare", False), visit("Aetna", True)] result = induce_program( - traces, narration=[said(RULE, "step_003"), None], interpreter=CountingInterpreter() + traces, + narration=[said(RULE, "step_003"), None], + interpreter=CountingInterpreter(), ) assert result.certified # The parser read the sentence, so the compile-time model was not asked. assert CountingInterpreter.calls == 0 pname = result.narrated_guards[0].guard.operands[0].param for payer in ("Medicare", "Aetna"): - _, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: payer}) + _, report = _run( + result.workflow, tmp_path, {**result.workflow.params, pname: payer} + ) assert report.success assert report.model_calls == 0 assert report.est_model_cost_usd == 0.0 @@ -202,7 +240,13 @@ def test_the_transcript_is_scrubbed_before_it_is_stored(tmp_path): rec = tmp_path / "rec" rec.mkdir() (rec / "events.jsonl").write_text( - "\n".join(json.dumps(e) for e in [{"i": 0, "kind": "type", "t": 1.0}, {"i": 1, "kind": "key", "t": 4.0}]) + "\n".join( + json.dumps(e) + for e in [ + {"i": 0, "kind": "type", "t": 1.0}, + {"i": 1, "kind": "key", "t": 4.0}, + ] + ) + "\n" ) @@ -241,7 +285,13 @@ def scrub_text(self, text, is_separated=False): def test_a_person_can_accept_a_guard_nothing_contradicts(): traces = [ - Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _key("step_003", "Enter", "finish"), + ], + ), Workflow( name="c", steps=[ @@ -255,21 +305,35 @@ def test_a_person_can_accept_a_guard_nothing_contradicts(): pinned = f"opt_1@{first.narrated_guards[0].guard_id}" # A bare location does not accept anything: the id pins the exact guard. bare = induce_program( - traces, narration=[said(RULE, "step_003"), None], accepted_narration={"opt_1": "R. Reviewer"} + traces, + narration=[said(RULE, "step_003"), None], + accepted_narration={"opt_1": "R. Reviewer"}, ) assert not bare.certified result = induce_program( - traces, narration=[said(RULE, "step_003"), None], accepted_narration={pinned: "R. Reviewer"} + traces, + narration=[said(RULE, "step_003"), None], + accepted_narration={pinned: "R. Reviewer"}, ) (ng,) = result.narrated_guards - assert (ng.status, ng.certified_by, ng.accepted_by) == ("certified", "person", "R. Reviewer") + assert (ng.status, ng.certified_by, ng.accepted_by) == ( + "certified", + "person", + "R. Reviewer", + ) assert result.certified assert result.workflow.guard_sources["opt_1"].accepted_by == "R. Reviewer" def test_a_screen_element_guard_certifies_from_the_recorded_screens(): traces = [ - Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _key("step_003", "Enter", "finish"), + ], + ), Workflow( name="c", steps=[ @@ -295,11 +359,17 @@ def trace(payer: str) -> Workflow: decide = ( _key("step_002", "M", "press M to bill medicare", risk="irreversible") if payer == "Medicare" - else _key("step_002", "C", "press C to collect the copay", risk="irreversible") + else _key( + "step_002", "C", "press C to collect the copay", risk="irreversible" + ) ) return Workflow( name="bill", - steps=[_type("step_001", "insurance", payer), decide, _key("step_003", "Enter", "finish")], + steps=[ + _type("step_001", "insurance", payer), + decide, + _key("step_003", "Enter", "finish"), + ], ) plain = induce_program([trace("Medicare"), trace("Aetna")]) @@ -313,19 +383,29 @@ def trace(payer: str) -> Workflow: (ng,) = result.narrated_guards assert ng.location.startswith("divergence_") and ng.status == "certified" assert result.certified, result.render() - branch = next(s for s in result.program.states.values() if s.kind is StateKind.BRANCH) + branch = next( + s for s in result.program.states.values() if s.kind is StateKind.BRANCH + ) assert branch.id in result.workflow.guard_sources pname = ng.guard.param - backend, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: "Medicare"}) + backend, report = _run( + result.workflow, tmp_path, {**result.workflow.params, pname: "Medicare"} + ) assert report.success and ("press", "M") in backend.actions - backend, report = _run(result.workflow, tmp_path, {**result.workflow.params, pname: "Humana"}) + backend, report = _run( + result.workflow, tmp_path, {**result.workflow.params, pname: "Humana"} + ) assert report.success and ("press", "C") in backend.actions def test_a_sentence_about_another_step_is_not_used(): traces = [visit("Medicare", False), visit("Aetna", True)] result = induce_program( - traces, narration=[said("If the insurance is Medicare, skip the referral.", "step_003"), None] + traces, + narration=[ + said("If the insurance is Medicare, skip the referral.", "step_003"), + None, + ], ) assert result.narrated_guards == [] assert not result.certified @@ -352,7 +432,9 @@ def recording(name: str, wf: Workflow, sentences) -> Path: {"i": int(s.id.split("_")[1]), "kind": s.action.value, "t": 2.0 * k} for k, s in enumerate(wf.steps) ] - (rec / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n") + (rec / "events.jsonl").write_text( + "\n".join(json.dumps(e) for e in events) + "\n" + ) if sentences: attach_narration(rec, sentences, scrubber=Scrubber()) return rec @@ -366,7 +448,11 @@ def recording(name: str, wf: Workflow, sentences) -> Path: assert narration[0].lines[0].step_id == "step_003" result = induce_program(traces, narration=narration) (ng,) = result.narrated_guards - assert (ng.status, ng.certified_by, ng.location) == ("certified", "recordings_agree", "opt_2") + assert (ng.status, ng.certified_by, ng.location) == ( + "certified", + "recordings_agree", + "opt_2", + ) def test_alignment_attaches_a_sentence_to_the_next_action(): @@ -384,7 +470,10 @@ def test_capture_narration_is_carried_into_the_recording(tmp_path): narration=lambda: [SimpleNamespace(start=103.0, end=104.0, text=RULE)] ) _write_capture_narration( - session, tmp_path, 100.0, [{"i": 0, "t": 1.0, "kind": "type"}, {"i": 1, "t": 3.5, "kind": "key"}] + session, + tmp_path, + 100.0, + [{"i": 0, "t": 1.0, "kind": "type"}, {"i": 1, "t": 3.5, "kind": "key"}], ) nar = load_narration(tmp_path) assert nar.source == "openadapt-capture" @@ -427,10 +516,17 @@ def scrub_text(self, text, is_separated=False): traces = [visit("Medicare", False), visit("Aetna", True)] rec = tmp_path / "rec" rec.mkdir() - events = [{"i": int(s.id.split("_")[1]), "kind": "x", "t": 3.0 * k} for k, s in enumerate(traces[0].steps)] + events = [ + {"i": int(s.id.split("_")[1]), "kind": "x", "t": 3.0 * k} + for k, s in enumerate(traces[0].steps) + ] (rec / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n") - result = transcribe_audio(FIXTURE, model=str(Path(cached).parent), backend="faster-whisper") - attach_narration(rec, sentences_from_transcript(result, offset=1.5), scrubber=Scrubber()) + result = transcribe_audio( + FIXTURE, model=str(Path(cached).parent), backend="faster-whisper" + ) + attach_narration( + rec, sentences_from_transcript(result, offset=1.5), scrubber=Scrubber() + ) assert "john smith" not in (rec / "narration.json").read_text().lower() induced = induce_program(traces, narration=[load_narration(rec), None]) (ng,) = induced.narrated_guards @@ -440,7 +536,9 @@ def scrub_text(self, text, is_separated=False): # -- probing on a test copy ----------------------------------------------------- -def test_probing_confirms_a_narrated_guard_the_recordings_cannot_check(tmp_path, monkeypatch): +def test_probing_confirms_a_narrated_guard_the_recordings_cannot_check( + tmp_path, monkeypatch +): """Coverage shows on screen and is never typed, so the recordings cannot check "skip the copay when it's Medicare". The scripted test copy can: the narrated reading produces the recorded effect in both contexts and @@ -472,13 +570,17 @@ def reset(self): def run(self, workflow, *, bundle_dir, run_dir, params): patient = params["patient_id"] backend = FakeBackend() - report = Replayer(backend, vision=ScreenVision(patient), poll_interval_s=0.01).run( - workflow, bundle_dir=bundle_dir, run_dir=run_dir, params=params - ) + report = Replayer( + backend, vision=ScreenVision(patient), poll_interval_s=0.01 + ).run(workflow, bundle_dir=bundle_dir, run_dir=run_dir, params=params) copay = ("type", "20") in backend.actions # The recorded effect: a copay exactly when coverage is not Medicare. - self.last[patient] = report.success and copay == (coverage[patient] != "Medicare") - return probing.RunSignal(probing.runtime_signal_of(report), report.model_calls) + self.last[patient] = report.success and copay == ( + coverage[patient] != "Medicare" + ) + return probing.RunSignal( + probing.runtime_signal_of(report), report.model_calls + ) target = TestCopy() @@ -492,10 +594,13 @@ def baseline(self, identity): def judge(self, identity, signal, before): ok = target.last.get(identity["patient_id"], False) return SimpleNamespace( - outcome=SimpleNamespace(value="verified" if ok else "wrong_effect"), reason="" + outcome=SimpleNamespace(value="verified" if ok else "wrong_effect"), + reason="", ) - bundle = SimpleNamespace(identity_keys=("patient_id",), check_identity=lambda i: None) + bundle = SimpleNamespace( + identity_keys=("patient_id",), check_identity=lambda i: None + ) monkeypatch.setattr(probing, "_Scorer", Scorer) monkeypatch.setattr(RewardBundle, "load", classmethod(lambda cls, x: x)) @@ -511,7 +616,9 @@ def recording(patient, copay): ) traces = [recording("p-med", False), recording("p-aet", True)] - plain = induce_program([t.workflow for t in traces], narration=[said(RULE, "step_003"), None]) + plain = induce_program( + [t.workflow for t in traces], narration=[said(RULE, "step_003"), None] + ) assert plain.narrated_guards[0].status == "proposed" and not plain.certified result = probing.probe_and_induce( @@ -536,7 +643,13 @@ def recording(patient, copay): def _screen_only_traces(): return [ - Workflow(name="c", steps=[_type("step_000", "patient", "A"), _key("step_003", "Enter", "finish")]), + Workflow( + name="c", + steps=[ + _type("step_000", "patient", "A"), + _key("step_003", "Enter", "finish"), + ], + ), Workflow( name="c", steps=[ @@ -570,12 +683,30 @@ def test_cli_induce_accepts_a_narrated_guard_only_with_a_name(tmp_path, capsys): assert main(["induce", *dirs, "--out", str(out), "--accept-narrated", pinned]) == 2 assert "--accepted-by" in capsys.readouterr().out rc = main( - ["induce", *dirs, "--out", str(out), "--accept-narrated", "opt_1", "--accepted-by", "R. Reviewer"] + [ + "induce", + *dirs, + "--out", + str(out), + "--accept-narrated", + "opt_1", + "--accepted-by", + "R. Reviewer", + ] ) assert rc == 2 and "LOCATION@GUARD_ID" in capsys.readouterr().out rc = main( - ["induce", *dirs, "--out", str(out), "--accept-narrated", pinned, "--accepted-by", "R. Reviewer"] + [ + "induce", + *dirs, + "--out", + str(out), + "--accept-narrated", + pinned, + "--accepted-by", + "R. Reviewer", + ] ) assert rc == 0, capsys.readouterr().out written = Workflow.load(out) @@ -589,10 +720,22 @@ def test_cli_narration_attach_scrubs_a_transcript(tmp_path, capsys): rec = tmp_path / "rec" rec.mkdir() - (rec / "events.jsonl").write_text(json.dumps({"i": 0, "kind": "type", "t": 2.0}) + "\n") + (rec / "events.jsonl").write_text( + json.dumps({"i": 0, "kind": "type", "t": 2.0}) + "\n" + ) transcript = tmp_path / "t.json" transcript.write_text( - json.dumps({"segments": [{"start": 0.5, "end": 1.5, "text": "Patient John Smith is Medicare."}]}) + json.dumps( + { + "segments": [ + { + "start": 0.5, + "end": 1.5, + "text": "Patient John Smith is Medicare.", + } + ] + } + ) ) class Scrubber: @@ -601,10 +744,16 @@ def scrub_text(self, text, is_separated=False): privacy.set_text_scrubber(None) try: - assert main(["narration", "attach", str(rec), "--transcript", str(transcript)]) == 2 + assert ( + main(["narration", "attach", str(rec), "--transcript", str(transcript)]) + == 2 + ) assert not (rec / "narration.json").exists() privacy.set_text_scrubber(Scrubber()) - assert main(["narration", "attach", str(rec), "--transcript", str(transcript)]) == 0 + assert ( + main(["narration", "attach", str(rec), "--transcript", str(transcript)]) + == 0 + ) finally: privacy.reset_scrubbers() out = capsys.readouterr().out @@ -634,7 +783,9 @@ def test_visualize_shows_each_guard_with_the_sentence_that_suggested_it(tmp_path assert "narrated rule" in node.badges assert "insurance" in node.guard and node.guard_on_unmet == "skip" assert RULE in render_html(spec) - assert "narrated rule" in render_mermaid(spec) and "because:" in render_mermaid(spec) + assert "narrated rule" in render_mermaid(spec) and "because:" in render_mermaid( + spec + ) # Off this machine the sentence is recorded text, so it is dropped. for profile in ("remote-safe", "public-synthetic"): @@ -651,7 +802,9 @@ def trace(payer: str) -> Workflow: decide = ( _key("step_002", "M", "press M to bill medicare", risk="irreversible") if payer == "Medicare" - else _key("step_002", "C", "press C to collect the copay", risk="irreversible") + else _key( + "step_002", "C", "press C to collect the copay", risk="irreversible" + ) ) return Workflow(name="b", steps=[_type("step_001", "insurance", payer), decide]) @@ -681,7 +834,7 @@ def refuse(): def test_recorded_values_are_compared_exactly_like_the_runtime(): - """"medicare" and "Medicare" are different run-time values, so a recording + """ "medicare" and "Medicare" are different run-time values, so a recording that typed one must not vouch for the other.""" traces = [visit("Medicare", False), visit("medicare", False), visit("Aetna", True)] result = induce_program(traces, narration=[said(RULE, "step_003"), None, None]) @@ -692,7 +845,9 @@ def test_recorded_values_are_compared_exactly_like_the_runtime(): def test_a_divergence_with_a_step_inside_it_stays_refused(): def trace(steps): - return Workflow(name="d", steps=[_type("step_000", "insurance", steps[0])] + steps[1]) + return Workflow( + name="d", steps=[_type("step_000", "insurance", steps[0])] + steps[1] + ) a = _key("step_001", "B", "press B for beta", risk="irreversible") z = _key("step_003", "Z", "press Z for zeta", risk="irreversible") @@ -728,19 +883,57 @@ def test_an_unclear_double_negative_is_not_a_rule(): def test_model_answers_are_validated(): - from openadapt_flow.compiler.narration_rules import FieldValues, candidate_from_model + from openadapt_flow.compiler.narration_rules import ( + FieldValues, + candidate_from_model, + ) - fields = [FieldValues(param="insurance", label="insurance", values=["Medicare", "Aetna"])] - kw = dict(sentence="Medicare ones go straight through", trace=0, step_id=None, fields=fields, source="m") + fields = [ + FieldValues(param="insurance", label="insurance", values=["Medicare", "Aetna"]) + ] + kw = dict( + sentence="Medicare ones go straight through", + trace=0, + step_id=None, + fields=fields, + source="m", + ) # A string "false" is not a boolean; the answer is dropped. - assert candidate_from_model({"field": "insurance", "value": "Medicare", "step_runs_when_true": "false"}, **kw) is None + assert ( + candidate_from_model( + {"field": "insurance", "value": "Medicare", "step_runs_when_true": "false"}, + **kw, + ) + is None + ) # A field that was not offered, or a value no recording had, is dropped. - assert candidate_from_model({"field": "payer", "value": "Medicare", "step_runs_when_true": False}, **kw) is None - assert candidate_from_model({"field": "insurance", "value": "Cigna", "step_runs_when_true": False}, **kw) is None + assert ( + candidate_from_model( + {"field": "payer", "value": "Medicare", "step_runs_when_true": False}, **kw + ) + is None + ) + assert ( + candidate_from_model( + {"field": "insurance", "value": "Cigna", "step_runs_when_true": False}, **kw + ) + is None + ) # Screen text must be words the person said. - assert candidate_from_model({"text": "Prior auth", "value": "x", "step_runs_when_true": True}, **kw) is None - ok = candidate_from_model({"field": "insurance", "value": "medicare", "step_runs_when_true": False}, **kw) - assert ok is not None and ok.condition.value == "Medicare" and not ok.runs_when_condition_holds + assert ( + candidate_from_model( + {"text": "Prior auth", "value": "x", "step_runs_when_true": True}, **kw + ) + is None + ) + ok = candidate_from_model( + {"field": "insurance", "value": "medicare", "step_runs_when_true": False}, **kw + ) + assert ( + ok is not None + and ok.condition.value == "Medicare" + and not ok.runs_when_condition_holds + ) def test_a_malformed_narration_file_is_skipped_by_the_cli(tmp_path, capsys): @@ -751,6 +944,8 @@ def test_a_malformed_narration_file_is_skipped_by_the_cli(tmp_path, capsys): d = tmp_path / f"b{i}" wf.save(d) dirs.append(str(d)) - (Path(dirs[0]) / "narration.json").write_text('{"schema": "openadapt.flow.narration/v1", "scrubbed": true, "lines": [{"t_start": "x"}]}') + (Path(dirs[0]) / "narration.json").write_text( + '{"schema": "openadapt.flow.narration/v1", "scrubbed": true, "lines": [{"t_start": "x"}]}' + ) assert main(["induce", *dirs, "--out", str(tmp_path / "out")]) == 2 assert "Narration not used" in capsys.readouterr().out diff --git a/packages/flow/tests/test_teach_branch_narration.py b/packages/flow/tests/test_teach_branch_narration.py index bbc4a36d7..dd08dd9c4 100644 --- a/packages/flow/tests/test_teach_branch_narration.py +++ b/packages/flow/tests/test_teach_branch_narration.py @@ -62,9 +62,16 @@ def _setup(tmp_path, sentence): r1, Narration(lines=[NarrationLine(t_start=0.0, t_end=2.0, text=sentence)]) ) normal_dir = tmp_path / "normal" - report, _ = _replay(wf, bundle, normal_dir, text=AETNA, blocking=False, params=normal) + report, _ = _replay( + wf, bundle, normal_dir, text=AETNA, blocking=False, params=normal + ) assert report.success - return wf, bundle, [BranchExample(run_dir=r1, fix=fix), BranchExample(run_dir=r2, fix=fix)], normal_dir + return ( + wf, + bundle, + [BranchExample(run_dir=r1, fix=fix), BranchExample(run_dir=r2, fix=fix)], + normal_dir, + ) def test_without_narration_two_separating_parameters_are_refused(tmp_path): @@ -91,14 +98,20 @@ def test_narration_picks_the_parameter_when_every_run_agrees(tmp_path): screen_texts=_normal_texts({normal_dir: [AETNA]}), ) assert result.promoted, result.summary() - assert (result.signature.param, result.signature.value) == ("payer_type", "medicaid") + assert (result.signature.param, result.signature.value) == ( + "payer_type", + "medicaid", + ) assert (result.narration, result.narration_status) == (RULE, "certified") assert "narration:" in result.summary() learned = Workflow.load(out) (source,) = learned.guard_sources.values() assert source.sentence == RULE and source.certified_by == "recordings_agree" report, backend = _replay( - learned, out, tmp_path / "m", text=HUMANA, + learned, + out, + tmp_path / "m", + text=HUMANA, params={"payer_type": "medicaid", "plan_tier": "gold"}, ) assert report.success and _dismissed(backend)