{
  "id": "wf-ai-qa-scorer",
  "name": "AI QA Scorer with Supervisor Review",
  "nodes": [
    {
      "parameters": {},
      "id": "01-manual",
      "name": "Run Synthetic Demo",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [-1120, 300]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { interaction_id: 'DEMO-1001', channel: 'call', transcript: `Customer: My air purifier is rattling and I am worried it is unsafe.\nAgent: Please turn it off and unplug it. I am sorry this happened, especially with the unit in your son's room. I will flag this for our safety team today. Do you notice heat, smoke, or a smell?\nCustomer: No, only the rattling.\nAgent: Keep it unplugged while we review the case. You will get an email with the case details.`, demo_mode: true } }];"
      },
      "id": "02-sample",
      "name": "Synthetic Transcript",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-900, 300]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ai-qa-scorer",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "03-webhook",
      "name": "Receive Transcript",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [-1120, 0],
      "webhookId": "af3f41d2-1cf1-4db4-a855-27ba780a4bc3"
    },
    {
      "parameters": {
        "jsCode": "const src = typeof $json.body === 'object' && $json.body !== null ? $json.body : $json;\nconst transcript = String(src.transcript ?? '').trim();\nif (transcript.length < 80) throw new Error('Transcript must contain at least 80 characters.');\nreturn [{ json: { interaction_id: String(src.interaction_id ?? `QA-${Date.now()}`), channel: String(src.channel ?? 'call'), transcript: transcript.slice(0, 16000), demo_mode: src.demo_mode === true } }];"
      },
      "id": "04-normalize",
      "name": "Check and Normalize Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-680, 0]
    },
    {
      "parameters": {
        "rules": {
          "values": [{
            "conditions": {
              "options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
              "conditions": [{"leftValue": "={{ $json.demo_mode }}", "rightValue": true, "operator": {"type": "boolean", "operation": "true", "singleValue": true}}],
              "combinator": "and"
            },
            "renameOutput": true,
            "outputKey": "demo"
          }]
        },
        "options": {"fallbackOutput": "extra"}
      },
      "id": "05-mode",
      "name": "Demo or Live AI",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.3,
      "position": [-450, 0]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { ...$json, score_packet: { overallScore: 91, confidence: 'high', summary: 'The agent handled the safety concern well. The main gap is a missing case number and follow-up window.', criteria: [{ id: 'safety', name: 'Safety and policy', weight: 30, score: 100, status: 'met', evidence: 'Please turn it off and unplug it.', note: 'The agent gave the safe first step before troubleshooting.' }, { id: 'empathy', name: 'Empathy and ownership', weight: 20, score: 100, status: 'met', evidence: 'I am sorry this happened, especially with the unit in your son\\'s room.', note: 'The apology fits the customer\\'s concern.' }, { id: 'discovery', name: 'Discovery', weight: 20, score: 95, status: 'met', evidence: 'Do you notice heat, smoke, or a smell?', note: 'The agent checked for signs that would change the risk.' }, { id: 'resolution', name: 'Resolution and next step', weight: 20, score: 75, status: 'partial', evidence: 'I will flag this for our safety team today.', note: 'The owner is clear, but the tracking details are missing.' }, { id: 'close', name: 'Close and confirm', weight: 10, score: 90, status: 'met', evidence: 'Keep it unplugged while we review the case.', note: 'The agent restated the safe next step.' }], coaching: { strength: 'You led with the safe action and used a specific apology.', focus: 'Give the case number and a clear follow-up window.', practice: 'Practice closing with owner, case number, time window, and customer action.', supervisorNote: 'Confirm the follow-up standard before using this coaching note.' } } } }];"
      },
      "id": "06-demo-score",
      "name": "Fixed Synthetic Score",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-180, -150]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/responses",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({ model: 'gpt-5.6-luna', instructions: 'You are an assistive QA reviewer for a fictional customer-support team. Score only the supplied transcript. Every criterion score must be an unweighted score from 0 to 100, never weighted points. Use the fixed weighted rubric. Quote exact transcript evidence. Mark unclear evidence as review. Do not make employment, pay, discipline, or termination recommendations. Return JSON that matches the schema.', input: $json.transcript, reasoning: { effort: 'none' }, text: { format: { type: 'json_schema', name: 'ai_qa_scorer_review', strict: true, schema: { type: 'object', properties: { overallScore: { type: 'integer', minimum: 0, maximum: 100 }, confidence: { type: 'string', enum: ['high','medium','low'] }, summary: { type: 'string' }, criteria: { type: 'array', minItems: 5, maxItems: 5, items: { type: 'object', properties: { id: { type: 'string', enum: ['safety','empathy','discovery','resolution','close'] }, name: { type: 'string' }, weight: { type: 'integer' }, score: { type: 'integer', minimum: 0, maximum: 100 }, status: { type: 'string', enum: ['met','partial','missed','review'] }, evidence: { type: 'string' }, note: { type: 'string' } }, required: ['id','name','weight','score','status','evidence','note'], additionalProperties: false } }, coaching: { type: 'object', properties: { strength: { type: 'string' }, focus: { type: 'string' }, practice: { type: 'string' }, supervisorNote: { type: 'string' } }, required: ['strength','focus','practice','supervisorNote'], additionalProperties: false } }, required: ['overallScore','confidence','summary','criteria','coaching'], additionalProperties: false } } }, max_output_tokens: 2200 }) }}",
        "options": {"timeout": 45000}
      },
      "id": "07-openai",
      "name": "OpenAI Score Fixed Rubric",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [-180, 120],
      "credentials": {"openAiApi": {"id": "REPLACE_WITH_N8N_CREDENTIAL_ID", "name": "OpenAI account"}}
    },
    {
      "parameters": {
        "jsCode": "const source = $('Check and Normalize Input').item.json;\nlet packet = $json.score_packet;\nif (!packet) {\n  const text = $json.output_text ?? $json.output?.[0]?.content?.find((part) => part.type === 'output_text')?.text;\n  if (!text) throw new Error('OpenAI did not return a score packet.');\n  packet = JSON.parse(text);\n}\nconst weights = { safety: 30, empathy: 20, discovery: 20, resolution: 20, close: 10 };\nif (!Array.isArray(packet.criteria) || packet.criteria.length !== 5) throw new Error('Expected five rubric criteria.');\nconst weightedPoints = packet.criteria.every((item) => item.id in weights && Number(item.score) <= weights[item.id]) && packet.overallScore > 40;\nconst seen = new Set();\nfor (const item of packet.criteria) {\n  if (!(item.id in weights) || seen.has(item.id)) throw new Error(`Invalid rubric id: ${item.id}`);\n  seen.add(item.id); const raw = weightedPoints ? Number(item.score) / weights[item.id] * 100 : Number(item.score); item.weight = weights[item.id]; item.score = Math.max(0, Math.min(100, Math.round(raw)));\n  if (!['met','partial','missed','review'].includes(item.status)) item.status = 'review';\n}\npacket.overallScore = Math.round(packet.criteria.reduce((sum, item) => sum + item.score * item.weight, 0) / 100);\npacket.needsSupervisorReview = packet.confidence !== 'high' || packet.criteria.some((item) => item.status === 'review');\nreturn [{ json: { interaction_id: source.interaction_id, channel: source.channel, reviewed_at: new Date().toISOString(), ...packet, authority: 'Supervisor must review before coaching or employee action.' } }];"
      },
      "id": "08-validate",
      "name": "Validate and Recalculate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [100, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
          "conditions": [{"id": "review-check", "leftValue": "={{ $json.needsSupervisorReview }}", "rightValue": true, "operator": {"type": "boolean", "operation": "true", "singleValue": true}}],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "09-review-gate",
      "name": "Needs Extra Review?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [350, 0]
    },
    {
      "parameters": {
        "assignments": {"assignments": [{"id": "rv1", "name": "review_status", "value": "needs supervisor review", "type": "string"}]},
        "includeOtherFields": true,
        "options": {}
      },
      "id": "10-flag",
      "name": "Flag for Supervisor",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [590, -90]
    },
    {
      "parameters": {
        "assignments": {"assignments": [{"id": "rv2", "name": "review_status", "value": "ready for supervisor confirmation", "type": "string"}]},
        "includeOtherFields": true,
        "options": {}
      },
      "id": "11-ready",
      "name": "Prepare Review Packet",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [590, 90]
    },
    {
      "parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}},
      "id": "12-respond",
      "name": "Return Score and Coaching",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [850, 0]
    }
  ],
  "connections": {
    "Run Synthetic Demo": {"main": [[{"node": "Synthetic Transcript", "type": "main", "index": 0}]]},
    "Synthetic Transcript": {"main": [[{"node": "Check and Normalize Input", "type": "main", "index": 0}]]},
    "Receive Transcript": {"main": [[{"node": "Check and Normalize Input", "type": "main", "index": 0}]]},
    "Check and Normalize Input": {"main": [[{"node": "Demo or Live AI", "type": "main", "index": 0}]]},
    "Demo or Live AI": {"main": [[{"node": "Fixed Synthetic Score", "type": "main", "index": 0}], [{"node": "OpenAI Score Fixed Rubric", "type": "main", "index": 0}]]},
    "Fixed Synthetic Score": {"main": [[{"node": "Validate and Recalculate", "type": "main", "index": 0}]]},
    "OpenAI Score Fixed Rubric": {"main": [[{"node": "Validate and Recalculate", "type": "main", "index": 0}]]},
    "Validate and Recalculate": {"main": [[{"node": "Needs Extra Review?", "type": "main", "index": 0}]]},
    "Needs Extra Review?": {"main": [[{"node": "Flag for Supervisor", "type": "main", "index": 0}], [{"node": "Prepare Review Packet", "type": "main", "index": 0}]]},
    "Flag for Supervisor": {"main": [[{"node": "Return Score and Coaching", "type": "main", "index": 0}]]},
    "Prepare Review Packet": {"main": [[{"node": "Return Score and Coaching", "type": "main", "index": 0}]]}
  },
  "settings": {"executionOrder": "v1"},
  "active": false,
  "pinData": {},
  "versionId": "d9827145-4b17-427d-9b9d-f411389b02fd",
  "meta": {"templateCredsSetupCompleted": false},
  "tags": []
}
