Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
749ceef
feat(quickstart): --app openemr runs the three acts on a real OpenEMR
abrichr Oct 20, 2026
2472d58
Merge remote-tracking branch 'origin/main' into feat/quickstart-openemr
abrichr Oct 20, 2026
286b7f2
docs: CAPABILITIES row for quickstart --app openemr
abrichr Oct 20, 2026
8cfc157
quickstart --app openemr: its own Act 3 prompt; timing script and VHS…
abrichr Oct 20, 2026
8d3f13f
quickstart: every finale links the published Seals; OpenEMR ports fro…
abrichr Oct 20, 2026
a4254fe
flow: register the committed OpenEMR referral program in the public a…
abrichr Oct 20, 2026
b95d5d2
Merge remote-tracking branch 'origin/main' into feat/quickstart-openemr
abrichr Oct 20, 2026
a3fe680
Merge remote-tracking branch 'origin/main' into feat/quickstart-openemr
abrichr Oct 25, 2026
39168b8
quickstart --app openemr: load the fixture driver from its file in th…
abrichr Oct 25, 2026
c4f14cb
timing script: stop at the first failed run unless --keep-going
abrichr Oct 25, 2026
5f0cf48
Merge main into feat/quickstart-openemr
abrichr Nov 4, 2026
fb39edd
Merge remote-tracking branch 'origin/main' into HEAD
abrichr Nov 8, 2026
cb1f7f8
Merge remote-tracking branch 'origin/main' into HEAD
abrichr Nov 9, 2026
062c46d
quickstart: describe Act 3's stop only when its own evidence shows th…
abrichr Nov 9, 2026
4fd90c1
Merge remote-tracking branch 'origin/main' into fix-1431-local
abrichr Nov 9, 2026
24925fe
quickstart: the timing script's parser rejects abbreviated options
abrichr Nov 9, 2026
288e0f1
Merge remote-tracking branch 'origin/main' into HEAD
abrichr Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ improvising or retrying when something is off.
| Capability | Status | Evidence | Where |
| --- | --- | --- | --- |
| Compile one or more demonstrations into a deterministic program | Released | Real app (OpenEMR referral, 62 timed runs) | `openadapt-flow record`, `compile`; [packages/flow](packages/flow/README.md) |
| Try the three acts on a real app: `openadapt quickstart --app openemr` runs the certified OpenEMR referral program in Docker, five referrals at once checked in OpenEMR's record, then a possible duplicate stopped before saving; without Docker or a clone it says so and exits 2 | On main ([#1431](https://github.com/OpenAdaptAI/OpenAdapt/pull/1431)) | Real app (local OpenEMR 8.0.0.3, synthetic patients) | `openadapt quickstart --app openemr`; [docs/quickstart](docs/quickstart/README.md#on-a-real-app---app-openemr) |
| Classify common local edits without a person (select-all, Enter in a field or dropdown, an in-form tab, an informational dialog's dismiss), only when the recording's network capture shows no write and a runtime check confirms only the expected change | Released ([#1366](https://github.com/OpenAdaptAI/OpenAdapt/pull/1366)) | Synthetic; OpenEMR referral recording re-admitted offline (violations 12 to 1 under `permissive`) | [LOCAL_EDIT_CLASSIFICATION.md](packages/flow/docs/design/LOCAL_EDIT_CLASSIFICATION.md), `compiler/local_edit.py` |
| Keep the empty search result a person confirmed through (a duplicate check's "No matches were found.") as an exact check after the click that opens the dialog and again right before Confirm, so a run whose dialog lists a match stops before the save; only fixed-vocabulary search-result statements shown once, never text with a name or search term, and a confirmation dialog without one is noted in `compile_notes.json` | On main | Real app frames (an OpenEMR referral recording, compiled offline) | `compiler/empty_result_guard.py` |
| Mine that check in any language: induction keeps the one line a named dialog adds (not its title or buttons, not a question, no typed value or number) when two or more demonstrations whose typed values all differ show the same line, refuses when the demonstrations disagree on a mined check instead of copying the first one's, and qualification then needs a business-rule run that halts at it | On main | Synthetic (a search dialog recorded in Chromium by the real recorder, German statement, real OCR) | `compiler/empty_result_guard.py`, `compiler/induction.py` |
Expand Down
55 changes: 55 additions & 0 deletions docs/quickstart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,58 @@
- `timing-2026-10-20.json`: the engine tutorial with `--break-it` (what
`openadapt quickstart --break-it` ran before) against the new quickstart,
three rounds each, interleaved, from `scripts/quickstart_timing.py`.

## On a real app: `--app openemr`

```bash
openadapt quickstart --app openemr
```

The same three acts on OpenEMR 8.0.0.3, a real open-source EHR, instead of the
bundled MockMed. The default stays MockMed: it needs no Docker and finishes in
about 22 seconds.

- **Needs** Docker (Docker Desktop, or Podman with `DOCKER_HOST` pointed at
its socket) and a clone of this repository, because the OpenEMR fixture is
repository-only:

```bash
git clone https://github.com/OpenAdaptAI/OpenAdapt && cd OpenAdapt
pip install -e . -e packages/flow
```

Without either, the command says which one is missing in one line and exits
with status 2. It never falls back to MockMed.
- **Setup.** The pinned fixture from `packages/flow/benchmark/openemr_local/`:
pinned image digests, verified source files, a hashed SQL baseline, loopback
ports 9741 (HTTP) and 9740 (HTTPS) (set `OPENADAPT_QUICKSTART_OPENEMR_HTTP_PORT`
and `OPENADAPT_QUICKSTART_OPENEMR_HTTPS_PORT` to move them), and its own Compose project
(`openadapt-quickstart-openemr`). Its state stays in
`~/.cache/openadapt/quickstart-openemr`. The first run downloads the images
if they're missing (about 2.6 GB unpacked), installs OpenEMR, and adds
synthetic API clients. Later runs reuse the running copy and restore its
baseline.
- **Act 1, show it once.** The referral as it was demonstrated once in
OpenEMR: the recorded actions of the certified referral program, the same
program behind the site's OpenEMR Seals
(`benchmark/openemr_local/referral_program/`). It was compiled with the
shared duplicate rule (`apply_referral_rules`) and qualified under
`clinical-write`.
- **Act 2, run it.** Five synthetic referrals at once, each on its own clerk
account and browser. Each runs through the certified run path (the Standard
run gate, then the REST record check) and is read back from OpenEMR's
database with SQL: "✓ checked in OpenEMR's record".
- **Act 3, break it.** The quickstart adds an existing chart (Rosa Delgado,
born 1958-03-27), then runs a referral for a Rosa Delgado born 1958-03-22.
OpenEMR's duplicate check lists the match, and the program stops before
saving ("Stopped before saving: possible duplicate"). SQL finds 0 new charts.
- **Finale.** The same receipt card and Seal. Every quickstart, in either app,
ends the receipt with "See real Seals from OpenEMR runs:
https://openadapt.ai/seals".

To stop the fixture, run
`docker compose -p openadapt-quickstart-openemr stop`. The next quickstart
starts it again.

The real run is covered by `test_real_openemr_quickstart` (opt in with
`OPENADAPT_REAL_QUICKSTART_OPENEMR=1` on a machine with Docker); CI skips it.
35 changes: 35 additions & 0 deletions docs/quickstart/quickstart-openemr.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Records docs/quickstart/quickstart-openemr.gif with VHS (https://github.com/charmbracelet/vhs, MIT).
#
# PATH="$PWD/.venv/bin:$PATH" vhs docs/quickstart/quickstart-openemr.tape
#
# Run it from a clone with Docker running (with Podman, export DOCKER_HOST
# first; the recording's shell inherits it). The fixture from an earlier run
# is reused, so this records a warm run. The browsers stay hidden (no display
# in the recording). Everything else is the real command, start to finish, at
# real speed.

Output docs/quickstart/quickstart-openemr.gif

Set Shell bash
Set FontSize 15
Set Width 1240
Set Height 1100
Set Padding 24
Set Framerate 12
Set TypingSpeed 45ms
Set Theme "Builtin Dark"

Hide
Type "unset DISPLAY WAYLAND_DISPLAY; export SSH_CONNECTION=recording BROWSER=true OPENADAPT_FLOW_SCRUB=off OPENADAPT_SEAL_HOME=$(mktemp -d) PS1='$ '; cd $(mktemp -d) && clear"
Enter
Show

Sleep 500ms
Type "openadapt quickstart --app openemr"
Sleep 400ms
Enter
Wait+Screen@600s /Press Enter/
Sleep 2s
Enter
Wait+Screen@300s /Give your agent/
Sleep 8s
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ improvising or retrying when something is off.
| Capability | Status | Evidence | Where |
| --- | --- | --- | --- |
| Compile one or more demonstrations into a deterministic program | Released | Real app (OpenEMR referral, 62 timed runs) | `openadapt-flow record`, `compile`; [packages/flow](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/README.md) |
| Try the three acts on a real app: `openadapt quickstart --app openemr` runs the certified OpenEMR referral program in Docker, five referrals at once checked in OpenEMR's record, then a possible duplicate stopped before saving; without Docker or a clone it says so and exits 2 | On main ([#1431](https://github.com/OpenAdaptAI/OpenAdapt/pull/1431)) | Real app (local OpenEMR 8.0.0.3, synthetic patients) | `openadapt quickstart --app openemr`; [docs/quickstart](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/docs/quickstart/README.md#on-a-real-app---app-openemr) |
| Classify common local edits without a person (select-all, Enter in a field or dropdown, an in-form tab, an informational dialog's dismiss), only when the recording's network capture shows no write and a runtime check confirms only the expected change | Released ([#1366](https://github.com/OpenAdaptAI/OpenAdapt/pull/1366)) | Synthetic; OpenEMR referral recording re-admitted offline (violations 12 to 1 under `permissive`) | [LOCAL_EDIT_CLASSIFICATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/LOCAL_EDIT_CLASSIFICATION.md), `compiler/local_edit.py` |
| Keep the empty search result a person confirmed through (a duplicate check's "No matches were found.") as an exact check after the click that opens the dialog and again right before Confirm, so a run whose dialog lists a match stops before the save; only fixed-vocabulary search-result statements shown once, never text with a name or search term, and a confirmation dialog without one is noted in `compile_notes.json` | On main | Real app frames (an OpenEMR referral recording, compiled offline) | `compiler/empty_result_guard.py` |
| Mine that check in any language: induction keeps the one line a named dialog adds (not its title or buttons, not a question, no typed value or number) when two or more demonstrations whose typed values all differ show the same line, refuses when the demonstrations disagree on a mined check instead of copying the first one's, and qualification then needs a business-rule run that halts at it | On main | Synthetic (a search dialog recorded in Chromium by the real recorder, German statement, real OCR) | `compiler/empty_result_guard.py`, `compiler/induction.py` |
Expand Down
56 changes: 53 additions & 3 deletions openadapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ def _is_externally_managed_error(error: BaseException) -> bool:
_QUICKSTART_OPTIONS = frozenset(
{
"--out",
"--app",
"--headless",
"--no-break",
"--verbose",
Expand Down Expand Up @@ -654,6 +655,17 @@ def _quickstart_root(out: Optional[Path]) -> Path:
f"{_DEFAULT_QUICKSTART_DIR}, suffixed -2, -3, ... when taken."
),
)
@click.option(
"--app",
type=click.Choice(["mockmed", "openemr"]),
default="mockmed",
show_default=True,
help=(
"The app to run it in. mockmed is bundled (about 22 seconds, no Docker). "
"openemr runs OpenEMR 8.0.0.3 in Docker from a clone of the repository; "
"the first run downloads and installs it, later runs reuse it."
),
)
@click.option(
"--headless",
is_flag=True,
Expand All @@ -676,6 +688,7 @@ def _quickstart_root(out: Optional[Path]) -> Path:
def quickstart(
command_ctx: click.Context,
out: Optional[Path],
app: str,
headless: bool,
no_break: bool,
verbose: bool,
Expand All @@ -690,14 +703,21 @@ def quickstart(
screen says saved, the database has nothing, and OpenAdapt stops. The
program makes no AI calls. Nothing in the output directory is overwritten.

With --app openemr, the same three acts run on OpenEMR 8.0.0.3 in Docker,
with the certified referral program: referrals registered at once and
checked in OpenEMR's record, then a referral that matches an existing
chart, where the program stops before saving. It runs from a clone of the
repository and needs Docker; without either it says so and exits 2.

Other flags of the engine tutorial (for example --guided or
--interactive-record) run that tutorial instead, unchanged.
"""
_require_supported_python()
_refuse_unknown_tutorial_options(command_ctx.args)
openemr = _openemr_checkout(command_ctx.args) if app == "openemr" else None
root = _quickstart_root(out)

if command_ctx.args or not _flow_runs_quickstart():
if openemr is None and (command_ctx.args or not _flow_runs_quickstart()):
# Engine-owned flags (--guided, --interactive-record, and future
# engine additions) run the engine's own tutorial, as before. So does
# a Flow older than the pieces the three-act quickstart uses.
Expand All @@ -711,7 +731,7 @@ def quickstart(
steps = next_steps(
_hint,
_shell_quote,
_agent_serves_tutorial(),
openemr is None and _agent_serves_tutorial(),
keep=_keep_command(),
shadow=_shadowing_openadapt if _under_uvx() else None,
)
Expand All @@ -723,6 +743,7 @@ def quickstart(
break_it=not no_break,
verbose=verbose,
steps=steps,
openemr=openemr,
)
except KeyboardInterrupt:
click.echo(f"\nStopped. Anything written so far is in {root}", err=True)
Expand All @@ -731,7 +752,11 @@ def quickstart(
if _is_externally_managed_error(error):
_echo_python_remedy()
raise click.exceptions.Exit(1) from error
if type(error).__name__ in ("TutorialError", "DurableAuthorityBusy"):
if type(error).__name__ in (
"TutorialError",
"DurableAuthorityBusy",
"FixtureError",
):
raise click.ClickException(
f"The quickstart stopped: {error}\nAnything it wrote is in {root}"
) from error
Expand All @@ -742,6 +767,31 @@ def quickstart(
raise click.exceptions.Exit(1)


def _openemr_checkout(extra: list[str]) -> Path:
"""The clone's packages/flow for ``--app openemr``, or exit 2 saying why.

Each missing piece is one line on stderr. Nothing falls back to MockMed.
"""
from openadapt.quickstart.openemr import (
checkout_problem,
docker_problem,
find_flow_checkout,
)

if extra:
raise click.UsageError(
f"--app openemr runs the three-act quickstart only; {extra[0]} "
"belongs to the engine's MockMed tutorial."
)
problem = docker_problem()
flow = find_flow_checkout() if problem is None else None
problem = problem or checkout_problem(flow)
if problem is not None or flow is None:
click.echo(problem, err=True)
raise click.exceptions.Exit(2)
return flow


# The oldest Flow with every piece the three-act quickstart uses (shared
# browser, record checks bound to run inputs, the side-by-side page).
_QUICKSTART_FLOW_MINIMUM = (2, 2, 0)
Expand Down
24 changes: 18 additions & 6 deletions openadapt/quickstart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

import sys
from pathlib import Path
from typing import IO, Callable, Optional
from typing import IO, Any, Callable, Optional

from openadapt.quickstart.scene import MOCKMED
from openadapt.quickstart.story import (
YOUR_APP_URL,
NextSteps,
Expand Down Expand Up @@ -71,21 +72,32 @@ def run_quickstart(
steps: Callable[[Outcome], NextSteps],
stream: Optional[IO[str]] = None,
interactive: Optional[bool] = None,
openemr: Optional[Path] = None,
) -> Outcome:
"""Run the quickstart into ``root`` and print it to ``stream``."""
"""Run the quickstart into ``root`` and print it to ``stream``.

``openemr`` is a clone's ``packages/flow`` folder: the three acts then run
on OpenEMR in Docker instead of the bundled MockMed.
"""

from openadapt.quickstart.engine import FlowEngine, display_available, open_page
from openadapt.quickstart.view import make_view

stream = stream or sys.stdout
if interactive is None:
interactive = bool(sys.stdin.isatty() and getattr(stream, "isatty", bool)())
# A person watching gets a short pause between demonstrated actions; a
# hidden browser runs at full speed.
engine = FlowEngine(root, visible=visible, pace_s=0.3 if visible else 0.0)
engine: Any
if openemr is not None:
from openadapt.quickstart.openemr import OpenEMREngine

engine = OpenEMREngine(root, visible=visible, flow=openemr)
else:
# A person watching gets a short pause between demonstrated actions;
# a hidden browser runs at full speed.
engine = FlowEngine(root, visible=visible, pace_s=0.3 if visible else 0.0)
return run_story(
engine,
make_view(stream),
make_view(stream, scene=getattr(engine, "scene", MOCKMED)),
next_steps=steps,
break_it=break_it,
interactive=interactive,
Expand Down
49 changes: 35 additions & 14 deletions openadapt/quickstart/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from pathlib import Path
from typing import Any, Callable, Optional, Sequence

from openadapt.quickstart.scene import MOCKMED

#: Synthetic notes for the five runs. None of them is clinical advice.
RUN_NOTES = (
"Follow-up in two weeks",
Expand Down Expand Up @@ -95,6 +97,12 @@ class BreakFacts:
halt_reason: str
model_calls: int
seconds: float
#: True only when the run's own evidence shows it stopped at the check
#: this act sets off (the record check refuting the save, or the app's
#: own check the program waits on). A stop for any other reason, or one
#: whose cause the evidence doesn't show, stays False, and the views then
#: describe a generic stop instead of the demonstration.
stop_attributed: bool = False
result: Any = field(default=None, repr=False)

@property
Expand Down Expand Up @@ -346,6 +354,10 @@ def _sha256(path: Path) -> Optional[str]:
class FlowEngine:
"""The bundled tutorial, one stage per call, inside one browser process."""

#: Act 2's inputs: one new note per run.
inputs = RUN_NOTES
scene = MOCKMED

def __init__(self, root: Path, *, visible: bool, pace_s: float = 0.0) -> None:
self.root = root
self.visible = visible
Expand Down Expand Up @@ -581,6 +593,9 @@ def break_it(self) -> BreakFacts:
halt_reason=str(result.halt_reason),
model_calls=int(getattr(report, "model_calls", 0) or 0),
seconds=seconds,
# The record check read the save back and refuted it: the
# silent failure this act injects, not some other refusal.
stop_attributed=int(result.effects_refuted) > 0,
result=result,
)

Expand All @@ -605,20 +620,7 @@ def seal(self, runs: list[RunFacts]) -> Optional[SealFacts]:
no Seal and no command to check one.
"""

try:
from openadapt_flow.offline_seal import seal_home, seal_run
except ImportError:
return None
checked = [run for run in runs if run.checked]
if not checked:
return None
key_id = ""
for run in checked:
# The configured Seal key, or one generated under the Seal home
# on first use (the offline-Seal default).
seal = seal_run(run.run_dir, home=seal_home(), enqueue=False)
key_id = str(seal.key_id)
return SealFacts(key_id=key_id, sealed=len(checked))
return seal_runs(runs)

def seal_broken(self, broken: BreakFacts) -> bool:
"""Sign the stopped run's report too; True once its Seal is written.
Expand Down Expand Up @@ -723,6 +725,25 @@ def write_page(self, first: RunFacts, broken: BreakFacts) -> Optional[Path]:
return demo_proof.write_demo_page(evidence)


def seal_runs(runs: list[RunFacts]) -> Optional[SealFacts]:
"""Sign each checked run's report with this computer's Seal key."""

try:
from openadapt_flow.offline_seal import seal_home, seal_run
except ImportError:
return None
checked = [run for run in runs if run.checked]
if not checked:
return None
key_id = ""
for run in checked:
# The configured Seal key, or one generated under the Seal home on
# first use (the offline-Seal default).
seal = seal_run(run.run_dir, home=seal_home(), enqueue=False)
key_id = str(seal.key_id)
return SealFacts(key_id=key_id, sealed=len(checked))


def return_focus_to_terminal(
*, visible: bool, platform: Optional[str] = None, environ=None, run=None
) -> bool:
Expand Down
Loading
Loading