assistant/apps/server/learning_contracts.py

238 lines
13 KiB
Python

"""Small, versioned input boundary shared by recording and frozen checks."""
from __future__ import annotations
import re
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
FIELD_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}")
PRIVATE_NAME = re.compile(r"password|passwd|credential|token|secret|session|cookie|authorization|captcha|verification|phone|mobile|sms|code|value", re.I)
EXECUTION_PROFILE = "playwright-xhs-native-ui-v1"
EXECUTION_CONTRACT = {"engine": "playwright", "action_policy": "xhs-bounded-readonly-v1", "network_policy": "xhs-page-native-v1"}
NAVIGATION_PROFILE = "playwright-xhs-navigation-ui-v1"
NAVIGATION_CONTRACT = {"engine": "playwright", "action_policy": "xhs-observed-navigation-readonly-v1", "network_policy": "xhs-page-navigation-v1"}
EXECUTION_CONTRACTS = {EXECUTION_PROFILE: EXECUTION_CONTRACT, NAVIGATION_PROFILE: NAVIGATION_CONTRACT}
def execution_metadata(profile) -> dict:
"""Resolve a fixed contract; absence means legacy without rewriting facts."""
if profile is None:
return {}
if not isinstance(profile, str) or profile not in EXECUTION_CONTRACTS:
raise ValueError("unsupported execution profile")
return {"execution_profile": profile, "execution_contract": dict(EXECUTION_CONTRACTS[profile])}
def execution_metadata_matches(value, profile) -> bool:
if not isinstance(value, dict):
return False
try:
expected = execution_metadata(profile)
except ValueError:
return False
actual = {key: value[key] for key in ("execution_profile", "execution_contract") if key in value}
return actual == expected
def valid_selector(value: str, *, relative: bool = False) -> bool:
forbidden = r">>|/|[\x00-\x1f]|(?:^|\s)(?:xpath|text|css|id|role|nth|javascript)\s*=|javascript:|:has-text\s*\(|:text(?:-is|-matches)?\s*\("
if not value.strip() or len(value) > 1000 or re.search(forbidden, value, re.I):
return False
return not relative or not re.search(r"(?:^|[\s,>+~])(?:html|body)(?:$|[\s,.#:[>+~])|:root", value, re.I)
def valid_attribute(value: str | None) -> bool:
return bool(value and (value in {"id", "title", "role", "datetime", "aria-label"} or (re.fullmatch(r"data-[a-z][a-z0-9_-]{0,63}", value) and not PRIVATE_NAME.search(value))))
class ClosedInput(BaseModel):
model_config = ConfigDict(extra="forbid")
class ExtractField(ClosedInput):
name: str = Field(min_length=1, max_length=64)
selector: str | None = Field(default=None, min_length=1, max_length=1000)
source: Literal["text", "href", "attribute", "content"] = "text"
attribute: str | None = Field(default=None, max_length=100)
required: bool = Field(default=False, strict=True)
text_selector: str | None = Field(default=None, min_length=1, max_length=1000)
media_selector: str | None = Field(default=None, min_length=1, max_length=1000)
max_media: int | None = Field(default=None, ge=1, le=4, strict=True)
@model_validator(mode="after")
def safe_field(self):
if not FIELD_NAME.fullmatch(self.name) or self.name in {"constructor", "prototype", "__proto__"}:
raise ValueError("invalid business field name")
if self.selector is not None and not valid_selector(self.selector, relative=True):
raise ValueError("only relative CSS selectors are supported")
if self.source == "attribute" and not valid_attribute(self.attribute):
raise ValueError("unsupported attribute")
if self.source != "attribute" and self.attribute is not None:
raise ValueError("attribute requires attribute source")
content_fields = {"text_selector", "media_selector", "max_media"}
if self.source == "content":
if not self.required or not self.text_selector or not self.media_selector or "attribute" in self.model_fields_set:
raise ValueError("content requires explicit text/media selectors and a required content root")
if not all(valid_selector(value, relative=True) for value in (self.text_selector, self.media_selector)):
raise ValueError("content selectors must stay within the ordinary content root")
if "max_media" in self.model_fields_set and self.max_media is None:
raise ValueError("max_media must be omitted or explicit")
if self.max_media is None:
self.max_media = 4
elif content_fields & self.model_fields_set:
raise ValueError("content configuration requires an explicit content source")
return self
class DynamicTarget(ClosedInput):
field: Literal["text", "href", "attribute"]
attribute: str | None = Field(default=None, max_length=100)
value: str | None = Field(default=None, min_length=1, max_length=2000)
parameter: str | None = Field(default=None, min_length=1, max_length=64)
@model_validator(mode="after")
def safe_target(self):
if (self.value is None) == (self.parameter is None):
raise ValueError("target requires exactly one value or parameter")
if self.value is not None and not self.value.strip():
raise ValueError("target value must be nonempty")
if self.field == "attribute" and not valid_attribute(self.attribute):
raise ValueError("unsupported target attribute")
if self.field != "attribute" and self.attribute is not None:
raise ValueError("attribute requires attribute field")
return self
class ScrollTarget(DynamicTarget):
selector: str = Field(min_length=1, max_length=1000)
@model_validator(mode="after")
def relative_target(self):
if not valid_selector(self.selector, relative=True):
raise ValueError("scroll target requires a safe descendant CSS selector")
return self
class ExtractBatch(ClosedInput):
scroll_selector: str = Field(min_length=1, max_length=1000)
pixels: int = Field(ge=1, le=1600, strict=True)
max_batches: int = Field(ge=1, le=5, strict=True)
dedupe_field: str = Field(min_length=1, max_length=64)
@model_validator(mode="after")
def bounded_batch(self):
if not valid_selector(self.scroll_selector) or not FIELD_NAME.fullmatch(self.dedupe_field):
raise ValueError("batch requires observed CSS and a declared identity field")
return self
class EmptyState(ClosedInput):
selector: str = Field(min_length=1, max_length=1000)
text: str = Field(min_length=1, max_length=2000)
@model_validator(mode="after")
def bounded_empty_state(self):
self.text = self.text.strip()
if not valid_selector(self.selector, relative=True) or not self.text.strip():
raise ValueError("empty state requires relative CSS and explicit visible text")
return self
class EmptyExpectation(ClosedInput):
state: Literal["empty"]
class NonemptyExpectation(ClosedInput):
state: Literal["nonempty"]
class Action(ClosedInput):
kind: Literal["fill", "click", "press", "read", "scroll", "wait", "extract"]
selector: str = Field(min_length=1, max_length=1000)
key: Literal["Enter"] | None = None
value: str | None = Field(default=None, max_length=2000)
parameter: str | None = Field(default=None, min_length=1, max_length=64)
output_name: str | None = Field(default=None, max_length=64)
target: DynamicTarget | None = None
direction: Literal["down", "up"] | None = None
pixels: int | None = Field(default=None, ge=1, le=1600, strict=True)
max_scrolls: int | None = Field(default=None, ge=1, le=10, strict=True)
until_selector: str | None = Field(default=None, min_length=1, max_length=1000)
until_target: ScrollTarget | None = None
scroll_scope: Literal["viewport"] | None = None
state: Literal["visible", "hidden", "text"] | None = None
wait_scope: str | None = Field(default=None, min_length=1, max_length=1000)
text: str | None = Field(default=None, min_length=1, max_length=2000)
timeout_ms: int | None = Field(default=None, ge=1, le=15000, strict=True)
min_count: int | None = Field(default=None, ge=1, le=100, strict=True)
fields: list[ExtractField] | None = Field(default=None, min_length=1, max_length=12)
limit: int | None = Field(default=None, ge=1, le=100, strict=True)
complete_only: bool | None = Field(default=None, strict=True)
row_selector: str | None = Field(default=None, min_length=1, max_length=1000)
batch: ExtractBatch | None = None
allow_empty: bool | None = Field(default=None, strict=True)
empty_state: EmptyState | None = None
@model_validator(mode="after")
def bounded_action(self):
if not valid_selector(self.selector):
raise ValueError("only bounded CSS selectors are supported")
if any(name in self.model_fields_set and getattr(self, name) is None for name in ("until_selector", "until_target", "scroll_scope", "wait_scope", "complete_only", "row_selector", "batch", "allow_empty", "empty_state", "min_count")):
raise ValueError("optional completion fields must be omitted or have an explicit valid value")
fields = self.model_dump(exclude_none=True)
if self.target is not None and self.kind not in {"click", "read", "extract"}:
raise ValueError("dynamic target only applies to click, read and extract")
common = {"kind", "selector", "target"}
allowed = {
"fill": {"value", "parameter"}, "click": set(), "press": {"key"}, "read": {"output_name"},
"scroll": {"direction", "pixels", "max_scrolls", "until_selector", "until_target", "scroll_scope", "timeout_ms"},
"wait": {"state", "text", "timeout_ms", "min_count", "wait_scope"},
"extract": {"output_name", "fields", "limit", "complete_only", "row_selector", "batch", "allow_empty", "empty_state"},
}[self.kind] | common
if set(fields) - allowed:
raise ValueError("fields do not apply to this action")
if self.kind == "fill" and (self.value is None) == (self.parameter is None):
raise ValueError("fill requires exactly one value or parameter")
if self.until_selector is not None and not valid_selector(self.until_selector, relative=True):
raise ValueError("until_selector requires a bounded CSS selector within the scroll root")
if self.until_target is not None:
if self.until_selector is not None or self.timeout_ms is None:
raise ValueError("until_target requires an explicit time budget and excludes until_selector")
if self.selector in {"body", "html"}:
raise ValueError("dynamic target requires an observed ordinary root")
elif self.kind == "scroll" and self.timeout_ms is not None:
raise ValueError("scroll time budget requires an explicit dynamic target")
if self.scroll_scope is not None and (self.kind != "scroll" or self.until_target is None):
raise ValueError("viewport scope only applies to bounded dynamic target scrolling")
if self.wait_scope is not None:
if self.kind != "wait" or self.state != "visible" or self.min_count is None:
raise ValueError("scoped wait requires explicit visible state and collection count")
if not valid_selector(self.wait_scope, relative=True) or not valid_selector(self.selector, relative=True):
raise ValueError("scoped wait requires ordinary root and descendant CSS selectors")
if self.kind == "press" and self.key != "Enter":
raise ValueError("press supports only an explicit search Enter")
if self.kind in {"read", "extract"} and not FIELD_NAME.fullmatch(self.output_name or ""):
raise ValueError("read and extract require an output name")
if self.kind == "extract":
if not self.fields or len({item.name for item in self.fields}) != len(self.fields):
raise ValueError("extract fields are required and must be unique")
if self.row_selector is not None and (not valid_selector(self.row_selector, relative=True) or self.target is not None):
raise ValueError("scoped rows require relative CSS and cannot use a dynamic target")
if self.batch is not None:
identity = next((item for item in self.fields if item.name == self.batch.dedupe_field), None)
if not self.row_selector or self.complete_only is not True or self.allow_empty is True or not identity or identity.source != "attribute" or not identity.required or not (identity.attribute == "id" or (identity.attribute or "").startswith("data-")):
raise ValueError("batch requires complete scoped rows and a required safe identity attribute")
if self.allow_empty is True and (not self.row_selector or not self.empty_state):
raise ValueError("empty extraction requires scoped rows and explicit empty-state evidence")
if self.empty_state is not None and self.allow_empty is not True:
raise ValueError("empty-state evidence requires allow_empty")
if self.kind == "wait" and (self.state == "text") != (self.text is not None):
raise ValueError("text wait requires exactly one text expectation")
if self.min_count is not None and self.state not in {None, "visible"}:
raise ValueError("min_count applies only to visible waits")
if self.text is not None and not self.text.strip():
raise ValueError("text expectation must be nonempty")
return self