100 lines
4.3 KiB
Python
100 lines
4.3 KiB
Python
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Literal
|
|
|
|
from jsonschema import Draft7Validator
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
_catalog = json.loads(
|
|
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
|
)
|
|
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
|
|
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
|
|
|
|
|
|
class UiModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class AnswerUiElement(UiModel):
|
|
type: str
|
|
props: dict[str, Any]
|
|
children: list[Key] = Field(max_length=48)
|
|
|
|
|
|
class AnswerUiSpec(UiModel):
|
|
root: Key
|
|
elements: dict[Key, AnswerUiElement]
|
|
|
|
|
|
class AnswerUiBlock(UiModel):
|
|
version: Literal["answer-ui@1"]
|
|
block_id: Key
|
|
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
|
spec: AnswerUiSpec
|
|
|
|
@model_validator(mode="after")
|
|
def validate_components(self):
|
|
spec = self.spec
|
|
if not 1 <= len(spec.elements) <= 48:
|
|
raise ValueError("UI requires 1 to 48 elements")
|
|
# Match the existing JavaScript serialized character limit, including UTF-16.
|
|
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
|
|
if len(serialized.encode("utf-16-le")) // 2 > 100000:
|
|
raise ValueError("UI block is too large")
|
|
for element in spec.elements.values():
|
|
validator = _validators.get(element.type)
|
|
if validator is None or not validator.is_valid(element.props):
|
|
raise ValueError("Unknown UI component or invalid properties")
|
|
if element.children and element.type not in {"Stack", "Section"}:
|
|
raise ValueError("Leaf components cannot contain children")
|
|
props = element.props
|
|
if element.type == "Chart":
|
|
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
|
|
raise ValueError("Chart series must match the axis")
|
|
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
|
|
raise ValueError("Chart values must be finite")
|
|
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
|
|
raise ValueError("Table row width must match its columns")
|
|
if element.type == "FollowUpForm":
|
|
names = [field["name"] for field in props["fields"]]
|
|
if len(names) != len(set(names)):
|
|
raise ValueError("Form field names must be unique")
|
|
visited: set[str] = set()
|
|
|
|
def visit(key: str, depth: int):
|
|
if depth > 12 or key in visited or key not in spec.elements:
|
|
raise ValueError("UI must be a bounded tree without duplicate or missing references")
|
|
visited.add(key)
|
|
for child in spec.elements[key].children:
|
|
visit(child, depth + 1)
|
|
|
|
visit(spec.root, 0)
|
|
if len(visited) != len(spec.elements):
|
|
raise ValueError("UI contains unreachable elements")
|
|
return self
|
|
|
|
|
|
class ReportUiBlock(UiModel):
|
|
block: AnswerUiBlock
|
|
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
|
|
|
|
|
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
|
ids = [item.block.block_id for item in blocks]
|
|
if len(ids) != len(set(ids)):
|
|
raise ValueError("Report UI block ids must be unique")
|
|
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
|
|
if len(serialized.encode("utf-8")) > 400000:
|
|
raise ValueError("Report UI exceeds 400 KB")
|
|
for item in blocks:
|
|
if not set(item.evidence_refs) <= set(evidence_refs):
|
|
raise ValueError("UI evidence must be included in report evidence")
|
|
for element in item.block.spec.elements.values():
|
|
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
|
|
raise ValueError("UI evidence component must reference its block evidence")
|