mattzh72--articraft
387 行
16 KiB
Python
387 行
16 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Protocol
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from agent.prompts import (
|
|
DESIGNER_PROMPT_NAME,
|
|
GEMINI_DESIGNER_PROMPT_NAME,
|
|
OPENAI_DESIGNER_PROMPT_NAME,
|
|
)
|
|
from storage.identifiers import validate_record_id
|
|
from storage.repo import StorageRepo
|
|
from storage.revisions import (
|
|
active_cost_path,
|
|
active_inputs_dir,
|
|
active_model_path,
|
|
active_prompt_path,
|
|
active_provenance_path,
|
|
active_revision_id,
|
|
active_traces_dir,
|
|
validate_revision_id,
|
|
)
|
|
from storage.trajectories import (
|
|
COMPRESSED_TRAJECTORY_FILENAME,
|
|
SYSTEM_PROMPT_FILENAMES,
|
|
TRAJECTORY_FILENAME,
|
|
unroll_record_trajectory,
|
|
)
|
|
from viewer.api.media import trace_media_type
|
|
from viewer.api.store_types import MaterializeRecordAssetsResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SYSTEM_PROMPT_REQUEST_NAMES = SYSTEM_PROMPT_FILENAMES | {
|
|
DESIGNER_PROMPT_NAME,
|
|
OPENAI_DESIGNER_PROMPT_NAME,
|
|
GEMINI_DESIGNER_PROMPT_NAME,
|
|
}
|
|
|
|
|
|
class MaterializingViewerStore(Protocol):
|
|
repo: StorageRepo
|
|
|
|
def materialize_record_assets(
|
|
self,
|
|
record_id: str,
|
|
*,
|
|
force: bool = False,
|
|
target: str = "full",
|
|
) -> MaterializeRecordAssetsResult: ...
|
|
|
|
|
|
class ViewerFileResolver:
|
|
def __init__(self, store: MaterializingViewerStore) -> None:
|
|
self.store = store
|
|
self.repo = store.repo
|
|
|
|
def resolve_record_folder(self, record_id: str) -> Path:
|
|
self._validate_record_id(record_id)
|
|
record_dir = self.repo.layout.record_dir(record_id)
|
|
if not record_dir.exists() or not record_dir.is_dir():
|
|
raise HTTPException(status_code=404, detail=f"Record folder not found: {record_id}")
|
|
return record_dir
|
|
|
|
def resolve_record_target(self, record_id: str, file_path: str) -> tuple[Path, Path]:
|
|
self._validate_record_id(record_id)
|
|
record_dir = self.repo.layout.record_dir(record_id)
|
|
|
|
if not record_dir.exists():
|
|
raise HTTPException(status_code=404, detail=f"Record not found: {record_id}")
|
|
|
|
requested_path = self._validated_relative_path(file_path)
|
|
|
|
if requested_path.parts == ("model.urdf",):
|
|
root = self.repo.layout.record_materialization_dir(record_id).resolve()
|
|
target = self.repo.layout.record_materialization_urdf_path(record_id).resolve()
|
|
elif requested_path.parts == ("compile_report.json",):
|
|
root = self.repo.layout.record_materialization_dir(record_id).resolve()
|
|
target = self.repo.layout.record_materialization_compile_report_path(
|
|
record_id
|
|
).resolve()
|
|
elif len(requested_path.parts) >= 2 and requested_path.parts[0] == "assets":
|
|
root = self.repo.layout.record_materialization_dir(record_id).resolve()
|
|
target = (root / requested_path).resolve()
|
|
elif requested_path.parts in {
|
|
("prompt.txt",),
|
|
("model.py",),
|
|
("provenance.json",),
|
|
("cost.json",),
|
|
}:
|
|
record = self.repo.read_json(self.repo.layout.record_metadata_path(record_id))
|
|
if not isinstance(record, dict):
|
|
raise HTTPException(status_code=404, detail=f"Record not found: {record_id}")
|
|
root = self.repo.layout.record_revision_dir(
|
|
record_id,
|
|
active_revision_id(self.repo, record_id, record=record),
|
|
).resolve()
|
|
active_paths = {
|
|
("prompt.txt",): active_prompt_path(self.repo, record_id, record=record),
|
|
("model.py",): active_model_path(self.repo, record_id, record=record),
|
|
("provenance.json",): active_provenance_path(self.repo, record_id, record=record),
|
|
("cost.json",): active_cost_path(self.repo, record_id, record=record),
|
|
}
|
|
target = active_paths[requested_path.parts].resolve()
|
|
root = target.parent.resolve()
|
|
elif len(requested_path.parts) >= 2 and requested_path.parts[0] == "inputs":
|
|
record = self.repo.read_json(self.repo.layout.record_metadata_path(record_id))
|
|
if not isinstance(record, dict):
|
|
raise HTTPException(status_code=404, detail=f"Record not found: {record_id}")
|
|
root = active_inputs_dir(self.repo, record_id, record=record).resolve()
|
|
target = (root / Path(*requested_path.parts[1:])).resolve()
|
|
else:
|
|
root = record_dir.resolve()
|
|
target = (record_dir / requested_path).resolve()
|
|
|
|
self._ensure_within_root(target, root)
|
|
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
|
|
|
return root, target
|
|
|
|
async def resolve_record_target_with_materialization(
|
|
self, record_id: str, file_path: str
|
|
) -> tuple[Path, Path]:
|
|
requested_path = Path(file_path)
|
|
try:
|
|
return self.resolve_record_target(record_id, file_path)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 404 or not should_attempt_materialize_for_record_path(file_path):
|
|
raise
|
|
try:
|
|
await asyncio.to_thread(
|
|
self.store.materialize_record_assets,
|
|
record_id,
|
|
target="visual",
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
try:
|
|
return self.resolve_record_target(record_id, file_path)
|
|
except HTTPException as exc:
|
|
needs_forced_rebuild = exc.status_code == 404 and (
|
|
len(requested_path.parts) >= 2
|
|
and requested_path.parts[0] == "assets"
|
|
and requested_path.parts[1] in {"meshes", "glb", "viewer"}
|
|
)
|
|
if not needs_forced_rebuild:
|
|
raise
|
|
try:
|
|
await asyncio.to_thread(
|
|
self.store.materialize_record_assets,
|
|
record_id,
|
|
force=True,
|
|
target="visual",
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
return self.resolve_record_target(record_id, file_path)
|
|
|
|
def resolve_record_trace_target(self, record_id: str, file_path: str) -> tuple[Path, str]:
|
|
self._validate_record_id(record_id)
|
|
record_dir = self.repo.layout.record_dir(record_id)
|
|
record = self.repo.read_json(self.repo.layout.record_metadata_path(record_id))
|
|
|
|
if not record_dir.exists() or not isinstance(record, dict):
|
|
raise HTTPException(status_code=404, detail=f"Record not found: {record_id}")
|
|
if self._record_trace_unavailable(record):
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
|
|
provenance = self.repo.read_json(
|
|
active_provenance_path(self.repo, record_id, record=record)
|
|
)
|
|
|
|
requested_path = self._validated_relative_path(file_path)
|
|
if len(requested_path.parts) != 1:
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
|
|
requested_name = requested_path.name
|
|
revision_id = active_revision_id(self.repo, record_id, record=record)
|
|
trace_root = active_traces_dir(self.repo, record_id, record=record).resolve()
|
|
|
|
if requested_name == TRAJECTORY_FILENAME:
|
|
try:
|
|
target = unroll_record_trajectory(
|
|
self.repo,
|
|
record_id,
|
|
revision_id=revision_id if record.get("schema_version") == 3 else None,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Trace file not found: {file_path}"
|
|
) from exc
|
|
return target, "application/x-ndjson"
|
|
|
|
if requested_name == COMPRESSED_TRAJECTORY_FILENAME:
|
|
target = (trace_root / requested_name).resolve()
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
return target, "application/zstd"
|
|
|
|
prompting = provenance.get("prompting") if isinstance(provenance, dict) else None
|
|
prompt_sha = (
|
|
str(prompting.get("system_prompt_sha256"))
|
|
if isinstance(prompting, dict) and prompting.get("system_prompt_sha256")
|
|
else None
|
|
)
|
|
prompt_name = (
|
|
str(prompting.get("system_prompt_file"))
|
|
if isinstance(prompting, dict) and prompting.get("system_prompt_file")
|
|
else None
|
|
)
|
|
if requested_name in SYSTEM_PROMPT_REQUEST_NAMES or (
|
|
prompt_name is not None and requested_name == prompt_name
|
|
):
|
|
if prompt_sha:
|
|
target = self.repo.layout.system_prompt_path(prompt_sha)
|
|
if target.exists() and target.is_file():
|
|
return target, "text/plain"
|
|
logger.warning(
|
|
"Canonical system prompt missing for record_id=%s sha=%s requested_name=%s prompt_name=%s; refusing trace-local fallback",
|
|
record_id,
|
|
prompt_sha,
|
|
requested_name,
|
|
prompt_name,
|
|
)
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
fallback_name = prompt_name or requested_name
|
|
target = (trace_root / Path(fallback_name).name).resolve()
|
|
if target.exists() and target.is_file():
|
|
logger.warning(
|
|
"Serving trace-local system prompt fallback for record_id=%s requested_name=%s prompt_name=%s",
|
|
record_id,
|
|
requested_name,
|
|
prompt_name,
|
|
)
|
|
return target, "text/plain"
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
|
|
target = (trace_root / requested_path).resolve()
|
|
self._ensure_within_root(target, trace_root)
|
|
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
return target, trace_media_type(target)
|
|
|
|
def resolve_record_revision_target(
|
|
self,
|
|
record_id: str,
|
|
revision_id: str,
|
|
file_path: str,
|
|
) -> tuple[Path, Path]:
|
|
self._validate_record_id(record_id)
|
|
revision_id = validate_revision_id(revision_id)
|
|
root = self.repo.layout.record_revision_dir(record_id, revision_id).resolve()
|
|
if not root.exists() or not root.is_dir():
|
|
raise HTTPException(status_code=404, detail=f"Revision not found: {revision_id}")
|
|
requested_path = self._validated_relative_path(file_path)
|
|
target = (root / requested_path).resolve()
|
|
self._ensure_within_root(target, root)
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
|
return root, target
|
|
|
|
def resolve_record_revision_trace_target(
|
|
self,
|
|
record_id: str,
|
|
revision_id: str,
|
|
file_path: str,
|
|
) -> tuple[Path, str]:
|
|
self._validate_record_id(record_id)
|
|
record = self.repo.read_json(self.repo.layout.record_metadata_path(record_id))
|
|
if not isinstance(record, dict):
|
|
raise HTTPException(status_code=404, detail=f"Record not found: {record_id}")
|
|
if self._record_trace_unavailable(record):
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
|
|
requested_path = self._validated_relative_path(file_path)
|
|
if len(requested_path.parts) != 1:
|
|
raise HTTPException(status_code=400, detail="Invalid file path")
|
|
revision_id = validate_revision_id(revision_id)
|
|
requested_name = requested_path.name
|
|
trace_root = self.repo.layout.record_revision_traces_dir(record_id, revision_id).resolve()
|
|
if requested_name == TRAJECTORY_FILENAME:
|
|
try:
|
|
target = unroll_record_trajectory(
|
|
self.repo,
|
|
record_id,
|
|
revision_id=revision_id,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Trace file not found: {file_path}"
|
|
) from exc
|
|
return target, "application/x-ndjson"
|
|
target = (trace_root / requested_name).resolve()
|
|
self._ensure_within_root(target, trace_root)
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"Trace file not found: {file_path}")
|
|
return target, trace_media_type(target)
|
|
|
|
def _record_trace_unavailable(self, record: dict[str, Any]) -> bool:
|
|
creator = record.get("creator")
|
|
return isinstance(creator, dict) and creator.get("trace_available") is False
|
|
|
|
def resolve_staging_root(self, run_id: str, record_id: str) -> Path:
|
|
self._validate_run_id(run_id)
|
|
self._validate_record_id(record_id)
|
|
|
|
staging_dir = self.repo.layout.run_staging_dir(run_id) / record_id
|
|
if not staging_dir.exists() or not staging_dir.is_dir():
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Staging entry not found: run_id={run_id} record_id={record_id}",
|
|
)
|
|
return staging_dir.resolve()
|
|
|
|
def resolve_staging_target(
|
|
self, run_id: str, record_id: str, file_path: str
|
|
) -> tuple[Path, Path]:
|
|
staging_dir = self.resolve_staging_root(run_id, record_id)
|
|
requested_path = self._validated_relative_path(file_path)
|
|
target = (staging_dir / requested_path).resolve()
|
|
self._ensure_within_root(target, staging_dir)
|
|
|
|
if not target.exists() or not target.is_file():
|
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
|
|
|
return staging_dir, target
|
|
|
|
def resolve_staging_trace_target(
|
|
self,
|
|
run_id: str,
|
|
record_id: str,
|
|
file_path: str,
|
|
) -> tuple[Path, str]:
|
|
requested_path = self._validated_relative_path(file_path)
|
|
if len(requested_path.parts) != 1:
|
|
raise HTTPException(status_code=400, detail="Invalid file path")
|
|
|
|
requested_name = requested_path.name
|
|
_, target = self.resolve_staging_target(run_id, record_id, f"traces/{requested_name}")
|
|
|
|
return target, trace_media_type(target)
|
|
|
|
def _validate_record_id(self, record_id: str) -> None:
|
|
try:
|
|
validate_record_id(record_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid record ID format")
|
|
|
|
@staticmethod
|
|
def _validate_run_id(run_id: str) -> None:
|
|
if not run_id.startswith("run_"):
|
|
raise HTTPException(status_code=400, detail="Invalid run ID format")
|
|
|
|
@staticmethod
|
|
def _validated_relative_path(file_path: str) -> Path:
|
|
requested_path = Path(file_path)
|
|
if requested_path.is_absolute() or ".." in requested_path.parts:
|
|
raise HTTPException(status_code=400, detail="Invalid file path")
|
|
return requested_path
|
|
|
|
@staticmethod
|
|
def _ensure_within_root(target: Path, root: Path) -> None:
|
|
try:
|
|
target.relative_to(root)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=403, detail="Access denied") from exc
|
|
|
|
|
|
def should_attempt_materialize_for_record_path(file_path: str) -> bool:
|
|
requested_path = Path(file_path)
|
|
if requested_path.parts == ("model.urdf",):
|
|
return True
|
|
if len(requested_path.parts) >= 2 and requested_path.parts[0] == "assets":
|
|
return requested_path.parts[1] in {"meshes", "glb", "viewer"}
|
|
return False
|