{
  "name": "LMWN-FastTrack-POC",
  "nodes": [
    {
      "parameters": {},
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        48,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// n8n Code node #1: \"Load Manifest\"  ·  Mode: Run Once for All Items\n// PASTE-READY VERSION IS GENERATED: run prepare_batch.py, then paste kit/generated/load-manifest.node.js\n// (the INJECT placeholder below is replaced with the real manifest incl. image hashes).\n//\n// Images are fetched over HTTPS (hosted on Cloudflare Pages by Claude) — no VPS files needed.\n\nconst IMAGE_BASE_URL = 'https://lmwn-poc.pages.dev/staged-images';\n\nconst MANIFEST = {\n  \"listings\": [\n    {\n      \"listing_id\": \"L1\",\n      \"scenario\": \"clean pass -> expect AUTO-CLEAR\",\n      \"name_th\": \"ครัวป้าแดง\",\n      \"name_en\": \"Krua Pa Dang\",\n      \"phone\": \"020000001\",\n      \"address\": \"88/1 ถนนสมมติ 12 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10110\",\n      \"owner\": \"Somchai T. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"food1.jpg\",\n          \"phash\": \"90296f3470d179cf\"\n        },\n        {\n          \"file\": \"food2.jpg\",\n          \"phash\": \"f194610a0e3ffe43\"\n        },\n        {\n          \"file\": \"interior1.jpg\",\n          \"phash\": \"8df2a73d728d5488\"\n        },\n        {\n          \"file\": \"interior2.jpg\",\n          \"phash\": \"dc814d1178d772ad\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"94226be13ed86bd8\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"d02a6be12bda2adc\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"947f6ba13a9c2e88\"\n        },\n        {\n          \"file\": \"menu04.jpg\",\n          \"phash\": \"907f6ba13a8b6e88\"\n        },\n        {\n          \"file\": \"menu05.jpg\",\n          \"phash\": \"94226be13ada3adc\"\n        },\n        {\n          \"file\": \"menu06.jpg\",\n          \"phash\": \"d4a26be13ad82bd8\"\n        },\n        {\n          \"file\": \"menu07.jpg\",\n          \"phash\": \"947f6ba13a8c2e98\"\n        },\n        {\n          \"file\": \"menu08.jpg\",\n          \"phash\": \"94776ba53e9c2a88\"\n        },\n        {\n          \"file\": \"menu09.jpg\",\n          \"phash\": \"942a6be12fd83ad8\"\n        },\n        {\n          \"file\": \"menu10.jpg\",\n          \"phash\": \"d0226be12ede2bd8\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"f0ecb99287c0e1ec\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L2\",\n      \"scenario\": \"watermarked photos -> expect AUTO-RETURN (R-01)\",\n      \"name_th\": \"ก๋วยเตี๋ยวเรือลุงชัย\",\n      \"name_en\": \"Boat Noodle Lung Chai\",\n      \"phone\": \"020000002\",\n      \"address\": \"45 ถนนสมมติ 3 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10240\",\n      \"owner\": \"Chaiwat P. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"food1.jpg\",\n          \"phash\": \"ffe4e0a18a8b1c4e\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"d0286be12eda3ada\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"c0b56ba56a8e2eca\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"907f6ba53a982b8a\"\n        },\n        {\n          \"file\": \"menu04.jpg\",\n          \"phash\": \"942a6be12ada3ada\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"b8fb265b24629f88\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L3\",\n      \"scenario\": \"alcohol as hero image -> expect flag (R-02)\",\n      \"name_th\": \"แซ่บอีสาน บาร์แอนด์กริลล์\",\n      \"name_en\": \"Zaab Isaan Bar and Grill\",\n      \"phone\": \"020000003\",\n      \"address\": \"199 ถนนสมมติ 55 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10310\",\n      \"owner\": \"Nok W. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"hero.jpg\",\n          \"phash\": \"c478761e73e058b6\"\n        },\n        {\n          \"file\": \"interior.jpg\",\n          \"phash\": \"b9f9ab5c480b7492\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"c07f6ba16a8a3b83\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"d07f6ba52e833a90\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"c42a6be12bd23ad6\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"faf9e090c163e30e\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L4\",\n      \"scenario\": \"illegible menu photo -> expect HUMAN REVIEW (R-03)\",\n      \"name_th\": \"ข้าวแกงบ้านสวน\",\n      \"name_en\": \"Khao Gaeng Baan Suan\",\n      \"phone\": \"020000004\",\n      \"address\": \"12 ซอยสมมติ 7 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10400\",\n      \"owner\": \"Malee K. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"interior.jpg\",\n          \"phash\": \"98e28f57438e6393\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"947f6ba13e882b8a\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"942a6be12bda2ada\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"942a6be12ada3ada\"\n        },\n        {\n          \"file\": \"menu04.jpg\",\n          \"phash\": \"907f6ba13a8a6e8a\"\n        },\n        {\n          \"file\": \"menu05.jpg\",\n          \"phash\": \"d47f6ba13a8a2a8a\"\n        },\n        {\n          \"file\": \"menu06.jpg\",\n          \"phash\": \"d47f41a0568a479f\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"dadec3533b9b8220\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L5\",\n      \"scenario\": \"blacklist near-match (transliteration variant + same phone) -> expect HUMAN REVIEW (R-06)\",\n      \"name_th\": \"ครัวคุณน้อย\",\n      \"name_en\": \"Khun Noi Kitchen\",\n      \"phone\": \"020000005\",\n      \"address\": \"301 ถนนสมมติ 21 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10250\",\n      \"owner\": \"Noi S. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"food1.jpg\",\n          \"phash\": \"cfad2db718da7040\"\n        },\n        {\n          \"file\": \"interior.jpg\",\n          \"phash\": \"def6a809c99ff100\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"94a263f03fc13ad9\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"94a263e23ed03add\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"94f763a57b812e88\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"a789854de4649ece\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L6\",\n      \"scenario\": \"reuses L1's storefront photo -> expect flag (R-07 stolen/duplicate photo)\",\n      \"name_th\": \"ครัวสองพี่น้อง\",\n      \"name_en\": \"Krua Song Phi Nong\",\n      \"phone\": \"020000006\",\n      \"address\": \"76/2 ถนนสมมติ 9 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10120\",\n      \"owner\": \"Somsak L. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"food1.jpg\",\n          \"phash\": \"dcbca8a7835a6499\"\n        },\n        {\n          \"file\": \"interior.jpg\",\n          \"phash\": \"8cec90083f68f7d3\"\n        },\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"94a063e03fd13add\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"94f763b42e853a89\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"94f563a57b852e88\"\n        },\n        {\n          \"file\": \"storefront.jpg\",\n          \"phash\": \"f0ecb99287c0e1ec\"\n        }\n      ]\n    },\n    {\n      \"listing_id\": \"L7\",\n      \"scenario\": \"incomplete submission (3 menu photos, no storefront) -> expect HUMAN REVIEW (R-04/R-05)\",\n      \"name_th\": \"ส้มตำแม่ไก่\",\n      \"name_en\": \"Somtum Mae Kai\",\n      \"phone\": \"020000007\",\n      \"address\": \"5 ซอยสมมติ 15 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10500\",\n      \"owner\": \"Kai N. (fictional)\",\n      \"images\": [\n        {\n          \"file\": \"menu01.jpg\",\n          \"phash\": \"d0776ba16a8e2e8c\"\n        },\n        {\n          \"file\": \"menu02.jpg\",\n          \"phash\": \"d0776ba52e8b2a8c\"\n        },\n        {\n          \"file\": \"menu03.jpg\",\n          \"phash\": \"d4226be12bda6ad8\"\n        }\n      ]\n    }\n  ]\n};\n\nconst runStartedAt = Date.now();\nconst out = [];\n\nfor (const listing of MANIFEST.listings) {\n  for (const img of listing.images) {\n    out.push({\n      json: {\n        listing_id: listing.listing_id,\n        name_th: listing.name_th,\n        name_en: listing.name_en,\n        phone: listing.phone,\n        address: listing.address,\n        scenario: listing.scenario,\n        file: img.file,\n        phash: img.phash,\n        url: `${IMAGE_BASE_URL}/${listing.listing_id}/${img.file}`,\n        run_started_at: runStartedAt,\n      },\n    });\n  }\n}\n\nif (out.length === 0) {\n  throw new Error('Manifest is empty. Did you paste the GENERATED file (kit/generated/load-manifest.node.js)?');\n}\n\nreturn out;\n"
      },
      "name": "Load Manifest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        224,
        0
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          },
          "timeout": 30000
        }
      },
      "name": "Fetch Image",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        400,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ $json.geminiBody }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 8000
            }
          },
          "timeout": 60000
        }
      },
      "name": "Gemini Vision",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        752,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 25000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Parse Gemini response (meta comes from Prepare node via paired chain)\nconst resp = $input.item.json;\nconst meta = $('Prepare Gemini Body').item.json;\nlet gemini; let parseError = null;\ntry {\n  const text = resp && resp.candidates && resp.candidates[0] && resp.candidates[0].content\n    && resp.candidates[0].content.parts && resp.candidates[0].content.parts[0]\n    && resp.candidates[0].content.parts[0].text;\n  if (!text) throw new Error('No text part in Gemini response: ' + JSON.stringify(resp).slice(0, 200));\n  gemini = JSON.parse(text);\n} catch (e) {\n  parseError = String(e && e.message ? e.message : e);\n  gemini = { image_type:'other', watermark:{present:false,confidence:0,detail:'PARSE ERROR'},\n    alcohol_prominent:{present:false,confidence:0,detail:'PARSE ERROR'},\n    menu_legibility:{applicable:false,score:0,sample_lines:[]}, quality_issues:['parse_error'] };\n}\nconst usage = (resp && resp.usageMetadata) || {};\nreturn { json: {\n  listing_id: meta.listing_id, name_th: meta.name_th, name_en: meta.name_en, phone: meta.phone,\n  address: meta.address, scenario: meta.scenario, file: meta.file, phash: meta.phash,\n  run_started_at: meta.run_started_at, gemini, parse_error: parseError,\n  usage: { input: usage.promptTokenCount || 0, output: usage.candidatesTokenCount || 0 },\n} };\n"
      },
      "name": "Parse Gemini",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        928,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// n8n Code node #3: \"Adjudicate\"  ·  Mode: Run Once for All Items\n// PASTE-READY VERSION IS GENERATED: run prepare_batch.py, then paste kit/generated/adjudicate.node.js\n// (the INJECT placeholder below is replaced with real hashes from the platform-photos folder).\n// Output: ONE item = the full run JSON. Copy it from the n8n output panel into run-output.json,\n// then render the report locally:  node render-report.mjs run-output.json\n\n// ---- CONFIG (pricing verified against ai.google.dev/gemini-api/docs/pricing on 2026-07-12) ----\n// Default model: gemini-3.1-flash-lite (GA, cheap, vision-capable; $0.25 in / $1.50 out per 1M tokens).\n// If the smoke test misses ANY seeded violation, switch the HTTP node AND this config to\n// gemini-3.5-flash (PRICE_IN 1.50, PRICE_OUT 9.00) and rerun; reliability beats pennies here.\nconst CONFIG = {\n  MODEL: 'gemini-3.1-flash-lite',\n  PRICE_IN_PER_1M_USD: 0.25,\n  PRICE_OUT_PER_1M_USD: 1.5,\n  FX_THB_PER_USD: 33.31, // open.er-api.com 2026-07-12\n};\n\n// Platform photo hashes: entries [{ hash, owner, file }] where owner = listing that ALREADY owns\n// the photo on the \"platform\" (so the owner itself is not flagged for its own picture).\nconst PLATFORM_HASHES = [\n  {\n    \"hash\": \"f0ecb99287c0e1ec\",\n    \"owner\": \"L1\",\n    \"file\": \"L1__storefront.jpg\"\n  }\n];\n\n// ---- Blacklist (source of truth: kit/blacklist.csv; embedded for the POC) ----\nconst BLACKLIST = [\n  { id: 'B001', name_th: 'ครัวคุณน้อย', name_en: 'Krua Khun Noi', phone: '020000005', address: '301 ถนนสมมติ 21 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10250', reason: 'duplicate account banned 2025' },\n  { id: 'B002', name_th: 'ร้านทองดีซีฟู้ด', name_en: 'Thongdee Seafood', phone: '020000101', address: '90 ถนนสมมติ 2 กรุงเทพฯ 10110', reason: 'fraudulent slips 2024' },\n  { id: 'B003', name_th: 'หมูกระทะบุฟเฟ่ต์ 199', name_en: 'Mookata Buffet 199', phone: '020000102', address: '', reason: 'fake storefront photos 2025' },\n  { id: 'B004', name_th: 'คาเฟ่ลับสวนหลังบ้าน', name_en: 'Secret Garden Cafe', phone: '020000103', address: '55/9 ซอยสมมติ 4 กรุงเทพฯ 10230', reason: 'identity mismatch 2024' },\n  { id: 'B005', name_th: 'ข้าวมันไก่เจ๊หงส์', name_en: 'Khao Man Gai Je Hong', phone: '020000104', address: '12 ถนนสมมติ 30 กรุงเทพฯ 10310', reason: 'duplicate menu across 4 accounts 2025' },\n  { id: 'B006', name_th: 'ชานมไข่มุกพรีเมียม', name_en: 'Premium Bubble Tea', phone: '020000105', address: '301 ถนนสมมติ 21 แขวงตัวอย่าง เขตตัวอย่าง กรุงเทพฯ 10250', reason: 'ghost kitchen address abuse 2025' },\n  { id: 'B007', name_th: 'ตำแซ่บเดลิเวอรี่', name_en: 'Tam Zaab Delivery', phone: '020000106', address: '88 ถนนสมมติ 18 กรุงเทพฯ 10520', reason: 'chargeback fraud 2023' },\n  { id: 'B008', name_th: 'พิซซ่าโฮมเมดคุณโจ', name_en: 'Khun Joe Homemade Pizza', phone: '020000107', address: '7 ซอยสมมติ 11 กรุงเทพฯ 10600', reason: 'stolen photos from competitor 2024' },\n  { id: 'B009', name_th: 'ก๋วยจั๊บญวนป้าอร', name_en: 'Kuay Jab Yuan Pa Orn', phone: '020000108', address: '64 ถนนสมมติ 40 กรุงเทพฯ 10700', reason: 'duplicate account 2024' },\n  { id: 'B010', name_th: 'สเต๊กลุงหนวด สาขาปลอม', name_en: 'Uncle Mustache Steak (fake branch)', phone: '020000109', address: '', reason: 'brand impersonation 2025' },\n];\n\n// ---- Thresholds (mirror the rule registry exactly) ----\nconst T = {\n  WATERMARK_AUTO: 0.90,        // R-01: >= auto-return\n  ALCOHOL_AUTO: 0.90,          // R-02: >= auto-return\n  ALCOHOL_HUMAN: 0.60,         // R-02: 0.60 to 0.90 human review\n  LEGIBILITY_MIN: 0.70,        // R-03: below -> human review\n  MIN_MENU_EVIDENCE: 10,       // R-04: menu photos OR readable menu lines\n  BLACKLIST_MATCH: 0.85,       // R-06: >= -> human review, never auto-reject\n  PHASH_HAMMING_MAX: 8,        // R-07: <= -> flag duplicate/stolen\n};\n\n// ---- helpers ----\nfunction normPhone(p) {\n  const d = String(p || '').replace(/\\D/g, '');\n  return d.startsWith('66') ? '0' + d.slice(2) : d;\n}\nfunction normName(s) {\n  return String(s || '').toLowerCase().replace(/[\\s\\-_.,'\"()/]+/g, '');\n}\nfunction levenshtein(a, b) {\n  const m = a.length, n = b.length;\n  if (m === 0) return n;\n  if (n === 0) return m;\n  let prev = new Array(n + 1);\n  let curr = new Array(n + 1);\n  for (let j = 0; j <= n; j++) prev[j] = j;\n  for (let i = 1; i <= m; i++) {\n    curr[0] = i;\n    for (let j = 1; j <= n; j++) {\n      const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n      curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);\n    }\n    [prev, curr] = [curr, prev];\n  }\n  return prev[n];\n}\nfunction ratio(a, b) {\n  if (!a || !b) return 0;\n  const maxLen = Math.max(a.length, b.length);\n  if (maxLen === 0) return 0;\n  return 1 - levenshtein(a, b) / maxLen;\n}\nfunction hamming(hexA, hexB) {\n  if (!hexA || !hexB || hexA.length !== hexB.length) return 999;\n  let x = BigInt('0x' + hexA) ^ BigInt('0x' + hexB);\n  let count = 0n;\n  while (x) { count += x & 1n; x >>= 1n; }\n  return Number(count);\n}\nfunction blacklistScore(listing) {\n  let best = { id: null, name: null, score: 0, why: '' };\n  const lPhone = normPhone(listing.phone);\n  const lTh = normName(listing.name_th);\n  const lEn = normName(listing.name_en);\n  const lAddr = normName(listing.address);\n  for (const b of BLACKLIST) {\n    let score = 0;\n    let why = [];\n    if (lPhone && normPhone(b.phone) === lPhone) { score = Math.max(score, 1.0); why.push('phone exact'); }\n    const rTh = ratio(lTh, normName(b.name_th));\n    const rEn = ratio(lEn, normName(b.name_en));\n    if (rTh > score) { score = rTh; }\n    if (rEn > score) { score = rEn; }\n    if (rTh >= 0.8) why.push(`th name ${rTh.toFixed(2)}`);\n    if (rEn >= 0.8) why.push(`en name ${rEn.toFixed(2)}`);\n    if (b.address && lAddr && normName(b.address) === lAddr) { score = Math.max(score, 0.9); why.push('address exact'); }\n    if (score > best.score) best = { id: b.id, name: b.name_en, score, why: why.join(', ') || 'name similarity', reason: b.reason };\n  }\n  return best;\n}\n\nconst FIX_TH = {\n  'R-01': 'รูปภาพมีลายน้ำหรือตัวหนังสือทับรูป รบกวนอัปโหลดรูปต้นฉบับที่ไม่มีลายน้ำครับ',\n  'R-02': 'รูปหลักเป็นเครื่องดื่มแอลกอฮอล์ ซึ่งไม่ตรงตามนโยบายของแพลตฟอร์ม รบกวนเปลี่ยนรูปหลักเป็นรูปอาหารหรือหน้าร้านครับ',\n};\nconst FIX_EN = {\n  'R-01': 'An image has a watermark or overlaid text. Please upload the original photo without the watermark.',\n  'R-02': 'The hero image features alcohol, which platform policy does not allow. Please use a food or storefront photo as the hero.',\n};\n\n// ---- group items by listing ----\nconst items = $input.all().map((i) => i.json);\nif (items.length === 0) throw new Error('No items reached Adjudicate.');\n\nconst byListing = {};\nfor (const it of items) {\n  (byListing[it.listing_id] = byListing[it.listing_id] || []).push(it);\n}\n\nconst finishedAt = Date.now();\nconst startedAt = items[0].run_started_at || finishedAt;\n\nlet totIn = 0, totOut = 0;\nconst listingsOut = [];\n\nfor (const [lid, imgs] of Object.entries(byListing)) {\n  const meta = imgs[0];\n  const rules = [];\n  const fixTh = [];\n  const fixEn = [];\n\n  for (const it of imgs) {\n    totIn += it.usage.input;\n    totOut += it.usage.output;\n  }\n\n  // R-01 watermark\n  let wmWorst = null;\n  for (const it of imgs) {\n    const w = it.gemini.watermark || {};\n    if (w.present && (!wmWorst || w.confidence > wmWorst.conf)) wmWorst = { conf: w.confidence, file: it.file, detail: w.detail };\n  }\n  if (!wmWorst) rules.push({ rule_id: 'R-01', name: 'No watermarks', status: 'pass', detail: 'No overlaid text or logos detected.' });\n  else if (wmWorst.conf >= T.WATERMARK_AUTO) { rules.push({ rule_id: 'R-01', name: 'No watermarks', status: 'fail', confidence: wmWorst.conf, file: wmWorst.file, detail: wmWorst.detail }); fixTh.push(FIX_TH['R-01']); fixEn.push(FIX_EN['R-01']); }\n  else rules.push({ rule_id: 'R-01', name: 'No watermarks', status: 'flag', confidence: wmWorst.conf, file: wmWorst.file, detail: wmWorst.detail });\n\n  // R-02 alcohol hero\n  let alWorst = null;\n  for (const it of imgs) {\n    const a = it.gemini.alcohol_prominent || {};\n    if (a.present && (!alWorst || a.confidence > alWorst.conf)) alWorst = { conf: a.confidence, file: it.file, detail: a.detail };\n  }\n  if (!alWorst) rules.push({ rule_id: 'R-02', name: 'Alcohol cannot be hero image', status: 'pass', detail: 'Alcohol is not the main subject of any image.' });\n  else if (alWorst.conf >= T.ALCOHOL_AUTO) { rules.push({ rule_id: 'R-02', name: 'Alcohol cannot be hero image', status: 'fail', confidence: alWorst.conf, file: alWorst.file, detail: alWorst.detail }); fixTh.push(FIX_TH['R-02']); fixEn.push(FIX_EN['R-02']); }\n  else if (alWorst.conf >= T.ALCOHOL_HUMAN) rules.push({ rule_id: 'R-02', name: 'Alcohol cannot be hero image', status: 'flag', confidence: alWorst.conf, file: alWorst.file, detail: alWorst.detail });\n  else rules.push({ rule_id: 'R-02', name: 'Alcohol cannot be hero image', status: 'pass', confidence: alWorst.conf, detail: 'Low-confidence mention only: ' + alWorst.detail });\n\n  // R-03 menu legibility\n  const menus = imgs.filter((it) => it.gemini.image_type === 'menu');\n  const badMenus = menus.filter((it) => it.gemini.menu_legibility && it.gemini.menu_legibility.applicable && it.gemini.menu_legibility.score < T.LEGIBILITY_MIN);\n  if (menus.length === 0) rules.push({ rule_id: 'R-03', name: 'Menu text must be legible', status: 'flag', detail: 'No menu images identified.' });\n  else if (badMenus.length > 0) rules.push({ rule_id: 'R-03', name: 'Menu text must be legible', status: 'flag', file: badMenus[0].file, confidence: badMenus[0].gemini.menu_legibility.score, detail: `${badMenus.length} menu photo(s) below legibility ${T.LEGIBILITY_MIN}.` });\n  else rules.push({ rule_id: 'R-03', name: 'Menu text must be legible', status: 'pass', detail: `All ${menus.length} menu photos legible.` });\n\n  // R-04 menu evidence (10+ menu photos OR 10+ readable menu lines, per the brief's \"10+ menu photos or physical books\")\n  const readableLines = menus.reduce((n, it) => n + ((it.gemini.menu_legibility && it.gemini.menu_legibility.sample_lines) || []).length, 0);\n  if (menus.length >= T.MIN_MENU_EVIDENCE || readableLines >= T.MIN_MENU_EVIDENCE) rules.push({ rule_id: 'R-04', name: '10+ menu items or photos', status: 'pass', detail: `${menus.length} menu photos, ${readableLines} readable lines.` });\n  else rules.push({ rule_id: 'R-04', name: '10+ menu items or photos', status: 'flag', detail: `Only ${menus.length} menu photos and ${readableLines} readable lines.` });\n\n  // R-05 storefront present\n  const hasStorefront = imgs.some((it) => it.gemini.image_type === 'storefront');\n  rules.push(hasStorefront\n    ? { rule_id: 'R-05', name: 'Storefront photo present', status: 'pass', detail: 'Storefront identified.' }\n    : { rule_id: 'R-05', name: 'Storefront photo present', status: 'flag', detail: 'No storefront photo found.' });\n\n  // R-06 blacklist entity resolution (human always, never auto-reject)\n  const bl = blacklistScore(meta);\n  rules.push(bl.score >= T.BLACKLIST_MATCH\n    ? { rule_id: 'R-06', name: 'Blacklist / duplicate entity', status: 'flag', confidence: bl.score, detail: `Possible match ${bl.id} (${bl.name}): ${bl.why}. Reason on file: ${bl.reason}` }\n    : { rule_id: 'R-06', name: 'Blacklist / duplicate entity', status: 'pass', confidence: bl.score, detail: 'No blacklist match above threshold.' });\n\n  // R-07 duplicate / stolen photos vs platform library\n  let dupHit = null;\n  for (const it of imgs) {\n    for (const ph of PLATFORM_HASHES) {\n      if (ph.owner === lid) continue;\n      const d = hamming(it.phash, ph.hash);\n      if (d <= T.PHASH_HAMMING_MAX && (!dupHit || d < dupHit.dist)) dupHit = { file: it.file, dist: d, owner: ph.owner, ownerFile: ph.file };\n    }\n  }\n  rules.push(dupHit\n    ? { rule_id: 'R-07', name: 'Photos not duplicated or stolen', status: 'flag', file: dupHit.file, detail: `Perceptual-hash match to ${dupHit.owner}/${dupHit.ownerFile} (Hamming ${dupHit.dist}).` }\n    : { rule_id: 'R-07', name: 'Photos not duplicated or stolen', status: 'pass', detail: 'No perceptual-hash match against platform photos.' });\n\n  // R-00 doctrine guard: never auto approve in doubt. Unanalyzed images block AUTO_CLEAR.\n  const parseErrs = imgs.filter((it) => it.parse_error).length;\n  if (parseErrs > 0) {\n    rules.push({ rule_id: 'R-00', name: 'All images analyzed', status: 'flag', detail: `${parseErrs} of ${imgs.length} images not analyzed (API errors); routed to human review per doctrine.` });\n  }\n\n  // Verdict\n  const anyFail = rules.some((r) => r.status === 'fail');\n  const anyFlag = rules.some((r) => r.status === 'flag');\n  const verdict = anyFail ? 'AUTO_RETURN' : anyFlag ? 'HUMAN_REVIEW' : 'AUTO_CLEAR';\n\n  listingsOut.push({\n    listing_id: lid,\n    name_th: meta.name_th,\n    name_en: meta.name_en,\n    scenario: meta.scenario,\n    verdict,\n    rules,\n    fix_list_th: fixTh,\n    fix_list_en: fixEn,\n    blacklist: { best_match_id: bl.id, best_match_name: bl.name, score: Number(bl.score.toFixed(3)) },\n    images: imgs.map((it) => ({\n      file: it.file,\n      image_type: it.gemini.image_type,\n      watermark: !!(it.gemini.watermark && it.gemini.watermark.present),\n      alcohol: !!(it.gemini.alcohol_prominent && it.gemini.alcohol_prominent.present),\n      legibility_score: it.gemini.menu_legibility && it.gemini.menu_legibility.applicable ? it.gemini.menu_legibility.score : null,\n      quality_issues: it.gemini.quality_issues || [],\n      parse_error: it.parse_error || null,\n    })),\n  });\n}\n\nlistingsOut.sort((a, b) => a.listing_id.localeCompare(b.listing_id));\n\nconst nListings = listingsOut.length;\nconst batchMs = finishedAt - startedAt;\nconst costUsd = (totIn * CONFIG.PRICE_IN_PER_1M_USD + totOut * CONFIG.PRICE_OUT_PER_1M_USD) / 1e6;\n\nconst runOutput = {\n  run_meta: {\n    data_note: 'Staged sample data (fictional listings), POC run',\n    started_at: new Date(startedAt).toISOString(),\n    finished_at: new Date(finishedAt).toISOString(),\n    batch_wall_ms: batchMs,\n    avg_ms_per_listing: Math.round(batchMs / Math.max(nListings, 1)),\n    model: CONFIG.MODEL,\n    pricing: {\n      input_per_1m_usd: CONFIG.PRICE_IN_PER_1M_USD,\n      output_per_1m_usd: CONFIG.PRICE_OUT_PER_1M_USD,\n      fx_thb_per_usd: CONFIG.FX_THB_PER_USD,\n    },\n    totals: {\n      listings: nListings,\n      images: items.length,\n      input_tokens: totIn,\n      output_tokens: totOut,\n      cost_usd: Number(costUsd.toFixed(5)),\n      cost_thb: Number((costUsd * CONFIG.FX_THB_PER_USD).toFixed(3)),\n      cost_per_listing_usd: Number((costUsd / Math.max(nListings, 1)).toFixed(5)),\n      cost_per_listing_thb: Number(((costUsd * CONFIG.FX_THB_PER_USD) / Math.max(nListings, 1)).toFixed(3)),\n      auto_clear: listingsOut.filter((l) => l.verdict === 'AUTO_CLEAR').length,\n      auto_return: listingsOut.filter((l) => l.verdict === 'AUTO_RETURN').length,\n      human_review: listingsOut.filter((l) => l.verdict === 'HUMAN_REVIEW').length,\n    },\n  },\n  listings: listingsOut,\n};\n\nreturn [{ json: runOutput }];\n"
      },
      "name": "Adjudicate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1104,
        0
      ]
    },
    {
      "parameters": {
        "path": "lmwn-fasttrack-poc",
        "options": {}
      },
      "name": "Run Hook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        48,
        176
      ]
    },
    {
      "parameters": {
        "jsCode": "// Prepare Gemini request bodies (binary -> base64 via n8n helper, robust across storage modes)\nconst PROMPT = \"You are the image validation station in a restaurant onboarding pipeline for a Thai food delivery platform. Analyze exactly ONE image and return ONLY JSON matching the response schema.\\n\\nDefinitions:\\n- image_type: storefront = shop exterior visible from the street; interior = inside dining or seating area; menu = a menu page, menu board, or photo of a physical menu book; food = a dish photo; other = anything else.\\n- watermark.present: true ONLY if text, a logo, a phone number, or a graphic has been digitally overlaid ON the photo. Natural signage physically present in the scene is NOT a watermark. Put what you saw in detail.\\n- alcohol_prominent.present: true ONLY if alcoholic drinks (beer, wine, spirits, bottles or glasses of them) are the MAIN SUBJECT of the image. Alcohol merely visible in the background or as a minor element is false; mention it in detail instead.\\n- menu_legibility: applicable only when image_type is menu. score 1.0 = all text clearly readable, 0.0 = unreadable. Read the menu and copy up to 5 real lines (dish name and price) into sample_lines exactly as printed, Thai or English. If fewer than 5 lines are readable, copy what you can. If image_type is not menu, set applicable false, score 0, sample_lines [].\\n- quality_issues: any of: blurry, too_dark, overexposed, extreme_angle, cropped_content, low_resolution. Empty array if none.\\n\\nAll confidence values are 0 to 1. Be decisive: obvious cases deserve 0.9 or higher. Return only the JSON.\";\nconst SCHEMA = {\"type\": \"object\", \"properties\": {\"image_type\": {\"type\": \"string\", \"enum\": [\"storefront\", \"interior\", \"menu\", \"food\", \"other\"]}, \"watermark\": {\"type\": \"object\", \"properties\": {\"present\": {\"type\": \"boolean\"}, \"confidence\": {\"type\": \"number\"}, \"detail\": {\"type\": \"string\"}}, \"required\": [\"present\", \"confidence\", \"detail\"]}, \"alcohol_prominent\": {\"type\": \"object\", \"properties\": {\"present\": {\"type\": \"boolean\"}, \"confidence\": {\"type\": \"number\"}, \"detail\": {\"type\": \"string\"}}, \"required\": [\"present\", \"confidence\", \"detail\"]}, \"menu_legibility\": {\"type\": \"object\", \"properties\": {\"applicable\": {\"type\": \"boolean\"}, \"score\": {\"type\": \"number\"}, \"sample_lines\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}}}, \"required\": [\"applicable\", \"score\", \"sample_lines\"]}, \"quality_issues\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}}}, \"required\": [\"image_type\", \"watermark\", \"alcohol_prominent\", \"menu_legibility\", \"quality_issues\"]};\nconst metas = $('Load Manifest').all();\nconst items = $input.all();\nif (items.length !== metas.length) throw new Error(`item count mismatch: ${items.length} vs ${metas.length}`);\nconst out = [];\nfor (let i = 0; i < items.length; i++) {\n  const buf = await this.helpers.getBinaryDataBuffer(i, 'data');\n  if (!buf || buf.length < 5000) throw new Error(`image buffer missing/too small at item ${i} (${metas[i].json.listing_id}/${metas[i].json.file})`);\n  const body = {\n    contents: [{ parts: [\n      { inline_data: { mime_type: 'image/jpeg', data: buf.toString('base64') } },\n      { text: PROMPT },\n    ]}],\n    generationConfig: { temperature: 0, responseMimeType: 'application/json', responseSchema: SCHEMA },\n  };\n  out.push({ json: { ...metas[i].json, geminiBody: JSON.stringify(body) }, pairedItem: { item: i } });\n}\nreturn out;\n"
      },
      "name": "Prepare Gemini Body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        576,
        0
      ]
    },
    {
      "parameters": {
        "content": "## POC -> Production mapping\n**Load Manifest** stands in for the Salesforce CRM record + Drive listing (production: Salesforce trigger + Drive node). **Fetch Image** = the Drive download. **Prepare + Gemini Vision** = the five AI check stations (rules R-01 to R-03). **Adjudicate** = completeness, blacklist entity resolution (R-06), perceptual-hash stolen-photo check (R-07), and the three-lane confidence router. Production adds: write-back to Salesforce, the owner fix-list message via LINE OA, and the reviewer queue. Staged fictional sample data.",
        "height": 120,
        "width": 1260,
        "color": 4
      },
      "name": "Production Mapping",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        48,
        -192
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Load Manifest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Manifest": {
      "main": [
        [
          {
            "node": "Fetch Image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Image": {
      "main": [
        [
          {
            "node": "Prepare Gemini Body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini Vision": {
      "main": [
        [
          {
            "node": "Parse Gemini",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Gemini": {
      "main": [
        [
          {
            "node": "Adjudicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Hook": {
      "main": [
        [
          {
            "node": "Load Manifest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Gemini Body": {
      "main": [
        [
          {
            "node": "Gemini Vision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "note": "LMWN AI Fast Track POC by Chanon Poovaviranon (Beam). Staged fictional sample data. Credentials removed."
  }
}