Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 59 additions & 0 deletions packages/flow/benchmark/odoo_contacts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Odoo 18 Community: create a contact

The skills registry's first non-healthcare candidate. Odoo Community is
LGPL-3.0. Its images are pulled by digest at run time
(`environment.lock.json`), and nothing of Odoo is stored here. All data is
synthetic.

| File | What it is |
| --- | --- |
| `compose.yml`, `fixture.py` | The test copy: Odoo 18.0 and Postgres 16 on loopback ports 9412 and 9413, the `oa` database with Contacts and no demo data. A dedicated user (`openadapt.actor@example.invalid`, with Contact Creation) and a read-only role for the record check (`oa_record_check`). |
| `qualify_hooks.py` | Signs the dedicated user in, opens a new contact, archives contacts between cases, and writes a signed-in browser state (what a registry user binds as `SIGNED_IN_STATE`). |
| `demo.py` | The scripted demonstration (choose Individual, fill name, street, city, ZIP, phone and email, save), then compile. |
| `verify.yaml` | The record check: one parameterized, read-only SELECT of the typed fields from `res_partner`, keyed by email. |
| `results/2026-10-28-click-teach/qualification/` | The qualification evidence. The compiled bundle isn't committed; `demo.py compile` rebuilds it. |

```bash
cd packages/flow
export ODOO_RECORD_PASSWORD=... # from the fixture's secrets.json
PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py record --out REC
PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py compile --recording REC --bundle B
PYTHONPATH=.:benchmark/odoo_contacts openadapt-flow qualify B \
--verify benchmark/odoo_contacts/verify.yaml --out Q --certify clinical-write
```

`PYTHONPATH` includes this folder because an installed `benchmark` package
can shadow this repository's namespace package.

## Result (2026-10-28)

The demonstration saved the contact, and the record check's SELECT read all
six fields back. The compiled program has 14 steps and 6 parameters.
Qualification under `clinical-write` refused, as it should, before any case
ran:

- **Short labels.** The clicks into City and Phone are unarmed: the only
text beside each is a one-word label, too little for an identity check.
Reaching them by keyboard from a checked click (Street, Email) works.
Those keystrokes need the focus check (#1579) to carry a postcondition.
- **An icon-only save button.** Odoo's save button is a cloud icon with no
text, so its click can't carry an identity check. This is the same class
as Medplum's Create button (#1581, form identity). Odoo's own save
shortcut is Control+S on macOS and Alt+S elsewhere, so a taught shortcut
would replay on one platform only.

So this skill isn't in the registry yet. Under the threat model (#1598) the
record check is what proves the save, and identity checks are fail-closed
pre-checks only, so the next step is the icon-button identity in #1581, not
a page heuristic here.

## Setting the fixture up

- Odoo keeps a long-polling connection open, so a page never goes network
idle. The hooks wait for the form instead.
- A plain internal user can't create contacts, and Odoo then shows no save
button. The dedicated user needs Contact Creation
(`base.group_partner_manager`).

Effort: about 0.8 hours of lease time, from bring-up to the refused
qualification.
41 changes: 41 additions & 0 deletions packages/flow/benchmark/odoo_contacts/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Odoo 18 Community (LGPL-3.0), self-hosted for the skills registry's first
# non-healthcare entry: create a contact. Synthetic data only. Images are
# pinned by digest; the web and database ports are published on loopback only;
# secrets come
# from the fixture's generated state (fixture.py).
services:
db:
image: ${ODOO_PG_IMAGE}
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: ${ODOO_DB_PASSWORD}
POSTGRES_DB: postgres
# Loopback only: the record check reads res_partner through a read-only role.
ports:
- "127.0.0.1:${ODOO_DB_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo"]
interval: 5s
timeout: 5s
retries: 30

odoo:
image: ${ODOO_IMAGE}
depends_on:
db:
condition: service_healthy
environment:
HOST: db
USER: odoo
PASSWORD: ${ODOO_DB_PASSWORD}
command: ["odoo", "--db-filter=^oa$", "--without-demo=all", "--proxy-mode"]
ports:
- "127.0.0.1:${ODOO_PORT}:8069"
volumes:
- odoodata:/var/lib/odoo

volumes:
pgdata:
odoodata:
121 changes: 121 additions & 0 deletions packages/flow/benchmark/odoo_contacts/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Teach Odoo's new-contact form: a scripted demonstration, then compile.

PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py \
record --out DIR [--values FILE]
PYTHONPATH=.:benchmark/odoo_contacts python benchmark/odoo_contacts/demo.py \
compile --recording DIR --bundle DIR

