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

@@ -314,6 +314,38 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_aliases_status ON tag_aliases(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_aliases_alias ON tag_aliases(alias)")
# Issue F (visual evidence): binary asset store. One row per unique
# content hash — re-uploading the same file is idempotent. The blob
# itself lives on disk under stored_path; this table is the catalog.
# width/height are populated for image mime types (NULL otherwise).
# source_refs is a JSON array of free-form provenance pointers
# (e.g. "session:<id>", "interaction:<id>") that survive independent
# of the EVIDENCED_BY graph. status=invalid tombstones an asset
# without dropping the row so audit trails stay intact.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
hash_sha256 TEXT UNIQUE NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
width INTEGER,
height INTEGER,
stored_path TEXT NOT NULL,
original_filename TEXT DEFAULT '',
project TEXT DEFAULT '',
caption TEXT DEFAULT '',
source_refs TEXT DEFAULT '[]',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_hash ON assets(hash_sha256)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status)")
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()