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

@@ -277,6 +277,115 @@ def render_project(project: str) -> str:
)
def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
from atocore.assets import get_asset
artifacts = []
for rel in ctx["relationships"]:
if rel.source_entity_id != entity_id or rel.relationship_type != "evidenced_by":
continue
target = ctx["related_entities"].get(rel.target_entity_id)
if target is None or target.entity_type != "artifact":
continue
artifacts.append(target)
if not artifacts:
return ""
tiles = []
for art in artifacts:
props = art.properties or {}
kind = props.get("kind", "other")
caption = props.get("caption", art.name)
asset_id = props.get("asset_id")
asset = get_asset(asset_id) if asset_id else None
detail_href = f"/wiki/entities/{art.id}"
if kind == "image" and asset and asset.mime_type.startswith("image/"):
full_href = f"/assets/{asset.id}"
thumb = f"/assets/{asset.id}/thumbnail?size=240"
tiles.append(
f'<figure class="evidence-tile">'
f'<a href="{full_href}" target="_blank" rel="noopener">'
f'<img src="{thumb}" alt="{_escape_attr(caption)}" loading="lazy">'
f'</a>'
f'<figcaption><a href="{detail_href}">{_escape_html(caption)}</a></figcaption>'
f'</figure>'
)
elif kind == "pdf" and asset:
full_href = f"/assets/{asset.id}"
tiles.append(
f'<div class="evidence-tile evidence-pdf">'
f'<a href="{full_href}" target="_blank" rel="noopener">'
f'📄 PDF: {_escape_html(caption)}</a>'
f' · <a href="{detail_href}">details</a>'
f'</div>'
)
else:
tiles.append(
f'<div class="evidence-tile evidence-other">'
f'<a href="{detail_href}">📎 {_escape_html(caption)}</a>'
f' <span class="tag">{kind}</span>'
f'</div>'
)
return (
'<h2>Visual evidence</h2>'
f'<div class="evidence-strip">{"".join(tiles)}</div>'
)
def _render_artifact_body(ent) -> list[str]:
"""Render an artifact entity's own image/pdf/caption."""
from atocore.assets import get_asset
props = ent.properties or {}
kind = props.get("kind", "other")
caption = props.get("caption", "")
capture_context = props.get("capture_context", "")
asset_id = props.get("asset_id")
asset = get_asset(asset_id) if asset_id else None
out: list[str] = []
if kind == "image" and asset and asset.mime_type.startswith("image/"):
out.append(
f'<figure class="artifact-full">'
f'<a href="/assets/{asset.id}" target="_blank" rel="noopener">'
f'<img src="/assets/{asset.id}/thumbnail?size=1024" '
f'alt="{_escape_attr(caption or ent.name)}">'
f'</a>'
f'<figcaption>{_escape_html(caption)}</figcaption>'
f'</figure>'
)
elif kind == "pdf" and asset:
out.append(
f'<p>📄 <a href="/assets/{asset.id}" target="_blank" rel="noopener">'
f'Open PDF ({asset.size_bytes // 1024} KB)</a></p>'
)
elif asset_id:
out.append(f'<p class="meta">asset_id: <code>{asset_id}</code> — blob missing</p>')
if capture_context:
out.append('<h2>Capture context</h2>')
out.append(f'<blockquote>{_escape_html(capture_context)}</blockquote>')
return out
def _escape_html(s: str) -> str:
if s is None:
return ""
return (str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
def _escape_attr(s: str) -> str:
return _escape_html(s).replace('"', "&quot;")
def render_entity(entity_id: str) -> str | None:
ctx = get_entity_with_context(entity_id)
if ctx is None:
@@ -297,6 +406,15 @@ def render_entity(entity_id: str) -> str | None:
lines.append(f'<p class="meta">confidence: {ent.confidence} · status: {ent.status} · created: {ent.created_at}</p>')
# Issue F: artifact entities render their own image inline; other
# entities render their EVIDENCED_BY artifacts as a visual strip.
if ent.entity_type == "artifact":
lines.extend(_render_artifact_body(ent))
else:
evidence_html = _render_visual_evidence(ent.id, ctx)
if evidence_html:
lines.append(evidence_html)
if ctx["relationships"]:
lines.append('<h2>Relationships</h2><ul>')
for rel in ctx["relationships"]:
@@ -799,6 +917,14 @@ _TEMPLATE = """<!DOCTYPE html>
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }
.evidence-tile figcaption { font-size: 0.8rem; margin-top: 0.35rem; opacity: 0.85; }
.evidence-pdf, .evidence-other { padding: 0.6rem 0.8rem; font-size: 0.9rem; }
.artifact-full figure, .artifact-full { margin: 0 0 1rem; }
.artifact-full img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.artifact-full figcaption { font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.85; }
.tag { background: var(--accent); color: var(--bg); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75em; margin-left: 0.3rem; }
.search-box { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.search-box input {