diff --git a/README.md b/README.md index 864d148..4400015 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ out the pixels. Coordinates are the thing that breaks when a window moves. | `AuthoringObserveV1` | PHI-safe authoring observe tree for the hosted MCP wire | | `AuthoringCommandV1` | Mailbox envelope. Hosted click is `node_id` only; compile is `needs_human_admit` | | `AuthoringBindV1` | Bind status plus exact `oab_` / `oals_` parsers. No tree, tokens, or secrets | +| `ClinicInboxV1` | Workbench job: opaque `patient_token`, artifact path, source, time | +| `ClinicOutboxV1` | Actuation intent. `needs_human: true` does not dispatch | +| `ClinicToolResultV1` | MCP result: `VERIFIED`, `HALTED`, or `RECONCILIATION_REQUIRED`. Halt is not success | Plus the versioned wire contracts: `ControlOverlayFrameV1`/`V2` and `ControlOverlayTimelineV1`/`V2` for PHI-safe execution overlays, @@ -98,7 +101,7 @@ print(json.dumps(ComputerState.model_json_schema(), indent=2)) ``` The same schemas ship as JSON under `openadapt_types/schemas/` for TypeScript, -Rust, and anything else that isn't Python. Twenty-seven files, including +Rust, and anything else that isn't Python. Thirty-one files, including `execute-v1-openapi.json`, the public OpenAdapt Execute contract. ## Converting from the older formats diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 96c39be..cf45661 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -140,3 +140,26 @@ is `{ node_id }` only. Pause results name a param and never a value. Compile returns `needs_human_admit`, never `VERIFIED`. Bind tokens are `oab_` plus 43 unreserved characters. Lease secrets are `oals_` plus 64 hex characters. Cloud `oar_` and pairing `oap_` are refused. + +## Clinic job inbox and MCP tools + +`ClinicInboxV1`, `ClinicOutboxV1`, and `ClinicToolResultV1` are the public +handoff for a compiled clinic program. They are not a workbench. Schema +packs, extraction, review UI, NL2SQL, and OCR do not belong on this wire. + +Inbox fields are `patient_token`, `artifact_path`, `source`, and +`recorded_at`. Identity is the opaque token. A name, MRN, screenshot, or +OCR string has no field. `artifact_path` is a relative POSIX path of +opaque segments; a live filename that could carry a person name is +refused. + +Outbox fields are `action`, `template`, and `needs_human`. `template` is +an opaque id of a clinic-defined template, not the typed body. When +`needs_human` is true, `require_actuation_dispatch` refuses. OpenAdapt +types the template only after a human stamp. + +The admitted MCP names are `run_harvest`, `run_attach_fax`, and +`run_create_triage_task`. Each returns `VERIFIED`, `HALTED`, or +`RECONCILIATION_REQUIRED`. `is_verified_success` is true only for +`VERIFIED`. There is no success flag a caller can set to launder a halt. +There is no tool that decides urgency or writes follow-up copy. diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 616af24..3163a9b 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -85,6 +85,39 @@ parse_authoring_lease_secret, parse_authoring_runner_uri, ) +from openadapt_types.clinic_job import ( + ACTION_TO_TOOL, + CLINIC_INBOX_SCHEMA, + CLINIC_MCP_TOOL_CATALOG, + CLINIC_MCP_TOOL_DESCRIPTIONS, + CLINIC_MCP_TOOL_NAMES, + CLINIC_MCP_TOOLS_SCHEMA, + CLINIC_OUTBOX_SCHEMA, + CLINIC_TOOL_RESULT_SCHEMA, + TOOL_TO_ACTION, + ClinicActuationDecisionV1, + ClinicActuationHeld, + ClinicBoundToolCallV1, + ClinicInboxV1, + ClinicMcpToolCatalogV1, + ClinicMcpToolNameV1, + ClinicMcpToolSpecV1, + ClinicOutboxActionV1, + ClinicOutboxV1, + ClinicToolResultV1, + ClinicToolStatusV1, + ClinicToolUnknown, + bind_clinic_tool_call, + bind_clinic_tool_result, + clinic_mcp_tool_spec, + decide_actuation, + is_verified_success, + parse_clinic_inbox, + parse_clinic_outbox, + planner_status, + planner_visible_payload, + require_actuation_dispatch, +) from openadapt_types.benchmark import ( BenchmarkAction, BenchmarkAgent, @@ -375,6 +408,38 @@ "parse_authoring_bind_token", "parse_authoring_lease_secret", "parse_authoring_runner_uri", + # clinic job inbox / outbox / MCP + "ACTION_TO_TOOL", + "CLINIC_INBOX_SCHEMA", + "CLINIC_MCP_TOOL_CATALOG", + "CLINIC_MCP_TOOL_DESCRIPTIONS", + "CLINIC_MCP_TOOL_NAMES", + "CLINIC_MCP_TOOLS_SCHEMA", + "CLINIC_OUTBOX_SCHEMA", + "CLINIC_TOOL_RESULT_SCHEMA", + "TOOL_TO_ACTION", + "ClinicActuationDecisionV1", + "ClinicActuationHeld", + "ClinicBoundToolCallV1", + "ClinicInboxV1", + "ClinicMcpToolCatalogV1", + "ClinicMcpToolNameV1", + "ClinicMcpToolSpecV1", + "ClinicOutboxActionV1", + "ClinicOutboxV1", + "ClinicToolResultV1", + "ClinicToolStatusV1", + "ClinicToolUnknown", + "bind_clinic_tool_call", + "bind_clinic_tool_result", + "clinic_mcp_tool_spec", + "decide_actuation", + "is_verified_success", + "parse_clinic_inbox", + "parse_clinic_outbox", + "planner_status", + "planner_visible_payload", + "require_actuation_dispatch", # control_overlay "CONTROL_OVERLAY_FRAME_SCHEMA", "CONTROL_OVERLAY_STATE_ID_COMPONENTS", diff --git a/openadapt_types/clinic_job.py b/openadapt_types/clinic_job.py new file mode 100644 index 0000000..914b527 --- /dev/null +++ b/openadapt_types/clinic_job.py @@ -0,0 +1,362 @@ +"""Thin inbox, outbox, and MCP result contracts for clinic jobs. + +The workbench owns schema, extract, review, and NL2SQL. This package does +not reimplement those. OpenAdapt is the hands: a compiled, admitted +program that halts on the wrong patient. Claude Code may call only the +three named tools, and only against admitted programs. + +Identity on this wire is ``patient_token`` only. A name, MRN, or other +live identifier has no field to travel in. Wrong-patient halt is the +existing OpenAdapt identity gate. This adapter does not invent a visual +click. + +SaMD: there is no tool that decides urgency or writes follow-up copy +into a chart. ``run_create_triage_task`` creates a task the clinic +already defined, and only after ``needs_human`` is false because a +human accepted. + +Fax-first: ``run_attach_fax`` is attach plus task create. OCR stays +out of this package. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import Enum +from typing import Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictStr, + model_validator, +) + +CLINIC_INBOX_SCHEMA: Literal["openadapt.clinic-inbox/v1"] = ( + "openadapt.clinic-inbox/v1" +) +CLINIC_OUTBOX_SCHEMA: Literal["openadapt.clinic-outbox/v1"] = ( + "openadapt.clinic-outbox/v1" +) +CLINIC_TOOL_RESULT_SCHEMA: Literal["openadapt.clinic-tool-result/v1"] = ( + "openadapt.clinic-tool-result/v1" +) +CLINIC_MCP_TOOLS_SCHEMA: Literal["openadapt.clinic-mcp-tools/v1"] = ( + "openadapt.clinic-mcp-tools/v1" +) + +_OPAQUE_TOKEN_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" +_SOURCE_PATTERN = r"^[a-z][a-z0-9_]{0,63}$" +_TIMESTAMP_PATTERN = ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$" +) +# Relative POSIX path of opaque segments. Each segment starts with an +# alphanumeric or underscore. Dots only appear as an extension separator, +# so `..`, a leading slash, and a drive letter have no legal shape. +_ARTIFACT_PATH_PATTERN = ( + r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*(?:/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)*$" +) + +_SUCCESS_STATUSES_NOTE = ( + "Only status VERIFIED is success. HALTED and RECONCILIATION_REQUIRED " + "are not success and must not be summarized as success." +) + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ClinicActuationHeld(ValueError): + """Outbox still needs a human stamp; actuation must not dispatch.""" + + +class ClinicToolUnknown(ValueError): + """Caller named a tool this catalog does not admit.""" + + +class ClinicOutboxActionV1(str, Enum): + HARVEST = "harvest" + ATTACH_FAX = "attach_fax" + CREATE_TRIAGE_TASK = "create_triage_task" + + +class ClinicMcpToolNameV1(str, Enum): + RUN_HARVEST = "run_harvest" + RUN_ATTACH_FAX = "run_attach_fax" + RUN_CREATE_TRIAGE_TASK = "run_create_triage_task" + + +class ClinicToolStatusV1(str, Enum): + VERIFIED = "VERIFIED" + HALTED = "HALTED" + RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED" + + +class ClinicActuationDecisionV1(str, Enum): + DISPATCH = "dispatch" + HOLD_FOR_HUMAN = "hold_for_human" + + +ACTION_TO_TOOL: dict[ClinicOutboxActionV1, ClinicMcpToolNameV1] = { + ClinicOutboxActionV1.HARVEST: ClinicMcpToolNameV1.RUN_HARVEST, + ClinicOutboxActionV1.ATTACH_FAX: ClinicMcpToolNameV1.RUN_ATTACH_FAX, + ClinicOutboxActionV1.CREATE_TRIAGE_TASK: ( + ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK + ), +} + +TOOL_TO_ACTION: dict[ClinicMcpToolNameV1, ClinicOutboxActionV1] = { + tool: action for action, tool in ACTION_TO_TOOL.items() +} + +CLINIC_MCP_TOOL_NAMES: tuple[ClinicMcpToolNameV1, ...] = ( + ClinicMcpToolNameV1.RUN_HARVEST, + ClinicMcpToolNameV1.RUN_ATTACH_FAX, + ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK, +) + +# Closed copy for a later MCP host. Not a clinical decision, not a +# follow-up sentence, not an extract/review/ask/audit tool. +CLINIC_MCP_TOOL_DESCRIPTIONS: dict[ClinicMcpToolNameV1, str] = { + ClinicMcpToolNameV1.RUN_HARVEST: ( + "Run the admitted harvest program for this inbox job. Returns " + "VERIFIED, HALTED, or RECONCILIATION_REQUIRED. Halt is halt." + ), + ClinicMcpToolNameV1.RUN_ATTACH_FAX: ( + "Attach the fax artifact and create the clinic-defined task. " + "Returns VERIFIED, HALTED, or RECONCILIATION_REQUIRED. Halt is halt." + ), + ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK: ( + "Create the clinic-defined triage task after a human stamp. " + "Returns VERIFIED, HALTED, or RECONCILIATION_REQUIRED. Halt is halt." + ), +} + +_TOOLS_THAT_REQUIRE_OUTBOX = frozenset( + { + ClinicMcpToolNameV1.RUN_ATTACH_FAX, + ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK, + } +) + + +class ClinicInboxV1(_StrictContract): + """Workbench to OpenAdapt job. Token only; never a name.""" + + schema_version: Literal[CLINIC_INBOX_SCHEMA] = CLINIC_INBOX_SCHEMA + patient_token: StrictStr = Field(pattern=_OPAQUE_TOKEN_PATTERN) + artifact_path: StrictStr = Field( + pattern=_ARTIFACT_PATH_PATTERN, + min_length=1, + max_length=512, + ) + source: StrictStr = Field(pattern=_SOURCE_PATTERN) + recorded_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + + @model_validator(mode="after") + def _artifact_path_has_no_relative_segments(self) -> "ClinicInboxV1": + for segment in self.artifact_path.split("/"): + if segment in {".", ".."} or ".." in segment: + raise ValueError("artifact_path must not contain relative segments") + return self + + +class ClinicOutboxV1(_StrictContract): + """Actuation intent. OpenAdapt types the template only after a human stamp.""" + + schema_version: Literal[CLINIC_OUTBOX_SCHEMA] = CLINIC_OUTBOX_SCHEMA + action: ClinicOutboxActionV1 + template: StrictStr = Field(pattern=_OPAQUE_TOKEN_PATTERN) + needs_human: StrictBool + + +class ClinicToolResultV1(_StrictContract): + """Typed MCP result. Status is the outcome; there is no success flag to set.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra={ + "x-openadapt-success-rule": _SUCCESS_STATUSES_NOTE, + }, + ) + + schema_version: Literal[CLINIC_TOOL_RESULT_SCHEMA] = ( + CLINIC_TOOL_RESULT_SCHEMA + ) + tool: ClinicMcpToolNameV1 + status: ClinicToolStatusV1 + patient_token: StrictStr = Field(pattern=_OPAQUE_TOKEN_PATTERN) + + @property + def ok(self) -> bool: + return self.status is ClinicToolStatusV1.VERIFIED + + +class ClinicMcpToolSpecV1(_StrictContract): + name: ClinicMcpToolNameV1 + action: ClinicOutboxActionV1 + requires_outbox: StrictBool + destructive: Literal[True] = True + read_only: Literal[False] = False + + @model_validator(mode="after") + def _action_matches_name(self) -> "ClinicMcpToolSpecV1": + expected = TOOL_TO_ACTION[self.name] + if self.action is not expected: + raise ValueError("tool name and action must match") + required = self.name in _TOOLS_THAT_REQUIRE_OUTBOX + if self.requires_outbox is not required: + raise ValueError("requires_outbox must match the tool") + return self + + +class ClinicMcpToolCatalogV1(_StrictContract): + schema_version: Literal[CLINIC_MCP_TOOLS_SCHEMA] = CLINIC_MCP_TOOLS_SCHEMA + tools: tuple[ClinicMcpToolSpecV1, ClinicMcpToolSpecV1, ClinicMcpToolSpecV1] + + @model_validator(mode="after") + def _exactly_the_three_admitted_tools(self) -> "ClinicMcpToolCatalogV1": + names = tuple(spec.name for spec in self.tools) + if names != CLINIC_MCP_TOOL_NAMES: + raise ValueError("catalog must be exactly the three admitted tools") + return self + + +class ClinicBoundToolCallV1(_StrictContract): + tool: ClinicMcpToolNameV1 + inbox: ClinicInboxV1 + outbox: ClinicOutboxV1 | None = None + + @model_validator(mode="after") + def _outbox_matches_tool(self) -> "ClinicBoundToolCallV1": + if self.tool in _TOOLS_THAT_REQUIRE_OUTBOX and self.outbox is None: + raise ValueError(f"{self.tool.value} requires an outbox") + if self.outbox is not None: + expected = TOOL_TO_ACTION[self.tool] + if self.outbox.action is not expected: + raise ValueError("outbox action must match the tool") + return self + + +CLINIC_MCP_TOOL_CATALOG = ClinicMcpToolCatalogV1( + tools=( + ClinicMcpToolSpecV1( + name=ClinicMcpToolNameV1.RUN_HARVEST, + action=ClinicOutboxActionV1.HARVEST, + requires_outbox=False, + ), + ClinicMcpToolSpecV1( + name=ClinicMcpToolNameV1.RUN_ATTACH_FAX, + action=ClinicOutboxActionV1.ATTACH_FAX, + requires_outbox=True, + ), + ClinicMcpToolSpecV1( + name=ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK, + action=ClinicOutboxActionV1.CREATE_TRIAGE_TASK, + requires_outbox=True, + ), + ) +) + + +def parse_clinic_inbox(payload: Mapping[str, Any]) -> ClinicInboxV1: + """Parse a workbench inbox. Missing ``patient_token`` is a validation error.""" + + return ClinicInboxV1.model_validate(payload) + + +def parse_clinic_outbox(payload: Mapping[str, Any]) -> ClinicOutboxV1: + return ClinicOutboxV1.model_validate(payload) + + +def decide_actuation(outbox: ClinicOutboxV1) -> ClinicActuationDecisionV1: + if outbox.needs_human: + return ClinicActuationDecisionV1.HOLD_FOR_HUMAN + return ClinicActuationDecisionV1.DISPATCH + + +def require_actuation_dispatch(outbox: ClinicOutboxV1) -> None: + """Refuse to dispatch when the human stamp is still required.""" + + if outbox.needs_human: + raise ClinicActuationHeld( + "outbox.needs_human is true; do not dispatch actuation" + ) + + +def is_verified_success(result: ClinicToolResultV1) -> bool: + """True only for ``VERIFIED``. Halt and reconciliation are not success.""" + + return result.status is ClinicToolStatusV1.VERIFIED + + +def planner_status(result: ClinicToolResultV1) -> ClinicToolStatusV1: + """Return the typed status unchanged. Halt is halt.""" + + return result.status + + +def planner_visible_payload(result: ClinicToolResultV1) -> dict[str, object]: + """Planner-facing dict. ``ok`` is derived from status and cannot be set.""" + + return { + "tool": result.tool.value, + "status": result.status.value, + "patient_token": result.patient_token, + "ok": is_verified_success(result), + } + + +def clinic_mcp_tool_spec(name: ClinicMcpToolNameV1 | str) -> ClinicMcpToolSpecV1: + label = name.value if isinstance(name, ClinicMcpToolNameV1) else name + if not isinstance(label, str): + raise ClinicToolUnknown("tool name is not an admitted clinic tool") + try: + tool = ClinicMcpToolNameV1(label) + except ValueError as exc: + raise ClinicToolUnknown( + f"{label!r} is not an admitted clinic tool" + ) from exc + for spec in CLINIC_MCP_TOOL_CATALOG.tools: + if spec.name is tool: + return spec + raise ClinicToolUnknown(f"{label!r} is not an admitted clinic tool") + + +def bind_clinic_tool_call( + tool: ClinicMcpToolNameV1 | str, + inbox: Mapping[str, Any], + outbox: Mapping[str, Any] | None = None, +) -> ClinicBoundToolCallV1: + """Validate inbox/outbox and refuse actuation that still needs a human.""" + + spec = clinic_mcp_tool_spec(tool) + parsed_inbox = parse_clinic_inbox(inbox) + parsed_outbox: ClinicOutboxV1 | None + if outbox is None: + parsed_outbox = None + else: + parsed_outbox = parse_clinic_outbox(outbox) + require_actuation_dispatch(parsed_outbox) + return ClinicBoundToolCallV1( + tool=spec.name, + inbox=parsed_inbox, + outbox=parsed_outbox, + ) + + +def bind_clinic_tool_result( + call: ClinicBoundToolCallV1, + status: ClinicToolStatusV1, +) -> ClinicToolResultV1: + """Stamp the inbox token onto the result. Identity does not get rewritten.""" + + return ClinicToolResultV1( + tool=call.tool, + status=status, + patient_token=call.inbox.patient_token, + ) diff --git a/openadapt_types/schemas/clinic-inbox-v1.json b/openadapt_types/schemas/clinic-inbox-v1.json new file mode 100644 index 0000000..74fbe70 --- /dev/null +++ b/openadapt_types/schemas/clinic-inbox-v1.json @@ -0,0 +1,42 @@ +{ + "additionalProperties": false, + "description": "Workbench to OpenAdapt job. Token only; never a name.", + "properties": { + "artifact_path": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*(?:/[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*)*$", + "title": "Artifact Path", + "type": "string" + }, + "patient_token": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Patient Token", + "type": "string" + }, + "recorded_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$", + "title": "Recorded At", + "type": "string" + }, + "schema_version": { + "const": "openadapt.clinic-inbox/v1", + "default": "openadapt.clinic-inbox/v1", + "title": "Schema Version", + "type": "string" + }, + "source": { + "pattern": "^[a-z][a-z0-9_]{0,63}$", + "title": "Source", + "type": "string" + } + }, + "required": [ + "patient_token", + "artifact_path", + "source", + "recorded_at" + ], + "title": "ClinicInboxV1", + "type": "object" +} diff --git a/openadapt_types/schemas/clinic-mcp-tools-v1.json b/openadapt_types/schemas/clinic-mcp-tools-v1.json new file mode 100644 index 0000000..3c2e662 --- /dev/null +++ b/openadapt_types/schemas/clinic-mcp-tools-v1.json @@ -0,0 +1,87 @@ +{ + "$defs": { + "ClinicMcpToolNameV1": { + "enum": [ + "run_harvest", + "run_attach_fax", + "run_create_triage_task" + ], + "title": "ClinicMcpToolNameV1", + "type": "string" + }, + "ClinicMcpToolSpecV1": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/$defs/ClinicOutboxActionV1" + }, + "destructive": { + "const": true, + "default": true, + "title": "Destructive", + "type": "boolean" + }, + "name": { + "$ref": "#/$defs/ClinicMcpToolNameV1" + }, + "read_only": { + "const": false, + "default": false, + "title": "Read Only", + "type": "boolean" + }, + "requires_outbox": { + "title": "Requires Outbox", + "type": "boolean" + } + }, + "required": [ + "name", + "action", + "requires_outbox" + ], + "title": "ClinicMcpToolSpecV1", + "type": "object" + }, + "ClinicOutboxActionV1": { + "enum": [ + "harvest", + "attach_fax", + "create_triage_task" + ], + "title": "ClinicOutboxActionV1", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "openadapt.clinic-mcp-tools/v1", + "default": "openadapt.clinic-mcp-tools/v1", + "title": "Schema Version", + "type": "string" + }, + "tools": { + "maxItems": 3, + "minItems": 3, + "prefixItems": [ + { + "$ref": "#/$defs/ClinicMcpToolSpecV1" + }, + { + "$ref": "#/$defs/ClinicMcpToolSpecV1" + }, + { + "$ref": "#/$defs/ClinicMcpToolSpecV1" + } + ], + "title": "Tools", + "type": "array" + } + }, + "required": [ + "tools" + ], + "title": "ClinicMcpToolCatalogV1", + "type": "object" +} diff --git a/openadapt_types/schemas/clinic-outbox-v1.json b/openadapt_types/schemas/clinic-outbox-v1.json new file mode 100644 index 0000000..49bed81 --- /dev/null +++ b/openadapt_types/schemas/clinic-outbox-v1.json @@ -0,0 +1,42 @@ +{ + "$defs": { + "ClinicOutboxActionV1": { + "enum": [ + "harvest", + "attach_fax", + "create_triage_task" + ], + "title": "ClinicOutboxActionV1", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Actuation intent. OpenAdapt types the template only after a human stamp.", + "properties": { + "action": { + "$ref": "#/$defs/ClinicOutboxActionV1" + }, + "needs_human": { + "title": "Needs Human", + "type": "boolean" + }, + "schema_version": { + "const": "openadapt.clinic-outbox/v1", + "default": "openadapt.clinic-outbox/v1", + "title": "Schema Version", + "type": "string" + }, + "template": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Template", + "type": "string" + } + }, + "required": [ + "action", + "template", + "needs_human" + ], + "title": "ClinicOutboxV1", + "type": "object" +} diff --git a/openadapt_types/schemas/clinic-tool-result-v1.json b/openadapt_types/schemas/clinic-tool-result-v1.json new file mode 100644 index 0000000..6d34544 --- /dev/null +++ b/openadapt_types/schemas/clinic-tool-result-v1.json @@ -0,0 +1,51 @@ +{ + "$defs": { + "ClinicMcpToolNameV1": { + "enum": [ + "run_harvest", + "run_attach_fax", + "run_create_triage_task" + ], + "title": "ClinicMcpToolNameV1", + "type": "string" + }, + "ClinicToolStatusV1": { + "enum": [ + "VERIFIED", + "HALTED", + "RECONCILIATION_REQUIRED" + ], + "title": "ClinicToolStatusV1", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Typed MCP result. Status is the outcome; there is no success flag to set.", + "properties": { + "patient_token": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Patient Token", + "type": "string" + }, + "schema_version": { + "const": "openadapt.clinic-tool-result/v1", + "default": "openadapt.clinic-tool-result/v1", + "title": "Schema Version", + "type": "string" + }, + "status": { + "$ref": "#/$defs/ClinicToolStatusV1" + }, + "tool": { + "$ref": "#/$defs/ClinicMcpToolNameV1" + } + }, + "required": [ + "tool", + "status", + "patient_token" + ], + "title": "ClinicToolResultV1", + "type": "object", + "x-openadapt-success-rule": "Only status VERIFIED is success. HALTED and RECONCILIATION_REQUIRED are not success and must not be summarized as success." +} diff --git a/scripts/export_clinic_job_schemas.py b/scripts/export_clinic_job_schemas.py new file mode 100644 index 0000000..8142661 --- /dev/null +++ b/scripts/export_clinic_job_schemas.py @@ -0,0 +1,36 @@ +"""Export the clinic inbox, outbox, and MCP JSON Schemas into the package.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from openadapt_types.clinic_job import ( + ClinicInboxV1, + ClinicMcpToolCatalogV1, + ClinicOutboxV1, + ClinicToolResultV1, +) + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = ROOT / "openadapt_types" / "schemas" +SCHEMAS = { + "clinic-inbox-v1.json": ClinicInboxV1, + "clinic-outbox-v1.json": ClinicOutboxV1, + "clinic-tool-result-v1.json": ClinicToolResultV1, + "clinic-mcp-tools-v1.json": ClinicMcpToolCatalogV1, +} + + +def main() -> int: + SCHEMA_DIR.mkdir(parents=True, exist_ok=True) + for filename, model in SCHEMAS.items(): + (SCHEMA_DIR / filename).write_text( + json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_clinic_job.py b/tests/test_clinic_job.py new file mode 100644 index 0000000..77ac15b --- /dev/null +++ b/tests/test_clinic_job.py @@ -0,0 +1,334 @@ +"""Invariants for the clinic inbox, outbox, and MCP result contracts.""" + +from __future__ import annotations + +import json +from importlib.resources import files + +import pytest +from pydantic import ValidationError + +from openadapt_types.clinic_job import ( + CLINIC_INBOX_SCHEMA, + CLINIC_MCP_TOOL_CATALOG, + CLINIC_MCP_TOOL_NAMES, + CLINIC_OUTBOX_SCHEMA, + CLINIC_TOOL_RESULT_SCHEMA, + ClinicActuationDecisionV1, + ClinicActuationHeld, + ClinicBoundToolCallV1, + ClinicInboxV1, + ClinicMcpToolCatalogV1, + ClinicMcpToolNameV1, + ClinicOutboxActionV1, + ClinicOutboxV1, + ClinicToolResultV1, + ClinicToolStatusV1, + ClinicToolUnknown, + bind_clinic_tool_call, + bind_clinic_tool_result, + clinic_mcp_tool_spec, + decide_actuation, + is_verified_success, + parse_clinic_inbox, + planner_status, + planner_visible_payload, + require_actuation_dispatch, +) + + +def _inbox_fields(**updates: object) -> dict[str, object]: + fields: dict[str, object] = { + "patient_token": "tok_aaaaaaaa", + "artifact_path": "fax/job_001.tif", + "source": "fax", + "recorded_at": "2026-09-01T12:00:00Z", + } + fields.update(updates) + return fields + + +def _outbox_fields(**updates: object) -> dict[str, object]: + fields: dict[str, object] = { + "action": "attach_fax", + "template": "tmpl_attach01", + "needs_human": False, + } + fields.update(updates) + return fields + + +def _unconstrained_string_paths(schema: dict[str, object]) -> list[str]: + unconstrained: list[str] = [] + + def visit(node: object, path: str) -> None: + if isinstance(node, dict): + if node.get("type") == "string" and not ( + {"pattern", "const", "enum"} & set(node) + ): + unconstrained.append(path) + for key, value in node.items(): + visit(value, f"{path}/{key}") + elif isinstance(node, list): + for index, value in enumerate(node): + visit(value, f"{path}/{index}") + + visit(schema, "") + return unconstrained + + +def test_inbox_rejects_a_missing_patient_token() -> None: + payload = _inbox_fields() + del payload["patient_token"] + with pytest.raises(ValidationError, match="patient_token"): + parse_clinic_inbox(payload) + + +def test_inbox_rejects_a_name_or_other_identity_field() -> None: + inbox = ClinicInboxV1.model_validate(_inbox_fields()) + assert inbox.schema_version == CLINIC_INBOX_SCHEMA + payload = inbox.model_dump(mode="json") + for field, value in { + "patient_name": "Jane Doe", + "name": "Jane Doe", + "mrn": "0093211", + "screenshot": "data:image/png;base64,secret", + "ocr": "referral text", + }.items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ClinicInboxV1.model_validate({**payload, field: value}) + + +@pytest.mark.parametrize( + "token", + ["Jane Doe", "patient jd", "jd", "tok", ""], +) +def test_inbox_rejects_a_name_shaped_patient_token(token: str) -> None: + with pytest.raises(ValidationError): + parse_clinic_inbox(_inbox_fields(patient_token=token)) + + +@pytest.mark.parametrize( + "path", + [ + "/Users/jane/fax.pdf", + "../secret.tif", + "C:\\Users\\Jane\\fax.pdf", + "Jane Doe fax.pdf", + "", + ], +) +def test_inbox_rejects_a_path_that_could_carry_a_name(path: str) -> None: + with pytest.raises(ValidationError): + parse_clinic_inbox(_inbox_fields(artifact_path=path)) + + +def test_outbox_with_needs_human_true_must_not_dispatch_actuation() -> None: + outbox = ClinicOutboxV1.model_validate( + _outbox_fields(needs_human=True, schema_version=CLINIC_OUTBOX_SCHEMA) + ) + assert decide_actuation(outbox) is ClinicActuationDecisionV1.HOLD_FOR_HUMAN + with pytest.raises(ClinicActuationHeld, match="do not dispatch actuation"): + require_actuation_dispatch(outbox) + with pytest.raises(ClinicActuationHeld, match="do not dispatch actuation"): + bind_clinic_tool_call( + "run_attach_fax", + _inbox_fields(), + _outbox_fields(needs_human=True), + ) + + +def test_outbox_without_a_human_stamp_may_bind_an_admitted_tool() -> None: + call = bind_clinic_tool_call( + ClinicMcpToolNameV1.RUN_ATTACH_FAX, + _inbox_fields(), + _outbox_fields(needs_human=False), + ) + assert call.outbox is not None + assert decide_actuation(call.outbox) is ClinicActuationDecisionV1.DISPATCH + require_actuation_dispatch(call.outbox) + + +def test_halt_must_not_map_to_success() -> None: + call = bind_clinic_tool_call( + "run_harvest", + _inbox_fields(), + ) + halted = bind_clinic_tool_result(call, ClinicToolStatusV1.HALTED) + reconciled = bind_clinic_tool_result( + call, ClinicToolStatusV1.RECONCILIATION_REQUIRED + ) + verified = bind_clinic_tool_result(call, ClinicToolStatusV1.VERIFIED) + + assert halted.status is ClinicToolStatusV1.HALTED + assert halted.ok is False + assert is_verified_success(halted) is False + assert planner_status(halted) is ClinicToolStatusV1.HALTED + assert planner_visible_payload(halted)["ok"] is False + assert planner_visible_payload(halted)["status"] == "HALTED" + + assert reconciled.ok is False + assert is_verified_success(reconciled) is False + assert planner_visible_payload(reconciled)["status"] == ( + "RECONCILIATION_REQUIRED" + ) + + assert verified.ok is True + assert is_verified_success(verified) is True + assert planner_visible_payload(verified)["status"] == "VERIFIED" + + +def test_tool_result_rejects_a_success_flag_that_could_launder_halt() -> None: + result = ClinicToolResultV1( + tool=ClinicMcpToolNameV1.RUN_HARVEST, + status=ClinicToolStatusV1.HALTED, + patient_token="tok_aaaaaaaa", + ) + payload = result.model_dump(mode="json") + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ClinicToolResultV1.model_validate({**payload, "success": True}) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ClinicToolResultV1.model_validate({**payload, "ok": True}) + + +def test_catalog_is_exactly_the_three_admitted_tools() -> None: + names = tuple(spec.name for spec in CLINIC_MCP_TOOL_CATALOG.tools) + assert names == CLINIC_MCP_TOOL_NAMES + assert names == ( + ClinicMcpToolNameV1.RUN_HARVEST, + ClinicMcpToolNameV1.RUN_ATTACH_FAX, + ClinicMcpToolNameV1.RUN_CREATE_TRIAGE_TASK, + ) + assert clinic_mcp_tool_spec("run_harvest").requires_outbox is False + assert clinic_mcp_tool_spec("run_attach_fax").requires_outbox is True + assert clinic_mcp_tool_spec("run_create_triage_task").requires_outbox is True + + +@pytest.mark.parametrize( + "name", + [ + "run_decide_urgency", + "run_write_followup", + "extract", + "review", + "ask", + "audit", + "schema_pack", + ], +) +def test_unknown_or_clinical_decision_tools_are_refused(name: str) -> None: + with pytest.raises(ClinicToolUnknown, match="not an admitted clinic tool"): + clinic_mcp_tool_spec(name) + + +def test_attach_and_triage_require_an_outbox() -> None: + with pytest.raises(ValidationError, match="requires an outbox"): + bind_clinic_tool_call("run_attach_fax", _inbox_fields()) + with pytest.raises(ValidationError, match="requires an outbox"): + bind_clinic_tool_call("run_create_triage_task", _inbox_fields()) + + +def test_outbox_action_must_match_the_tool() -> None: + with pytest.raises(ValidationError, match="outbox action must match"): + bind_clinic_tool_call( + "run_create_triage_task", + _inbox_fields(), + _outbox_fields(action="attach_fax", needs_human=False), + ) + + +def test_template_rejects_follow_up_copy() -> None: + with pytest.raises(ValidationError): + ClinicOutboxV1.model_validate( + _outbox_fields(template="see in two weeks") + ) + payload = ClinicOutboxV1.model_validate(_outbox_fields()).model_dump( + mode="json" + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ClinicOutboxV1.model_validate( + {**payload, "follow_up_copy": "see in two weeks"} + ) + + +def test_result_keeps_the_inbox_patient_token() -> None: + call = bind_clinic_tool_call("run_harvest", _inbox_fields()) + result = bind_clinic_tool_result(call, ClinicToolStatusV1.VERIFIED) + assert result.patient_token == call.inbox.patient_token + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ClinicToolResultV1.model_validate( + { + **result.model_dump(mode="json"), + "patient_name": "Jane Doe", + } + ) + + +def test_bound_call_cannot_rewrite_identity_on_the_result() -> None: + call = ClinicBoundToolCallV1( + tool=ClinicMcpToolNameV1.RUN_HARVEST, + inbox=ClinicInboxV1.model_validate(_inbox_fields()), + ) + result = bind_clinic_tool_result(call, ClinicToolStatusV1.HALTED) + assert result.patient_token == "tok_aaaaaaaa" + with pytest.raises(ValidationError): + ClinicToolResultV1( + tool=call.tool, + status=ClinicToolStatusV1.HALTED, + patient_token="Jane Doe", + ) + + +@pytest.mark.parametrize( + "model,filename,schema_version", + [ + (ClinicInboxV1, "clinic-inbox-v1.json", CLINIC_INBOX_SCHEMA), + (ClinicOutboxV1, "clinic-outbox-v1.json", CLINIC_OUTBOX_SCHEMA), + ( + ClinicToolResultV1, + "clinic-tool-result-v1.json", + CLINIC_TOOL_RESULT_SCHEMA, + ), + (ClinicMcpToolCatalogV1, "clinic-mcp-tools-v1.json", None), + ], +) +def test_packaged_json_schemas_are_strict_and_match_the_models( + model: type[ClinicInboxV1] + | type[ClinicOutboxV1] + | type[ClinicToolResultV1] + | type[ClinicMcpToolCatalogV1], + filename: str, + schema_version: str | None, +) -> None: + schema = model.model_json_schema() + assert schema["additionalProperties"] is False + assert _unconstrained_string_paths(schema) == [] + encoded = json.dumps(schema).lower() + for term in ( + "screenshot", + "ocr", + "yolo", + "mrn", + "patient_name", + "schema_pack", + "mockmed", + ): + assert term not in encoded + packaged = files("openadapt_types.schemas").joinpath(filename) + assert json.loads(packaged.read_text()) == schema + if schema_version is not None: + assert schema_version in json.dumps(schema) + + +def test_tool_result_schema_states_that_halt_is_not_success() -> None: + schema = ClinicToolResultV1.model_json_schema() + assert "not success" in schema["x-openadapt-success-rule"].lower() + assert "HALTED" in json.dumps(schema) + + +def test_catalog_refuses_a_fourth_tool() -> None: + harvest, attach, triage = CLINIC_MCP_TOOL_CATALOG.tools + with pytest.raises(ValidationError, match="exactly the three admitted"): + ClinicMcpToolCatalogV1(tools=(harvest, attach, harvest)) + assert attach.action is ClinicOutboxActionV1.ATTACH_FAX + assert triage.action is ClinicOutboxActionV1.CREATE_TRIAGE_TASK diff --git a/tests/test_readme_claims.py b/tests/test_readme_claims.py index 427bba0..d9e908d 100644 --- a/tests/test_readme_claims.py +++ b/tests/test_readme_claims.py @@ -22,6 +22,7 @@ "sixteen", "seventeen", "eighteen", "nineteen", "twenty", "twenty-one", "twenty-two", "twenty-three", "twenty-four", "twenty-five", "twenty-six", "twenty-seven", "twenty-eight", "twenty-nine", "thirty", + "thirty-one", ) # Tied to the sentence itself, so an unrelated "N files" elsewhere in the