Daily Art engine: museum-guide blurb (grounded LLM) + extracted palette

- daily_art gains blurb + palette columns (idempotent migration).
- art._palette: Pillow median-cut to ~5 hex colors from the cached image (best-
  effort → [] on any failure). art._blurb: a warm 2-3 sentence "what you're
  looking at" note grounded in the Met catalogue (title/artist/bio/date/medium/
  classification/culture/tags). Prompt leans on context/significance and the
  title+tags for subject — explicitly NOT asserting literal composition (figure
  counts/poses) it can't see, since the model can't view the image. Markdown
  stripped from the output.
- pick_daily generates both (client optional → blurb skipped when absent); cycle
  + art CLI pass an LLM client. /api/art/today exposes blurb + palette.
- Backfilled the last 3 days on host (Veteran / Magnolia Vase / Bierstadt).
- scripts/art_blurb_palette_backfill.py for in-place backfill (no re-pick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-23 20:12:54 -04:00
parent 79ecb800af
commit ed814c97b9
7 changed files with 172 additions and 10 deletions
+52
View File
@@ -44,6 +44,58 @@ def test_harvest_dedupes_into_pool(conn):
assert art.harvest_pool(conn)["added"] == 0 # idempotent
def test_palette_extracts_hex_colors(tmp_path):
from PIL import Image
p = tmp_path / "img.png"
im = Image.new("RGB", (60, 60), (200, 30, 30)) # mostly red...
for x in range(60):
for y in range(30):
im.putpixel((x, y), (30, 150, 70)) # ...top half green
im.save(p)
cols = art._palette(p, n=3)
assert 1 <= len(cols) <= 3
assert all(c.startswith("#") and len(c) == 7 for c in cols)
def test_palette_bad_image_is_empty(tmp_path):
p = tmp_path / "bad.jpg"
p.write_bytes(b"\xff\xd8\xff" + b"x" * 500) # not a decodable image
assert art._palette(p) == []
class _FakeClient:
def __init__(self, text="A quiet wheat field at dusk."):
self.text, self.seen = text, None
def chat_text(self, messages):
self.seen = messages
return self.text
def test_blurb_grounds_in_metadata_and_cleans():
c = _FakeClient(" A returning soldier in a golden field. \n")
out = art._blurb(c, {"title": "The Veteran", "artistDisplayName": "Homer",
"medium": "Oil on canvas", "tags": [{"term": "wheat"}, {"term": "scythe"}]})
assert out == "A returning soldier in a golden field."
user = c.seen[-1]["content"]
assert "Homer" in user and "Oil on canvas" in user and "wheat" in user # catalogue facts fed in
def test_blurb_none_on_error_or_empty():
class Bad:
def chat_text(self, m): raise RuntimeError("down")
assert art._blurb(Bad(), {"title": "X"}) is None
assert art._blurb(_FakeClient(" "), {"title": "X"}) is None
def test_pick_stores_blurb_and_palette(conn):
art.harvest_pool(conn)
a = art.pick_daily(conn, art_date="2026-06-21", client=_FakeClient("A quiet masterwork."))
assert a["blurb"] == "A quiet masterwork."
assert a["palette"] == "[]" # fixture image isn't decodable → empty palette, stored as JSON
b = art.pick_daily(conn, art_date="2026-06-22") # no client → no blurb, pick still succeeds
assert b["blurb"] is None
def test_pick_caches_image_metadata_and_marks_shown(conn):
art.harvest_pool(conn)
a = art.pick_daily(conn, art_date="2026-06-21")
+4
View File
@@ -23,6 +23,8 @@ def client(tmp_path, monkeypatch):
"VALUES ('2026-06-21','met',10154,'Lander''s Peak','Bierstadt','1863','Oil','Paintings',"
"'Gift','https://met/10154','10154.jpg','https://met/full.jpg',1)"
)
c.execute("UPDATE daily_art SET blurb=?, palette=? WHERE object_id=10154",
("A luminous western vista.", '["#7fb4cf", "#c79a3c"]'))
c.commit(); c.close()
cache.mkdir(parents=True, exist_ok=True)
(cache / "10154.jpg").write_bytes(b"\xff\xd8\xff" + b"x" * 5000) # web-large display copy
@@ -43,3 +45,5 @@ def test_today_exposes_full_res_url(client):
assert a["image_url"] == "/api/art/image/10154"
assert a["image_url_large"] == "/api/art/image/10154?size=full"
assert a["license"] == "Public Domain (CC0)" and a["museum"] == "The Met"
assert a["blurb"] == "A luminous western vista."
assert a["palette"] == ["#7fb4cf", "#c79a3c"] # parsed from stored JSON