Skip to content
Merged
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
1 change: 1 addition & 0 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ improvising or retrying when something is off.
| Run a step as a direct API call (`ApiActuator`, `ApiBinding`) | Released, hand-authored bindings | Synthetic benchmarks | `runtime/actuators/api.py` |
| Capture a recording's network traffic and compile a clean save into an API step, with the same record check | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` |
| Fall back from the API step to page structure when the API is unavailable, before anything is sent | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) |
| Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | In review ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#under-an-approval-mode) |
| Run a worklist on several workers at once, one signed-in session each, each item claimed once, with a stop on any worker stopping all | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic patients on a local OpenEMR test copy | `--workers`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) |
| Read a save back while the next item runs (pipelined read-back, REST, FHIR, GraphQL or SQL record checks) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `--readback-window`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) |
| A faster speed profile that keeps every check (one insert per plain text field, shorter page-quiet wait) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `runtime.speed: fast`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) |
Expand Down
68 changes: 54 additions & 14 deletions llms-full.txt

Large diffs are not rendered by default.

38 changes: 35 additions & 3 deletions packages/flow/docs/API_TIER.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,46 @@ RECONCILIATION_REQUIRED` unless the record check proves the effect.
The record check (`effects`) then confirms the save with the same verifier
a GUI save uses.

## Under an approval mode

Under `each_item` (the default) and `problems_only`, the person approves the
request the API step sends, not a screenshot of the form. The API step fills
no form, so a screenshot would show an empty one.

1. Before it asks, the run prepares the request: it reads the form page,
builds the body and runs the pre-checks. Only reads are sent. If the
request can't be prepared, nobody is asked and the GUI steps run; their
save asks the person as before.
2. The approval request (`item-approval/request-<id>.json`) has
`form_state_kind: "api_request"` and an `api_request` block with the
method, the path and `payload_sha256`, the digest of every byte the save
sends (method, path, content type, encoded body and referer, session
values included). Its `form_state_sha256` is the operation digest: the
payload digest, the record the check reads back (the record check's
selector fields and their values for this run) and the record check's
contract hashes.
3. The person sees the values this run supplies, the record the check reads
back and the request line, as rows on the phone and as the request's
image in Desktop. Session values are counted, never shown.
4. After the approval, the run prepares the request again (fresh form, fresh
pre-checks). The last-point gate compares its operation digest with the
approved one. A different request, record or record check stops the item
before the save (`form_changed_after_approval`). A request that can't be
prepared again stops it too (`form_state_unreadable`). In both cases the
save isn't sent, and the GUI steps don't run in its place.
5. The run sends exactly the request it just checked, once.

An application that issues a new session value on every form read can't be
approved this way: the second read changes the digest, and the item stops
before saving.

## When it doesn't run

The API step runs only in a plain linear run. It doesn't run, and the GUI
steps run instead, when:

- approval mode is `each_item` (the default) or `problems_only`. The person
approves what the screen shows, and an API step leaves the form empty, so
API steps run only with `--approval-mode not_gated`;
- an approval mode is on and the API actuator can't prepare the request
before sending it (a deployment's own actuator without `prepare`);
- the run is governed by qualification, is durable, or is resumed;
- the step isn't admitted, or the program's steps, on-screen checks or the
save's record check changed since it was proposed or admitted;
Expand Down
10 changes: 9 additions & 1 deletion packages/flow/docs/APPROVAL_MODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ in its run directory and waits:
(at most 4 hours).
- `item-approval/<id>.png`: the filled form the person is shown.

When the save is an API step ([`API_TIER.md`](API_TIER.md)), the person
approves the request it sends instead of a screenshot: the image and the
phone's rows list the values, the record the check reads back and the
request line. The request's `form_state_kind` is `api_request`, and its
digest binds every byte the save sends, the record and the record check.

Desktop reads the requests and answers one of them:

```bash
Expand All @@ -63,7 +69,9 @@ openadapt-flow item-approval decline RUN_DIR ... --reason wrong_values
and right-record check again. At the last point before the input, the run
reads the form again; a field that changed while waiting stops the item
before the save. In a browser the run reads the page's form controls;
elsewhere it reads the words on the screen.
elsewhere it reads the words on the screen. An API step prepares its
request again instead; a request whose digest changed stops the item
before the save, and the GUI steps don't run in its place.
- A decline stops the item before that save. Earlier saves of the same
item stay as they are.
- `--approval-wait SECONDS` (default 900, at most 14,400) sets how long the
Expand Down
6 changes: 6 additions & 0 deletions packages/flow/openadapt_flow/approval_mode/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from openadapt_flow.approval_mode.item_approval import (
DEFAULT_APPROVAL_WAIT_S,
ApiRequestRef,
ItemApprovalRefused,
ItemApprovalRequest,
ItemApprovalStore,
Expand Down Expand Up @@ -199,6 +200,10 @@ class StepContext:
#: The values the save writes, shown on the phone (see
#: :data:`SaveFieldsReader`). Bound into the request digest.
save_fields: SaveFieldsReader = _no_save_fields
#: The request an API step sends, when the save is one. ``frame`` then
#: returns an image of that request and ``form_state`` its operation
#: digest, so the person approves the request rather than a screenshot.
api_request: Optional[ApiRequestRef] = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -631,6 +636,7 @@ def wait_refusal(detail: str) -> GateVerdict:
qualification_case_kind=ctx.qualification_case_kind,
step_class=step_class_for(ctx.step, policy_name=self._policy_name),
fields=ctx.save_fields(),
api_request=ctx.api_request,
)
except (OSError, ValueError, ItemApprovalRefused):
return wait_refusal("approval_request_unwritable")
Expand Down
38 changes: 36 additions & 2 deletions packages/flow/openadapt_flow/approval_mode/item_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ class SaveField(BaseModel):
after: Optional[str] = Field(default=None, max_length=_MAX_FIELD_VALUE)


