feat(assets): binary asset store + artifact entity + wiki evidence (Issue F)

Wires visual evidence into the knowledge graph. Images, PDFs, and CAD
exports can now be uploaded, deduped by SHA-256, thumbnailed, linked to
entities via EVIDENCED_BY, and rendered inline on wiki pages. Unblocks
AKC uploading voice-session screenshots alongside extracted entities.

- assets/ module: store_asset (hash dedup + MIME allowlist + 20 MB cap),
  get_asset_binary, get_thumbnail (Pillow, on-disk cache under
  .thumbnails/<size>/), list_orphan_assets, invalidate_asset
- models/database.py: new `assets` table + indexes
- engineering/service.py: `artifact` added to ENTITY_TYPES
- api/routes.py: POST /assets (multipart), GET /assets/{id},
  /assets/{id}/thumbnail, /assets/{id}/meta, /admin/assets/orphans,
  DELETE /assets/{id} (409 if still referenced),
  GET /entities/{id}/evidence (EVIDENCED_BY artifacts with asset meta)
- main.py: all new paths aliased under /v1
- engineering/wiki.py: entity pages render EVIDENCED_BY → artifact as a
  "Visual evidence" thumbnail strip; artifact pages render the full
  image + caption + capture_context
- deploy/dalidou/docker-compose.yml: bind-mount ${ATOCORE_ASSETS_DIR}
- config.py: assets_dir + assets_max_upload_bytes settings
- requirements.txt + pyproject.toml: python-multipart, Pillow>=10.0.0
- tests/test_assets.py: 16 tests (dedup, cap, thumbnail cache, orphan
  detection, invalidate gating, API upload/fetch, evidence, v1 aliases,
  wiki rendering)
- DEV-LEDGER.md: session log + cleanup note + test_count 478 -> 494

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:46:52 -04:00
parent b1a3dd071e
commit 069d155585
13 changed files with 1016 additions and 3 deletions

View File

@@ -2,8 +2,8 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
import atocore.config as _config
@@ -2377,3 +2377,177 @@ def api_debug_context() -> dict:
if pack is None:
return {"message": "No context pack built yet."}
return _pack_to_dict(pack)
# --- Issue F: binary asset store (visual evidence) ---
@router.post("/assets")
async def api_upload_asset(
file: UploadFile = File(...),
project: str = Form(""),
caption: str = Form(""),
source_refs: str = Form(""),
) -> dict:
"""Upload a binary asset (image, PDF, CAD export).
Idempotent on SHA-256 content hash. ``source_refs`` is a JSON-encoded
list of provenance pointers (e.g. ``["session:<id>"]``); pass an
empty string for none. MIME type is inferred from the upload's
Content-Type header.
"""
from atocore.assets import (
AssetTooLarge,
AssetTypeNotAllowed,
store_asset,
)
import json as _json
data = await file.read()
try:
refs = _json.loads(source_refs) if source_refs else []
if not isinstance(refs, list):
raise ValueError("source_refs must be a JSON array")
refs = [str(r) for r in refs]
except (ValueError, _json.JSONDecodeError) as e:
raise HTTPException(
status_code=400,
detail=f"source_refs must be a JSON array of strings: {e}",
)
mime_type = (file.content_type or "").split(";", 1)[0].strip()
if not mime_type:
raise HTTPException(
status_code=400,
detail="Upload missing Content-Type; cannot determine mime_type",
)
try:
asset = store_asset(
data=data,
mime_type=mime_type,
original_filename=file.filename or "",
project=project or "",
caption=caption or "",
source_refs=refs,
)
except AssetTooLarge as e:
raise HTTPException(status_code=413, detail=str(e))
except AssetTypeNotAllowed as e:
raise HTTPException(status_code=415, detail=str(e))
return asset.to_dict()
@router.get("/assets/{asset_id}")
def api_get_asset_binary(asset_id: str):
"""Return the original binary with its stored Content-Type."""
from atocore.assets import AssetNotFound, get_asset_binary
try:
asset, data = get_asset_binary(asset_id)
except AssetNotFound as e:
raise HTTPException(status_code=404, detail=str(e))
headers = {
"Cache-Control": "private, max-age=3600",
"ETag": f'"{asset.hash_sha256}"',
}
return Response(content=data, media_type=asset.mime_type, headers=headers)
@router.get("/assets/{asset_id}/thumbnail")
def api_get_asset_thumbnail(asset_id: str, size: int = 240):
"""Return a generated thumbnail (images only). Max side ``size`` px."""
from atocore.assets import AssetError, AssetNotFound, get_thumbnail
try:
asset, data = get_thumbnail(asset_id, size=size)
except AssetNotFound as e:
raise HTTPException(status_code=404, detail=str(e))
except AssetError as e:
raise HTTPException(status_code=415, detail=str(e))
headers = {
"Cache-Control": "private, max-age=86400",
"ETag": f'"{asset.hash_sha256}-{size}"',
}
return Response(content=data, media_type="image/jpeg", headers=headers)
@router.get("/assets/{asset_id}/meta")
def api_get_asset_meta(asset_id: str) -> dict:
"""Return asset metadata without the binary."""
from atocore.assets import get_asset
asset = get_asset(asset_id)
if asset is None:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return asset.to_dict()
@router.get("/admin/assets/orphans")
def api_list_asset_orphans(limit: int = 200) -> dict:
"""List assets with no referencing active entity."""
from atocore.assets import list_orphan_assets
orphans = list_orphan_assets(limit=limit)
return {
"orphans": [a.to_dict() for a in orphans],
"count": len(orphans),
}
@router.delete("/assets/{asset_id}")
def api_invalidate_asset(asset_id: str) -> dict:
"""Tombstone an asset. No-op if still referenced by an active entity."""
from atocore.assets import get_asset, invalidate_asset
if get_asset(asset_id) is None:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
ok = invalidate_asset(asset_id, actor="api-http")
if not ok:
raise HTTPException(
status_code=409,
detail=f"Asset {asset_id} is still referenced; "
"unlink EVIDENCED_BY relationships or retarget entity.properties.asset_id first",
)
return {"status": "invalidated", "id": asset_id}
@router.get("/entities/{entity_id}/evidence")
def api_get_entity_evidence(entity_id: str) -> dict:
"""Return artifact entities linked to this one via EVIDENCED_BY.
Each entry carries the artifact entity plus its resolved asset
metadata so the caller can build thumbnail URLs without a second
query. Non-artifact evidenced_by targets are skipped (the assumption
is that visual evidence is always an artifact entity).
"""
from atocore.engineering.service import (
get_entity,
get_relationships,
)
from atocore.assets import get_asset
entity = get_entity(entity_id)
if entity is None:
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
rels = get_relationships(entity_id, direction="outgoing")
evidence: list[dict] = []
for rel in rels:
if rel.relationship_type != "evidenced_by":
continue
target = get_entity(rel.target_entity_id)
if target is None or target.entity_type != "artifact":
continue
asset_id = (target.properties or {}).get("asset_id")
asset = get_asset(asset_id) if asset_id else None
evidence.append({
"entity_id": target.id,
"name": target.name,
"kind": (target.properties or {}).get("kind", "other"),
"caption": (target.properties or {}).get("caption", ""),
"capture_context": (target.properties or {}).get("capture_context", ""),
"asset": asset.to_dict() if asset else None,
"relationship_id": rel.id,
})
return {"entity_id": entity_id, "evidence": evidence, "count": len(evidence)}