277 lines
14 KiB
Python
277 lines
14 KiB
Python
"""HUX-07 scoped multimodal metadata, lineage and transcript corrections.
|
|
|
|
This service never accepts media bytes and has no capture endpoint. It only
|
|
records metadata after an approved autonomy action, plus inert camera/screen
|
|
intents that a human-facing client may send through the HUX-05 approval lane.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from typing import Any
|
|
|
|
from hux import contracts, redaction
|
|
from hux.errors import Conflict, Forbidden, Invalid, NotFound, TooLarge
|
|
from hux.http import Request, Response, Router, page
|
|
from hux.store import check_id, new_id, now_iso
|
|
|
|
CARD = "HUX-07"
|
|
ITEMS = "multimodal_items"
|
|
CORRECTIONS = "transcript_corrections"
|
|
INTENTS = "capture_intents"
|
|
MAX_ITEMS = 2000
|
|
MAX_CORRECTIONS = 10000
|
|
MAX_INTENTS = 1000
|
|
EXECUTABLE_MIMES = frozenset({"text/html", "application/xhtml+xml", "image/svg+xml", "application/xml", "text/xml"})
|
|
SAFE_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".wav", ".webm", ".ogg", ".mp4", ".pdf", ".txt")
|
|
KIND_MIMES = {
|
|
"image": frozenset({"image/jpeg", "image/png", "image/webp"}),
|
|
"audio": frozenset({"audio/wav", "audio/webm", "audio/ogg"}),
|
|
"video": frozenset({"video/webm", "video/mp4"}),
|
|
"document": frozenset({"application/pdf", "text/plain"}),
|
|
}
|
|
ITEM_FIELDS = frozenset({"project_id", "kind", "source", "filename", "mime", "bytes", "hash", "approval_id", "lineage"})
|
|
SCHEMAS = contracts.load_all()
|
|
SCHEMAS["multimodal.schema.json"] = contracts.load_schema("multimodal.schema.json")
|
|
|
|
|
|
def _body(request: Request, allowed: frozenset[str]) -> dict[str, Any]:
|
|
if not isinstance(request.body, dict):
|
|
raise Invalid("body must be a JSON object")
|
|
extra = set(request.body) - allowed
|
|
if extra:
|
|
raise Invalid("unexpected multimodal fields", sorted(extra))
|
|
return request.body
|
|
|
|
|
|
def _scope(request: Request, project_id: Any) -> dict[str, Any]:
|
|
from hux import organization
|
|
|
|
try:
|
|
conversation = request.store.get(organization.CONVERSATIONS, request.params["id"])
|
|
except (Invalid, NotFound) as error:
|
|
raise NotFound("project or conversation not found") from error
|
|
path_project = request.params.get("project_id")
|
|
actual = conversation.get("project_id")
|
|
if not isinstance(project_id, str) or project_id != path_project or not actual or project_id != actual:
|
|
raise NotFound("project or conversation not found")
|
|
if not organization.project_exists(request.store, project_id):
|
|
raise NotFound("project or conversation not found")
|
|
return conversation
|
|
|
|
|
|
def _if_match(request: Request, current: int) -> None:
|
|
expected = request.if_match()
|
|
if expected is None:
|
|
raise Invalid("If-Match is required")
|
|
if expected != current:
|
|
raise Conflict("revision does not match If-Match")
|
|
|
|
|
|
def _key(request: Request) -> str:
|
|
key = request.idempotency_key()
|
|
if not key:
|
|
raise Invalid("Idempotency-Key is required")
|
|
return key
|
|
|
|
|
|
def _digest(body: dict[str, Any]) -> str:
|
|
return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def _replay(request: Request, family: str, key: str, digest: str) -> dict[str, Any] | None:
|
|
for row in request.store.read(family, "idempotency"):
|
|
if row.get("key") != key:
|
|
continue
|
|
if row.get("digest") != digest:
|
|
raise Conflict("Idempotency-Key was already used with different metadata")
|
|
return request.store.get(family, row["id"])
|
|
return None
|
|
|
|
|
|
def _remember(request: Request, family: str, key: str, digest: str, record_id: str) -> None:
|
|
request.store.append(family, "idempotency", {"key": key, "digest": digest, "id": record_id, "at": now_iso()})
|
|
|
|
|
|
def _validate(record: dict[str, Any], pointer: str) -> None:
|
|
problems = contracts.validate("multimodal.schema.json", record, SCHEMAS, pointer)
|
|
if problems:
|
|
raise Invalid("multimodal record failed contract validation", problems)
|
|
|
|
|
|
def _approval(request: Request, approval_id: Any, source: Any) -> str:
|
|
from hux import policy
|
|
|
|
try:
|
|
approval = policy.load_approval(request.store, check_id(approval_id))
|
|
except (Invalid, NotFound) as error:
|
|
raise Forbidden("approved autonomy action is required") from error
|
|
capability = "external_side_effect" if source in {"camera", "screen"} else "artifact_write"
|
|
if approval.get("status") != "approved" or approval.get("conversation_id") != request.params["id"]:
|
|
raise Forbidden("approved autonomy action is required")
|
|
if approval.get("capability") != capability:
|
|
raise Forbidden("approval does not cover this multimodal action")
|
|
return approval["id"]
|
|
|
|
|
|
def _lineage(request: Request, body: Any, project_id: str) -> dict[str, Any] | None:
|
|
if body is None:
|
|
return None
|
|
if not isinstance(body, dict) or set(body) - {"parent_item_id", "artifact_id", "artifact_version"}:
|
|
raise Invalid("lineage must contain only a parent item or artifact version")
|
|
if "parent_item_id" in body:
|
|
if len(body) != 1:
|
|
raise Invalid("parent-item lineage cannot also name an artifact")
|
|
parent = request.store.get(ITEMS, check_id(body["parent_item_id"]))
|
|
if parent["conversation_id"] != request.params["id"] or parent["project_id"] != project_id:
|
|
raise NotFound("lineage item not found")
|
|
return {"parent_item_id": parent["id"]}
|
|
if set(body) != {"artifact_id", "artifact_version"} or not isinstance(body["artifact_version"], int):
|
|
raise Invalid("artifact lineage needs artifact_id and artifact_version")
|
|
from hux import artifacts
|
|
|
|
artifact = request.store.get(artifacts.FAMILY, check_id(body["artifact_id"]))
|
|
if artifact.get("project_id") != project_id or artifact.get("conversation_id") != request.params["id"]:
|
|
raise NotFound("lineage artifact not found")
|
|
version = next((row for row in artifact["versions"] if row["version"] == body["artifact_version"]), None)
|
|
if version is None:
|
|
raise NotFound("lineage artifact version not found")
|
|
if version["content_ref"]["mime"].lower() in EXECUTABLE_MIMES or artifact.get("type") in {"html", "svg"}:
|
|
raise Invalid("executable HTML and SVG lineage is not accepted")
|
|
return {"artifact_id": artifact["id"], "artifact_version": version["version"]}
|
|
|
|
|
|
def create_item(request: Request) -> Response:
|
|
"""Register metadata for approved media; bytes are never accepted here."""
|
|
body = _body(request, ITEM_FIELDS)
|
|
conversation = _scope(request, body.get("project_id"))
|
|
key, digest = _key(request), _digest(body)
|
|
with request.store.lock(ITEMS):
|
|
replayed = _replay(request, ITEMS, key, digest)
|
|
if replayed is not None:
|
|
return Response(200, replayed, {"ETag": str(replayed["revision"]), "HUX-Replayed": "true", "Cache-Control": "no-store"})
|
|
_if_match(request, 0)
|
|
if request.store.count(ITEMS) >= MAX_ITEMS:
|
|
raise TooLarge("multimodal item limit reached")
|
|
filename, mime = body.get("filename"), body.get("mime")
|
|
if not isinstance(filename, str) or not filename.lower().endswith(SAFE_SUFFIXES):
|
|
raise Invalid("filename is missing or executable")
|
|
if not isinstance(mime, str) or mime.lower() in EXECUTABLE_MIMES:
|
|
raise Invalid("executable HTML, XML and SVG are not accepted")
|
|
if mime.lower() not in KIND_MIMES.get(body.get("kind"), frozenset()):
|
|
raise Invalid("kind and MIME type do not agree")
|
|
approval_id = _approval(request, body.get("approval_id"), body.get("source"))
|
|
lineage = _lineage(request, body.get("lineage"), body["project_id"])
|
|
record = {
|
|
"schema": "hux.multimodal_item.v1", "id": new_id("mmi"), "owner": request.identity.subject,
|
|
"project_id": body["project_id"], "conversation_id": conversation["id"], "kind": body.get("kind"),
|
|
"source": body.get("source"), "filename": filename, "mime": mime.lower(), "bytes": body.get("bytes"),
|
|
"hash": body.get("hash"), "approval_id": approval_id, "status": "metadata_only", "created_at": now_iso(),
|
|
}
|
|
if lineage:
|
|
record["lineage"] = lineage
|
|
_validate({**record, "revision": 1}, "/$defs/item")
|
|
stored = request.store.put(ITEMS, record, expected_revision=0)
|
|
_remember(request, ITEMS, key, digest, stored["id"])
|
|
request.audit("multimodal.create", stored["id"])
|
|
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
|
|
|
|
|
|
def list_items(request: Request) -> Response:
|
|
"""List metadata in one project/conversation scope."""
|
|
project_id = request.params["project_id"]
|
|
_scope(request, project_id)
|
|
items = [row for row in request.store.scan(ITEMS) if row["project_id"] == project_id and row["conversation_id"] == request.params["id"]]
|
|
request.audit("multimodal.list", request.params["id"])
|
|
response = page(items)
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
def get_item(request: Request) -> Response:
|
|
"""Read one metadata record only within its path scope."""
|
|
project_id = request.params["project_id"]
|
|
_scope(request, project_id)
|
|
try:
|
|
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
|
|
except (Invalid, NotFound) as error:
|
|
raise NotFound("multimodal item not found") from error
|
|
if item["project_id"] != project_id or item["conversation_id"] != request.params["id"]:
|
|
raise NotFound("multimodal item not found")
|
|
request.audit("multimodal.read", item["id"])
|
|
return Response(200, item, {"ETag": str(item["revision"]), "Cache-Control": "no-store"})
|
|
|
|
|
|
def correct_transcript(request: Request) -> Response:
|
|
"""Append an immutable transcript correction and point the media head to it."""
|
|
body = _body(request, frozenset({"project_id", "replacement_text"}))
|
|
_scope(request, body.get("project_id"))
|
|
key, digest = _key(request), _digest({**body, "item_id": request.params["item_id"]})
|
|
with request.store.lock(CORRECTIONS):
|
|
replayed = _replay(request, CORRECTIONS, key, digest)
|
|
if replayed is not None:
|
|
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
|
item = request.store.get(ITEMS, check_id(request.params["item_id"]))
|
|
if item["project_id"] != body["project_id"] or item["conversation_id"] != request.params["id"]:
|
|
raise NotFound("multimodal item not found")
|
|
if item["kind"] not in {"audio", "video"}:
|
|
raise Invalid("only audio or video transcripts can be corrected")
|
|
_if_match(request, item["revision"])
|
|
text = body.get("replacement_text")
|
|
if not isinstance(text, str) or not text.strip() or len(text) > 10000:
|
|
raise Invalid("replacement_text must contain 1 to 10000 characters")
|
|
if request.store.count(CORRECTIONS) >= MAX_CORRECTIONS:
|
|
raise TooLarge("transcript correction limit reached")
|
|
record = {
|
|
"schema": "hux.transcript_correction.v1", "id": new_id("trc"), "owner": request.identity.subject,
|
|
"project_id": body["project_id"], "conversation_id": request.params["id"], "item_id": item["id"],
|
|
"replacement_text": text, "created_at": now_iso(),
|
|
}
|
|
_validate({**record, "revision": 1}, "/$defs/correction")
|
|
stored = request.store.put(CORRECTIONS, record, expected_revision=0)
|
|
with request.store.lock(ITEMS):
|
|
updated = request.store.put(ITEMS, {**item, "latest_correction_id": stored["id"]}, item["revision"])
|
|
_remember(request, CORRECTIONS, key, digest, stored["id"])
|
|
request.audit("multimodal.correct", stored["id"])
|
|
return Response(201, stored, {"ETag": str(updated["revision"]), "Cache-Control": "no-store"})
|
|
|
|
|
|
def create_capture_intent(request: Request) -> Response:
|
|
"""Record an inert camera/screen proposal; it never grants execution."""
|
|
body = _body(request, frozenset({"project_id", "source", "purpose"}))
|
|
_scope(request, body.get("project_id"))
|
|
key, digest = _key(request), _digest(body)
|
|
with request.store.lock(INTENTS):
|
|
replayed = _replay(request, INTENTS, key, digest)
|
|
if replayed is not None:
|
|
return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"})
|
|
_if_match(request, 0)
|
|
if request.store.count(INTENTS) >= MAX_INTENTS:
|
|
raise TooLarge("capture intent limit reached")
|
|
purpose = body.get("purpose")
|
|
if body.get("source") not in {"camera", "screen"} or not isinstance(purpose, str) or not purpose.strip():
|
|
raise Invalid("source and purpose are required")
|
|
purpose = redaction.scrub_text(purpose[:280])[0]
|
|
record = {
|
|
"schema": "hux.capture_intent.v1", "id": new_id("cap"), "owner": request.identity.subject,
|
|
"project_id": body["project_id"], "conversation_id": request.params["id"], "source": body["source"],
|
|
"purpose": purpose, "status": "proposed", "requires_approval": True, "execution_allowed": False,
|
|
"created_at": now_iso(),
|
|
}
|
|
_validate({**record, "revision": 1}, "/$defs/capture_intent")
|
|
stored = request.store.put(INTENTS, record, expected_revision=0)
|
|
_remember(request, INTENTS, key, digest, stored["id"])
|
|
request.audit("multimodal.intent", stored["id"])
|
|
return Response(201, stored, {"ETag": "1", "Cache-Control": "no-store"})
|
|
|
|
|
|
def register(router: Router) -> None:
|
|
"""Attach metadata-only HUX-07 routes."""
|
|
base = "/hux/v1/projects/{project_id}/conversations/{id}"
|
|
router.add("POST", base + "/multimodal/items", CARD, "multimodal.create", create_item)
|
|
router.add("GET", base + "/multimodal/items", CARD, "multimodal.list", list_items)
|
|
router.add("GET", base + "/multimodal/items/{item_id}", CARD, "multimodal.read", get_item)
|
|
router.add("POST", base + "/multimodal/items/{item_id}/transcript-corrections", CARD, "multimodal.correct", correct_transcript)
|
|
router.add("POST", base + "/capture-intents", CARD, "multimodal.intent", create_capture_intent)
|