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)