← back to Dw Photo Capture
repoint dead Ollama macvision + Q&A paths to shared exo-vision lib (ring primary, Gemini fallback, honest not_measured) (TK-12090 Lane P)
5b591d9309c584e790208a40d185842cddde140c · 2026-09-23 14:40:06 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
M build_logo_fingerprints.pyM server.js
Diff
commit 5b591d9309c584e790208a40d185842cddde140c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 14:40:06 2026 -0700
repoint dead Ollama macvision + Q&A paths to shared exo-vision lib (ring primary, Gemini fallback, honest not_measured) (TK-12090 Lane P)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
build_logo_fingerprints.py | 37 +++++++++++++++-------
server.js | 78 ++++++++++++++++++++++++----------------------
2 files changed, 66 insertions(+), 49 deletions(-)
diff --git a/build_logo_fingerprints.py b/build_logo_fingerprints.py
index 01d7f4d..d295ffd 100644
--- a/build_logo_fingerprints.py
+++ b/build_logo_fingerprints.py
@@ -3,13 +3,21 @@
each harvested reference logo ONCE and store a canonical fingerprint — what the logo looks
like, its typesetting, and the brand text it reads as — into the vendor profile. At scan time
this grounds /api/identify (compare the swatch's logo to the known reference) and shows on
-/learn. Idempotent: skips vendors already fingerprinted unless --force. Local only. $0."""
-import base64, json, os, re, sys, urllib.request
+/learn. Idempotent: skips vendors already fingerprinted unless --force.
+
+TK-12090 Lane P: the VLM call now goes through the shared fleet vision lib
+(~/Projects/_shared/lib/exo_vision.py) — exo ring primary ($0), Gemini fallback (~$0.0006/logo,
+cost-ledgered by the lib). The old direct Ollama :11434 path was retired 2026-09-18. When the
+lib reports not_measured (ring down + fallback off/failed) the logo is SKIPPED, never written
+as an empty/false fingerprint. Set VISION_FALLBACK=none to stay strictly $0."""
+import base64, json, os, re, sys
ROOT = os.path.dirname(os.path.abspath(__file__))
PROF = os.path.join(ROOT, "data", "vendor_profiles.json")
-OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
-MODEL = os.environ.get("OLLAMA_VISION_MODEL", "qwen2.5vl:7b")
+sys.path.insert(0, os.environ.get("EXO_VISION_LIB_DIR",
+ os.path.join(ROOT, "..", "_shared", "lib")))
+import exo_vision # noqa: E402 — shared lib, path set above
+MODEL = exo_vision.EXO_MODEL
FORCE = "--force" in sys.argv
PROMPT = ('This image is a BRAND LOGO for a wallcovering/fabric maker. Reply ONLY as compact '
@@ -17,13 +25,16 @@ PROMPT = ('This image is a BRAND LOGO for a wallcovering/fabric maker. Reply ONL
'caps / script / geometric / etc>","logo":"<one-line description of the mark>",'
'"colors":"<dominant colors>"}')
-def vlm(b64):
- body = json.dumps({"model": MODEL, "prompt": PROMPT, "images": [b64],
- "stream": False, "format": "json", "options": {"temperature": 0}}).encode()
- r = urllib.request.Request(OLLAMA + "/api/generate", data=body,
- headers={"Content-Type": "application/json"})
- d = json.load(urllib.request.urlopen(r, timeout=90))
- try: return json.loads(d.get("response", "{}"))
+COST = {"usd": 0.0, "calls": 0}
+
+def vlm(b64, mime="image/png"):
+ r = exo_vision.vision_chat(PROMPT, {"b64": b64, "mime": mime}, timeout=90, max_tokens=512)
+ if not r.get("ok"):
+ raise RuntimeError("vision not measured: %s" % r.get("error"))
+ COST["usd"] += r.get("cost_usd") or 0; COST["calls"] += 1
+ t = r.get("text") or ""
+ m = re.search(r"```(?:json)?\s*([\s\S]*?)```", t)
+ try: return json.loads((m.group(1) if m else t).strip() or "{}")
except Exception: return {}
prof = json.load(open(PROF))
@@ -39,7 +50,8 @@ for v, p in todo:
continue
try:
b64 = base64.b64encode(open(fp, "rb").read()).decode()
- out = vlm(b64)
+ ext = os.path.splitext(fp)[1].lower()
+ out = vlm(b64, {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}.get(ext, "image/png"))
if out:
reads = out.get("reads_as") or ""
# VALIDATE: the logo must read back as THIS vendor. If the VLM sees a different
@@ -74,3 +86,4 @@ for v, p in todo:
prof.setdefault("_meta", {})["logos_fingerprinted"] = sum(1 for p in profiles.values() if p.get("logo_desc"))
json.dump(prof, open(PROF, "w"), indent=0)
print(f"\nfingerprinted {done} logos -> profiles updated")
+print(f"cost: ${COST['usd']:.4f} over {COST['calls']} vision calls (exo ring = $0; Gemini fallback ~$0.0006 each)")
diff --git a/server.js b/server.js
index 7bbc3c8..71cbc16 100644
--- a/server.js
+++ b/server.js
@@ -642,29 +642,39 @@ function rebuildLexicon() {
}
rebuildLexicon();
-// ── Local VLM brand/logo recognition (Ollama qwen2.5vl, $0) ────────────────────
+// ── Local VLM brand/logo recognition (exo ring via shared lib, Gemini fallback) ─────
// Reads a STYLIZED logo/wordmark + typesetting that the deterministic OCR can't, to name
// the vendor when no brand text was cleanly recognized. An on-lock assist, not per-frame.
-const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b'; // reuses OLLAMA_URL declared with ollamaAsk
-// Allow-listed vision models a request may pick. 'fast' => moondream (~3-5s, lower accuracy);
-// default qwen2.5vl is more accurate (~15-20s). Anything off-list is ignored (no arbitrary pulls).
-const VISION_MODELS = { 'qwen2.5vl:7b': 'qwen2.5vl:7b', 'moondream:latest': 'moondream:latest',
- 'moondream': 'moondream:latest', 'fast': 'moondream:latest', 'accurate': 'qwen2.5vl:7b', 'llava:13b': 'llava:13b' };
-function pickVisionModel(req) { return VISION_MODELS[String(req || '').toLowerCase().trim()] || OLLAMA_VISION_MODEL; }
-function ollamaVision(b64, prompt, timeoutMs, model) {
- return new Promise(resolve => {
- let target; try { target = new URL('/api/generate', OLLAMA_URL); } catch (e) { return resolve({ error: 'bad OLLAMA_URL' }); }
- const lib = target.protocol === 'https:' ? https : http;
- const payload = JSON.stringify({ model: model || OLLAMA_VISION_MODEL, prompt, images: [b64],
- stream: false, format: 'json', options: { temperature: 0 } });
- // default 35s is fine once the model is warm; callers expecting a possible COLD model
- // load (first call after idle ≈ 50s here) pass a larger budget so they don't false-timeout.
- const r = lib.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 35000 },
- res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { resolve({ error: 'parse' }); } }); });
- r.on('error', e => resolve({ error: e.message }));
- r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout' }); });
- r.write(payload); r.end();
- });
+// TK-12090 Lane P: the old path POSTed to a local Ollama (:11434, qwen2.5vl:7b) that was
+// RETIRED 2026-09-18 — every call silently errored. It now goes through the shared fleet
+// vision lib (~/Projects/_shared/lib/exo-vision.mjs): exo ring primary ($0), Gemini fallback
+// (cost-logged), and an honest {error, not_measured} when neither is reachable. The lib is
+// ESM; this file is CJS, so it is loaded lazily via dynamic import(). Override the path
+// with EXO_VISION_LIB (e.g. on a host where _shared lives elsewhere).
+const EXO_VISION_LIB = process.env.EXO_VISION_LIB || path.join(__dirname, '..', '_shared', 'lib', 'exo-vision.mjs');
+let _exoLibP = null;
+function exoLib() {
+ if (!_exoLibP) _exoLibP = import(require('url').pathToFileURL(EXO_VISION_LIB).href)
+ .catch(e => { _exoLibP = null; throw new Error(`vision lib unavailable (${EXO_VISION_LIB}): ${e.message}`); });
+ return _exoLibP;
+}
+// The ring model is chosen by the lib (VISION_MODEL env, or whichever vision instance is live).
+// 'fast'/'accurate' and legacy Ollama names are accepted for API compatibility but no longer
+// select a different model — there is only the ring's vision instance (or the Gemini fallback).
+const OLLAMA_VISION_MODEL = process.env.VISION_MODEL || 'mlx-community/Qwen3-VL-4B-Instruct-4bit'; // name kept for callers; reports the ring model
+function pickVisionModel() { return OLLAMA_VISION_MODEL; }
+// Strip ```json fences a chat model may wrap its JSON in, so callers can JSON.parse(r.response).
+function _unfence(t) { const m = String(t || '').match(/```(?:json)?\s*([\s\S]*?)```/i); return (m ? m[1] : String(t || '')).trim(); }
+// Returns the Ollama-compatible shape callers already parse: { response, error } plus
+// provider/model/cost_usd so the $ is visible (Steve's always-show-costs rule).
+async function ollamaVision(b64, prompt, timeoutMs, model) {
+ try {
+ const lib = await exoLib();
+ const r = await lib.visionChat({ prompt, image: { b64, mime: 'image/jpeg' }, model: model || OLLAMA_VISION_MODEL,
+ timeoutMs: timeoutMs || 60000, maxTokens: 512 });
+ if (!r.ok) return { error: r.error || 'vision not measured', not_measured: true, provider: null, cost_usd: 0 };
+ return { response: _unfence(r.text), provider: r.provider, model: r.model, cost_usd: r.cost_usd };
+ } catch (e) { return { error: e.message, not_measured: true, provider: null, cost_usd: 0 }; }
}
// ── Pluggable vision engines: macvision (Apple Vision + local Ollama) · gemini · gcv ──
@@ -1516,7 +1526,7 @@ const appHandler = (req, res) => {
}
} catch (e) { visual = { error: e.message }; }
}
- return send(res, 200, { ok: !r.error, model: (engine === 'macvision' ? OLLAMA_VISION_MODEL : GEMINI_VISION_MODEL), engine,
+ return send(res, 200, { ok: !r.error, model: (engine === 'macvision' ? (r.model || OLLAMA_VISION_MODEL) : GEMINI_VISION_MODEL), engine, provider: r.provider || engine, cost_usd: r.cost_usd,
recognized: {
description: a.description || null, motif: a.motif || null, style: a.style || null,
material: a.material || null, colors: Array.isArray(a.colors) ? a.colors.slice(0, 6) : [],
@@ -2904,21 +2914,15 @@ const _PNODE = `legacyResourceId title status vendor
mf:metafield(namespace:"custom",key:"manufacturer_sku"){value}
mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}
v:variants(first:5){edges{node{sku title price}}}`;
-// ── Local Ollama Q&A (free, on-LAN) ──────────────────────────────────────────
-const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
-const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:latest';
-function ollamaAsk(prompt) {
- return new Promise((resolve, reject) => {
- const ou = new URL(OLLAMA_URL + '/api/generate');
- const data = JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, options: { num_predict: 160, temperature: 0.2 } });
- const lib = ou.protocol === 'https:' ? https : http;
- const r = lib.request({ hostname: ou.hostname, port: ou.port, path: ou.pathname, method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, resp => {
- let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve((JSON.parse(d).response) || ''); } catch (e) { reject(e); } });
- });
- r.setTimeout(30000, () => r.destroy(new Error('ollama timeout')));
- r.on('error', reject); r.write(data); r.end();
- });
+// ── Local Q&A (exo ring text model via the shared lib, $0) ────────────────────
+// TK-12090 Lane P: was a direct Ollama :11434 call (retired). Now the shared lib's textChat;
+// an unreachable ring rejects with a real error so the endpoint says "unreachable" honestly.
+const OLLAMA_MODEL = process.env.LLM_MODEL || 'mlx-community/Qwen3.6-27B-4bit'; // name kept for callers
+async function ollamaAsk(prompt) {
+ const lib = await exoLib();
+ const r = await lib.textChat({ prompt, model: OLLAMA_MODEL, timeoutMs: 30000, maxTokens: 160 });
+ if (!r.ok) throw new Error(r.error || 'local LLM not measured');
+ return String(r.text || '').replace(/<think>[\s\S]*?<\/think>/g, '');
}
// Pull a product's facts (basics + spec-like metafields) → a compact context block for the LLM.
async function productContext(productId) {
← 2284bdb auto-data-snapshot: 2026-09-23T04:15:24 (1 data files) — dat
·
back to Dw Photo Capture
·
visual-search nightly embed: env-first DB user (PGUSER or ru d5f6c4a →