← back to HERMES

demo/headtohead.mjs

133 lines

#!/usr/bin/env node
// Head-to-head: Hermes-3-8B vs Qwen3:14b on a real DW task.
// Prompt: 5 wallpaper concept briefs for a "Costa Brava" coastal-Spanish-luxury collection.
// Strict JSON output → measure validity, latency, palette quality.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(__dirname, 'output');
fs.mkdirSync(OUT, { recursive: true });

const HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';

const SYSTEM = `You are a senior wallpaper designer at Designer Wallcoverings.
You speak in tight, editorial copy — the way a Vogue feature writes about an interior.
You return STRICT JSON only. No prose, no markdown fences, no commentary.
Schema:
{
  "collection": "string",
  "concepts": [
    {
      "name": "string (2-3 words, evocative)",
      "hero_hex": "#RRGGBB",
      "palette": ["#RRGGBB", "#RRGGBB", "#RRGGBB"],
      "motif": "string (one sentence)",
      "room": "string (single room type)",
      "tagline": "string (max 8 words)",
      "tier": "Studio | Atelier | Bespoke"
    }
  ]
}`;

const USER = `Brief: Designer Wallcoverings is launching the COSTA BRAVA collection — coastal Spanish luxury, sun-bleached terracotta and Mediterranean blue, hand-block prints reinterpreted for 2026 interiors. Target client: hospitality + high-end residential.

Generate exactly 5 distinct wallpaper concepts. Each must have a unique hero_hex and a palette that genuinely complements it. Names should evoke place, not be generic. Taglines must SELL — not describe.

Return strict JSON only.`;

async function callOllama(model, system, user) {
  const t0 = Date.now();
  const r = await fetch(`${HOST}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model,
      stream: false,
      format: 'json',
      options: { temperature: 0.7, num_ctx: 4096, num_predict: 1500 },
      messages: [
        { role: 'system', content: system },
        { role: 'user', content: user },
      ],
    }),
  });
  const j = await r.json();
  const ms = Date.now() - t0;
  return {
    model,
    ms,
    raw: j.message?.content ?? '',
    eval_count: j.eval_count,
    prompt_eval_count: j.prompt_eval_count,
  };
}

function tryParse(raw) {
  try { return { ok: true, data: JSON.parse(raw) }; }
  catch (e) {
    const m = raw.match(/\{[\s\S]*\}/);
    if (m) { try { return { ok: true, data: JSON.parse(m[0]), salvaged: true }; } catch {} }
    return { ok: false, error: e.message };
  }
}

function validate(data) {
  const issues = [];
  if (!data?.concepts || !Array.isArray(data.concepts)) {
    return { issues: ['no concepts array'], valid: 0, total: 0 };
  }
  const total = data.concepts.length;
  let valid = 0;
  for (const c of data.concepts) {
    const ok =
      typeof c.name === 'string' && c.name.trim().length > 0 &&
      /^#[0-9a-fA-F]{6}$/.test(c.hero_hex || '') &&
      Array.isArray(c.palette) && c.palette.length >= 3 &&
      c.palette.every(h => /^#[0-9a-fA-F]{6}$/.test(h)) &&
      typeof c.motif === 'string' &&
      typeof c.tagline === 'string' &&
      (c.tagline.split(/\s+/).filter(Boolean).length <= 8) &&
      ['Studio', 'Atelier', 'Bespoke'].includes(c.tier);
    if (ok) valid++; else issues.push(`bad concept: ${JSON.stringify(c).slice(0, 80)}…`);
  }
  return { issues, valid, total };
}

console.log('→ pinging both models in parallel…');
const [hermes, qwen] = await Promise.all([
  callOllama('hermes3:8b', SYSTEM, USER),
  callOllama('qwen3:14b', SYSTEM, USER),
]);

for (const r of [hermes, qwen]) {
  const slug = r.model.replace(/[^a-z0-9]+/gi, '_');
  fs.writeFileSync(path.join(OUT, `${slug}.raw.txt`), r.raw);
  const p = tryParse(r.raw);
  const v = p.ok ? validate(p.data) : { issues: [p.error], valid: 0, total: 0 };
  const summary = {
    model: r.model,
    latency_ms: r.ms,
    tokens_out: r.eval_count,
    tokens_in: r.prompt_eval_count,
    parsed_ok: p.ok,
    salvaged: !!p.salvaged,
    valid_concepts: v.valid,
    total_concepts: v.total,
    issues: v.issues,
    data: p.ok ? p.data : null,
  };
  fs.writeFileSync(path.join(OUT, `${slug}.json`), JSON.stringify(summary, null, 2));
  console.log(`\n[${r.model}]  ${r.ms}ms  parsed=${p.ok}  valid=${v.valid}/${v.total}  tokens=${r.eval_count}`);
  if (p.ok && p.data?.concepts) {
    for (const c of p.data.concepts) {
      console.log(`   ${c.hero_hex || '------'}  ${(c.name || '?').padEnd(22)}  ${c.tagline || ''}`);
    }
  }
}

console.log('\n✓ outputs written to demo/output/');
console.log('  hermes3_8b.json     qwen3_14b.json');