Signing in and opening a new contact are setup, not part of the
demonstration (``qualify_hooks.open_session``). The demonstration marks the
contact an Individual (the form starts on Company), fills it, and saves it
with the form's save button. Every action goes through the ``Recorder``, so
the frames and events are what a person's demonstration would leave; record
time uses Playwright locators only to find where to click.

Synthetic values only.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

#: The demonstration's contact (synthetic). Replays bring their own values.
DEFAULT_VALUES = {
"name": "Rowan Achterberg",
"street": "41 Quarry Lane",
"city": "Worcester",
"zip": "01609",
"phone": "508-555-0147",
"email": "rowan.achterberg@example.invalid",
}

SETTLE = {
"settle_timeout_s": 10.0,
"settle_stable_frames": 3,
"settle_interval_s": 0.3,
}


def _click(recorder: Any, page: Any, locator: Any) -> None:
box = locator.bounding_box()
if box is None:
raise RuntimeError("target is not on the page")
recorder.click(int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2))
page.wait_for_timeout(300)


def demonstrate(recorder: Any, page: Any, values: dict[str, str]) -> None:
"""Fill a new person contact once and save it."""

_click(recorder, page, page.get_by_label("Individual"))
_click(recorder, page, page.locator("div[name=name] input"))
recorder.type_text(values["name"], param="name")
for placeholder, param in (("Street...", "street"), ("City", "city"), ("ZIP", "zip")):
_click(recorder, page, page.get_by_placeholder(placeholder, exact=True))
recorder.type_text(values[param], param=param)
# Phone and Email have a label beside them, not a placeholder.
for param in ("phone", "email"):
_click(recorder, page, page.locator(f"#{param}_0"))
recorder.type_text(values[param], param=param)
_click(recorder, page, page.get_by_role("button", name="Save manually"))
page.wait_for_url(lambda url: not url.rstrip("/").endswith("/new"), timeout=30_000)
page.wait_for_timeout(1500)


def cmd_record(args: argparse.Namespace) -> int:
from types import SimpleNamespace

import qualify_hooks

from openadapt_flow.recorder import Recorder

out = Path(args.out)
if out.exists():
raise SystemExit(f"recording path already exists: {out}")
values = dict(DEFAULT_VALUES)
if args.values:
values.update(json.loads(Path(args.values).read_text()))
config = SimpleNamespace(app=SimpleNamespace(viewport=(1280, 900)))
backend, close = qualify_hooks.open_session(config)
try:
recorder = Recorder(backend, out, app_url=backend.page.url, **SETTLE)
demonstrate(recorder, backend.page, values)
print("recording:", recorder.finish())
print("saved:", backend.page.url)
finally:
close()
return 0


def cmd_compile(args: argparse.Namespace) -> int:
from openadapt_flow.compiler import compile_recording

bundle = Path(args.bundle)
if bundle.exists():
raise SystemExit(f"bundle path already exists: {bundle}")
workflow = compile_recording(Path(args.recording), bundle, name="odoo-new-contact")
print("bundle:", bundle, "steps:", len(workflow.steps))
print("params:", sorted(workflow.params) if workflow.params else [])
return 0


def main(argv: Any = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("record")
p.add_argument("--out", required=True)
p.add_argument("--values", default=None, help="JSON file of field values")
p.set_defaults(func=cmd_record)
p = sub.add_parser("compile")
p.add_argument("--recording", required=True)
p.add_argument("--bundle", required=True)
p.set_defaults(func=cmd_compile)
args = parser.parse_args(argv)
return args.func(args)


if __name__ == "__main__":
raise SystemExit(main())
12 changes: 12 additions & 0 deletions packages/flow/benchmark/odoo_contacts/environment.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema_version": 1,
"generated_for": "OpenAdapt skills registry: Odoo 18 Community, create a contact (synthetic data)",
"license": "LGPL-3.0 (Odoo Community); images pulled at run time, never stored in this repository",
"services": {
"odoo": "odoo:18.0@sha256:9668abacd894276ed009e8db7db1d1ffb8068af6982f76a3400ed0d68e40432d",
"postgres": "postgres:16@sha256:1beaa3084eee2676f20a9cd9928f0bf2c5bf674f8e49f81017912183d377c61c"
},
"platforms": "both images publish linux/arm64 and linux/amd64",
"project": "openadapt-si-odoo",
"database": "oa"
}
Loading
Loading