class ApiRequestRef(BaseModel):
"""The request an API step sends, when the save is an API step.

The person approves this request, not a screenshot of a form: the API
step fills no form. ``payload_sha256`` covers every byte the request
sends; the request's ``form_state_sha256`` is the operation digest that
binds it to the record and the record check
(:func:`openadapt_flow.runtime.actuators.web_form.operation_digest`).
"""

model_config = ConfigDict(extra="forbid", frozen=True)

method: str = Field(min_length=1, max_length=16)
path: str = Field(min_length=1, max_length=1024)
payload_sha256: str = Field(pattern=PREFIXED_SHA256_PATTERN)


class ItemApprovalRequest(BaseModel):
"""What the person is asked to approve: one save of one item."""

Expand All @@ -179,7 +196,9 @@ class ItemApprovalRequest(BaseModel):
screenshot_sha256: str = Field(pattern=_HEX_SHA256)
#: How the field state was read: the page's own form controls, or the
#: text on the screen where the application exposes no form controls.
form_state_kind: Literal["dom", "screen_text"]
#: ``api_request`` when the save is an API step: the digest is then the
#: exact request, the record and the record check (``api_request``).
form_state_kind: Literal["dom", "screen_text", "api_request"]
form_state_sha256: str = Field(pattern=PREFIXED_SHA256_PATTERN)
created_at: str
expires_at: str
Expand All @@ -195,6 +214,9 @@ class ItemApprovalRequest(BaseModel):
fields: Optional[tuple[SaveField, ...]] = Field(
default=None, min_length=1, max_length=MAX_SAVE_FIELDS
)
#: The request an API step sends, when this save is one. Left out of the
#: file when absent, so earlier requests keep their digest.
api_request: Optional[ApiRequestRef] = None

@model_serializer(mode="wrap")
def _serialize_compatible(self, handler: Any) -> dict[str, Any]:
Expand All @@ -203,6 +225,8 @@ def _serialize_compatible(self, handler: Any) -> dict[str, Any]:
data.pop("step_class", None)
if self.fields is None:
data.pop("fields", None)
if self.api_request is None:
data.pop("api_request", None)
return data

@model_validator(mode="after")
Expand All @@ -215,6 +239,8 @@ def _times(self) -> "ItemApprovalRequest":
raise ValueError("an approval request may not outlive its window")
if self.screenshot != f"{APPROVAL_DIRNAME}/{self.request_id}.png":
raise ValueError("the request screenshot must be its own file")
if (self.form_state_kind == "api_request") != (self.api_request is not None):
raise ValueError("an API step's request names the request it sends")
return self

def digest(self) -> str:
Expand Down Expand Up @@ -691,11 +717,13 @@ def open_request(
ttl_s: float = REQUEST_TTL_S,
step_class: StepClass = StepClass.CONSEQUENTIAL,
fields: Optional[Sequence[SaveField]] = None,
api_request: Optional[ApiRequestRef] = None,
) -> ItemApprovalRequest:
"""Write the screenshot and the request. Returns the request.

``fields`` are the values the save writes, shown on the phone. An
empty list is stored as none.
empty list is stored as none. ``api_request`` names the request an
API step sends; ``form_state`` is then ``("api_request", digest)``.
"""

self._checked_dir(create=True)
Expand All @@ -721,6 +749,7 @@ def open_request(
qualification_case_kind=qualification_case_kind,
step_class=step_class,
fields=tuple(fields) if fields else None,
api_request=api_request,
)
_write_exclusive(self.dir / f"{request_id}.png", frame_png)
_write_exclusive(
Expand Down Expand Up @@ -1100,6 +1129,11 @@ def request_summary(
"qualification_case_kind": request.qualification_case_kind,
"step_class": request.step_class.value,
"decided_by": decided_by_summary(store, request),
"api_request": (
request.api_request.model_dump(mode="json")
if request.api_request is not None
else None
),
}


Expand Down
Loading
Loading