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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user