79일차 운영 데이터 기반 Ops Assistant: 실습 15개 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브), 정답본 LAB-A · answers · VIBE-A · tops 포함

This commit is contained in:
mcchae committed 2026-11-03 20:54:35 +09:00
1 parent 9ce9aceba6
commit 6f0ecd59a9
156 files changed
+31640

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
{
"name": "79 Ops ask (team baseline)",
"nodes": [
{
"name": "Ask webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "base-ops-ask",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "cacb2fe4-6d95-5bdc-ace5-d418b067f9f2",
"webhookId": "17b262ab-6142-5063-99bc-1689af27b62b"
},
{
"name": "Get facts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-ops-facts",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({}) }}"
},
"position": [
220,
300
],
"id": "d19a4c49-1318-5d1f-b074-feb88ba1895f"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Ask webhook').first().json.body.question }}\n운영 데이터: {{ JSON.stringify($json) }}",
"options": {
"systemMessage": "You are a helpful operations assistant for an IT helpdesk team. Answer the operator's request in Korean.",
"maxIterations": 1
}
},
"onError": "continueRegularOutput",
"position": [
440,
300
],
"id": "1471ab65-56b3-5a9e-9857-3588af91bb7d"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
440,
520
],
"id": "522004dd-9a0f-5713-8527-eb56487ebff0"
},
{
"name": "Make answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { question: $('Ask webhook').first().json.body.question, answer: String($json.output ?? '') } }];"
},
"position": [
660,
300
],
"id": "2404ff7d-7934-5db7-957f-3f9ea817018f"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
880,
300
],
"id": "ee9a10b6-d4d1-5801-9147-89e67b2bed92"
}
],
"connections": {
"Ask webhook": {
"main": [
[
{
"node": "Get facts",
"type": "main",
"index": 0
}
]
]
},
"Get facts": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Make answer",
"type": "main",
"index": 0
}
]
]
},
"Make answer": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,205 @@
{
"name": "79 Ops facts (team baseline)",
"nodes": [
{
"name": "Facts webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "base-ops-facts",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "e31e497c-4628-5404-98d8-d6cbafc7c5ee",
"webhookId": "29a51e92-50af-5329-aef4-8662be597525"
},
{
"name": "Get logs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/logs/query",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({}) }}"
},
"position": [
220,
300
],
"id": "46df7394-80a0-51d6-b749-5ac183227830"
},
{
"name": "Get alerts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
440,
300
],
"id": "d1bbadd5-3cbc-536a-8173-ac7a85579305"
},
{
"name": "Get DLQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/dlq",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
660,
300
],
"id": "3f4c95d0-be36-5206-8b5a-741d1c43e7e0"
},
{
"name": "Get budget",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/budget",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "63abc78c-ec4f-5c79-9b9a-f073d8a04a97"
},
{
"name": "Make facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "// team baseline: the morning dashboard we put together last month\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst alerts = $('Get alerts').first().json, dlq = $('Get DLQ').first().json, budget = $('Get budget').first().json;\nreturn [{ json: {\n runs: { total, errors, error_rate: total ? errors / total * 100 : null },\n alerts: { count: alerts.count },\n dlq: { size: dlq.size },\n cost: { used_usd: budget.used_usd, daily_usd: budget.daily_usd },\n} }];"
},
"position": [
1100,
300
],
"id": "7dc18d76-23f3-583c-95e3-4d6c246c218a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1320,
300
],
"id": "627c47e0-68e1-5f61-892b-8a56a7d2a1cc"
}
],
"connections": {
"Facts webhook": {
"main": [
[
{
"node": "Get logs",
"type": "main",
"index": 0
}
]
]
},
"Get logs": {
"main": [
[
{
"node": "Get alerts",
"type": "main",
"index": 0
}
]
]
},
"Get alerts": {
"main": [
[
{
"node": "Get DLQ",
"type": "main",
"index": 0
}
]
]
},
"Get DLQ": {
"main": [
[
{
"node": "Get budget",
"type": "main",
"index": 0
}
]
]
},
"Get budget": {
"main": [
[
{
"node": "Make facts",
"type": "main",
"index": 0
}
]
]
},
"Make facts": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,281 @@
{
"name": "79 Ops facts (reference)",
"nodes": [
{
"name": "Facts webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-facts",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "663cb969-bc5a-52e7-929e-7e0161c2806d",
"position": [
0,
300
],
"id": "2b858e10-bcb6-507a-b018-ccad5055474c"
},
{
"name": "Get logs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/logs/query",
"options": {},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $('Facts webhook').first().json.body?.from || '2026-10-26', to: $('Facts webhook').first().json.body?.to || '2026-10-30' }) }}"
},
"position": [
220,
300
],
"id": "bf44c4e1-c21f-5ce3-8b3c-7a403c9de155"
},
{
"name": "Get alerts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {}
},
"position": [
440,
300
],
"id": "00bb14a7-97da-5f51-83c0-ba80bb44de43"
},
{
"name": "Get DLQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/dlq",
"options": {}
},
"position": [
660,
300
],
"id": "91d75ee3-35c2-5d59-8d9f-4245564a9be2"
},
{
"name": "Get quality",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {}
},
"position": [
880,
300
],
"id": "ec7984e1-42a4-544a-bf05-c45c5c620978"
},
{
"name": "Get quarantine",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quarantine",
"options": {}
},
"position": [
1100,
300
],
"id": "a617d18d-5214-5f24-bc5c-49efd6e41e8a"
},
{
"name": "Get usage",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/usage/summary",
"options": {}
},
"position": [
1320,
300
],
"id": "37980fb4-f88c-56a4-81de-79e4a1544312"
},
{
"name": "Get budget",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/budget",
"options": {}
},
"position": [
1540,
300
],
"id": "910bbd7e-c6fe-5a06-abd7-fd4b60dc33da"
},
{
"name": "Get release",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/releases/helpdesk",
"options": {}
},
"position": [
1760,
300
],
"id": "ad4454f9-c31e-5c86-b44d-70c35b2c6f10"
},
{
"name": "Make facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Facts webhook').first().json.body ?? {};\nconst from = body.from || '2026-10-26', to = body.to || '2026-10-30';\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst d = logs.map((l) => l.duration_ms).sort((a, b) => a - b);\nconst p95 = d.length ? d[Math.max(0, Math.ceil(0.95 * d.length) - 1)] : 0;\nconst groups = {};\nfor (const l of logs.filter((x) => x.status === 'error')) { const k = l.error_node + '\\u0000' + l.error_message; groups[k] = (groups[k] ?? 0) + 1; }\nconst top = Object.entries(groups).sort((a, b) => b[1] - a[1])[0];\nconst rel = {};\nfor (const l of logs.filter((x) => x.route === '자동 답변')) { const x = (rel[l.release] ??= { answers: 0, repeats: 0 }); x.answers += 1; if (l.repeat) x.repeats += 1; }\nconst rate = (name) => rel[name] ? r4(rel[name].repeats / rel[name].answers) : null;\nconst alerts = $('Get alerts').first().json.alerts ?? [];\nconst byReason = {};\nfor (const a of alerts) byReason[a.reason ?? 'unknown'] = (byReason[a.reason ?? 'unknown'] ?? 0) + 1;\nconst dlq = $('Get DLQ').first().json.items ?? [];\nconst q = $('Get quality').first().json.items ?? [];\nconst quarantine = $('Get quarantine').first().json.size ?? 0;\nconst usage = $('Get usage').first().json, budget = $('Get budget').first().json;\nconst users = Object.entries(usage.by_user ?? {}).sort((a, b) => b[1].cost_usd - a[1].cost_usd);\nconst release = $('Get release').first().json;\nconst facts = {\n period: { from, to },\n runs: { total, errors, error_rate: total ? r4(errors / total) : 0, p95_ms: p95,\n unanswered_rate: total ? r4(logs.filter((l) => l.route === '담당자 연결').length / total) : 0,\n top_failure: top ? { node: top[0].split('\\u0000')[0], message: top[0].split('\\u0000')[1], count: top[1] } : null },\n alerts: { count: alerts.length, high: alerts.filter((a) => a.severity === 'high').length, by_reason: byReason },\n dlq: { size: dlq.length, parked: dlq.filter((x) => x.parked).length, waiting: dlq.filter((x) => !x.parked).length },\n quality: { batches: q.length, avg_score: q.length ? r4(q.reduce((s, x) => s + x.score, 0) / q.length) : 0,\n last_score: q.length ? q.at(-1).score : null, rejected_batches: q.filter((x) => x.rejected).length, quarantine },\n cost: { requests: usage.requests ?? 0, calls: usage.calls ?? 0, cost_usd: Math.round((usage.cost_usd ?? 0) * 1e6) / 1e6,\n daily_usd: budget.daily_usd, used_ratio: budget.daily_usd ? r4(budget.used_usd / budget.daily_usd) : 0,\n top_user: users.length ? { user: users[0][0], cost_usd: Math.round(users[0][1].cost_usd * 1e6) / 1e6, share: r4(users[0][1].cost_usd / usage.cost_usd) } : null },\n release: { active: release.active, canary_release: release.canary?.release ?? null, canary_percent: release.canary?.percent ?? 0,\n active_repeat_rate: rate(release.active), canary_repeat_rate: release.canary ? rate(release.canary.release) : null },\n};\n\nreturn [{ json: facts }];"
},
"position": [
1980,
300
],
"id": "207a0874-b62e-5dd2-bf56-3d8f273ca7c1"
},
{
"name": "Send facts",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
2200,
300
],
"id": "2e25d833-7a9c-52cf-a2ae-7c20cccb5ba6"
}
],
"connections": {
"Facts webhook": {
"main": [
[
{
"node": "Get logs",
"type": "main",
"index": 0
}
]
]
},
"Get logs": {
"main": [
[
{
"node": "Get alerts",
"type": "main",
"index": 0
}
]
]
},
"Get alerts": {
"main": [
[
{
"node": "Get DLQ",
"type": "main",
"index": 0
}
]
]
},
"Get DLQ": {
"main": [
[
{
"node": "Get quality",
"type": "main",
"index": 0
}
]
]
},
"Get quality": {
"main": [
[
{
"node": "Get quarantine",
"type": "main",
"index": 0
}
]
]
},
"Get quarantine": {
"main": [
[
{
"node": "Get usage",
"type": "main",
"index": 0
}
]
]
},
"Get usage": {
"main": [
[
{
"node": "Get budget",
"type": "main",
"index": 0
}
]
]
},
"Get budget": {
"main": [
[
{
"node": "Get release",
"type": "main",
"index": 0
}
]
]
},
"Get release": {
"main": [
[
{
"node": "Make facts",
"type": "main",
"index": 0
}
]
]
},
"Make facts": {
"main": [
[
{
"node": "Send facts",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,289 @@
{
"name": "79 Ops ask (reference)",
"nodes": [
{
"name": "Ask webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-ask",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "e0cc9a1d-38cf-580a-b52e-3f6d8bb1cab2",
"position": [
0,
300
],
"id": "5bab6bdc-b287-574f-bfc6-713e57603960"
},
{
"name": "Route",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const q = String($('Ask webhook').first().json.body?.question ?? '').trim();\nconst lower = q.toLowerCase();\nconst words = {\n runs: ['오류', '에러', '실패율', '느려', '느린', '응답 시간', 'p95', '실행'],\n alerts: ['알림', '경보'],\n dlq: ['dlq', '재처리', '쌓인', 'parked', '접수 실패'],\n quality: ['품질', '배치', '격리'],\n cost: ['비용', '돈', '예산', '토큰', '요금'],\n release: ['릴리스', '버전', '카나리', '배포'],\n overview: ['괜찮', '상태', '요약', '전체', '브리핑'],\n};\n\nlet area = 'unknown', best = 0;\nfor (const [k, list] of Object.entries(words)) {\n const n = list.filter((w) => lower.includes(w)).length;\n if (n > best) { best = n; area = k; }\n}\nreturn [{ json: { question: q, area, router: 'keyword' } }];"
},
"position": [
220,
300
],
"id": "454883f1-d845-5ef3-8ac0-2273b7b626f8"
},
{
"name": "Known area?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.area !== 'unknown' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
440,
300
],
"id": "79d63d99-7ccf-589f-b573-0494c689fddf"
},
{
"name": "Out of scope",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ question: $json.question, area: 'unknown', router: $json.router, refused: true, reason: 'out_of_scope', answer: '운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다.' }) }}",
"options": {}
},
"position": [
660,
560
],
"id": "d350c7de-a80a-5f09-8fa6-bbb42b78784d"
},
{
"name": "Get facts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-ops-facts",
"options": {
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $('Ask webhook').first().json.body?.from ?? '', to: $('Ask webhook').first().json.body?.to ?? '' }) }}"
},
"position": [
660,
300
],
"id": "7c08a9c6-99a6-5967-a699-678a1fd11701"
},
{
"name": "Pick facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const r = $('Route').first().json;\nconst f = $('Get facts').first().json;\nconst part = r.area === 'overview' ? f : { period: f.period, [r.area]: f[r.area], ...(f.health ? { health: f.health[r.area] } : {}) };\nreturn [{ json: { ...r, facts: part } }];"
},
"position": [
880,
300
],
"id": "d7524d66-8ab6-5122-bd66-bce5849d9f09"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $json.question }}\n사실(JSON): {{ JSON.stringify($json.facts) }}",
"options": {
"systemMessage": "You are the operations assistant of a company IT helpdesk team. Answer the operator's question in Korean, 2-3 sentences, using only the facts JSON you are given. Use only numbers that appear in the facts (a ratio like 0.0588 may be written as 5.88%). Do not guess causes that are not in the facts. Do not offer to run any action; you can only read.",
"maxIterations": 1
}
},
"position": [
1100,
300
],
"id": "f049e910-3871-5073-bbdb-63ce3e969af5"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1100,
520
],
"id": "05b19df9-03bd-5ba0-bb97-58a5433ddf1e"
},
{
"name": "Make answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const r = $('Pick facts').first().json;\nreturn [{ json: { question: r.question, area: r.area, router: r.router, refused: false, reason: null, answer: String($json.output ?? '').trim() } }];"
},
"position": [
1320,
300
],
"id": "d8e2b63f-ecb2-5934-b1ee-d7685e3d0a50"
},
{
"name": "Send answer",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
300
],
"id": "3b97fc93-c615-5711-a0a6-210980ab11d7"
}
],
"connections": {
"Ask webhook": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Known area?",
"type": "main",
"index": 0
}
]
]
},
"Known area?": {
"main": [
[
{
"node": "Get facts",
"type": "main",
"index": 0
}
],
[
{
"node": "Out of scope",
"type": "main",
"index": 0
}
]
]
},
"Get facts": {
"main": [
[
{
"node": "Pick facts",
"type": "main",
"index": 0
}
]
]
},
"Pick facts": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Make answer",
"type": "main",
"index": 0
}
]
]
},
"Make answer": {
"main": [
[
{
"node": "Send answer",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,356 @@
{
"name": "79 Ops ask + refuse + number check (reference)",
"nodes": [
{
"name": "Ask webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-ask",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "e0cc9a1d-38cf-580a-b52e-3f6d8bb1cab2",
"position": [
0,
300
],
"id": "dc6271e7-1050-5416-bf0a-26688d2d29ab"
},
{
"name": "Route",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const q = String($('Ask webhook').first().json.body?.question ?? '').trim();\nconst lower = q.toLowerCase();\nconst words = {\n runs: ['오류', '에러', '실패율', '느려', '느린', '응답 시간', 'p95', '실행'],\n alerts: ['알림', '경보'],\n dlq: ['dlq', '재처리', '쌓인', 'parked', '접수 실패'],\n quality: ['품질', '배치', '격리'],\n cost: ['비용', '돈', '예산', '토큰', '요금'],\n release: ['릴리스', '버전', '카나리', '배포'],\n overview: ['괜찮', '상태', '요약', '전체', '브리핑'],\n};\nconst action = /(다시 돌려|재처리해|지워|삭제해|꺼 ?줘|켜 ?줘|배포해|되돌려|롤백해|바꿔 ?줘|올려 ?줘|내려 ?줘|비워 ?줘)/.test(q);\nif (action) return [{ json: { question: q, area: 'action', router: 'keyword' } }];\nlet area = 'unknown', best = 0;\nfor (const [k, list] of Object.entries(words)) {\n const n = list.filter((w) => lower.includes(w)).length;\n if (n > best) { best = n; area = k; }\n}\nreturn [{ json: { question: q, area, router: 'keyword' } }];"
},
"position": [
220,
300
],
"id": "f9ebba3b-b71b-5cef-81d3-d2701e982ee8"
},
{
"name": "Action?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.area === 'action' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
440,
300
],
"id": "1c6e30aa-01a8-51bd-b897-d46bf26619bc"
},
{
"name": "Refuse action",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ question: $json.question, area: 'action', router: $json.router, refused: true, reason: 'read_only', answer: '운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요.' }) }}",
"options": {}
},
"position": [
660,
560
],
"id": "0648af6b-cd57-59c3-933b-b2aa0b83b4cb"
},
{
"name": "Known area?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.area !== 'unknown' }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
660,
300
],
"id": "fde1fecc-4f44-52dd-93ef-12ac37a6e362"
},
{
"name": "Out of scope",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ question: $json.question, area: 'unknown', router: $json.router, refused: true, reason: 'out_of_scope', answer: '운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다.' }) }}",
"options": {}
},
"position": [
880,
560
],
"id": "c218ca92-1811-5f8a-82cd-ab5f63d67c9a"
},
{
"name": "Get facts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-ops-facts",
"options": {
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $('Ask webhook').first().json.body?.from ?? '', to: $('Ask webhook').first().json.body?.to ?? '' }) }}"
},
"position": [
880,
300
],
"id": "45a16e6c-5137-5047-9e26-2baadb544368"
},
{
"name": "Pick facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const r = $('Route').first().json;\nconst f = $('Get facts').first().json;\nconst part = r.area === 'overview' ? f : { period: f.period, [r.area]: f[r.area], ...(f.health ? { health: f.health[r.area] } : {}) };\nreturn [{ json: { ...r, facts: part } }];"
},
"position": [
1100,
300
],
"id": "bb44be68-2a9f-5615-abf7-3931749039e6"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $json.question }}\n사실(JSON): {{ JSON.stringify($json.facts) }}",
"options": {
"systemMessage": "You are the operations assistant of a company IT helpdesk team. Answer the operator's question in Korean, 2-3 sentences, using only the facts JSON you are given. Use only numbers that appear in the facts (a ratio like 0.0588 may be written as 5.88%). Do not guess causes that are not in the facts. Do not offer to run any action; you can only read.",
"maxIterations": 1
}
},
"position": [
1320,
300
],
"id": "53eb5e17-4cef-5f54-bdef-371560ec5368"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
520
],
"id": "2db947c2-c91c-57a4-ab27-5376b329d71c"
},
{
"name": "Check numbers",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const r = $('Pick facts').first().json;\nconst answer = String($json.output ?? '').trim();\nconst allowed = new Set();\nconst add = (v) => { for (const d of [0, 1, 2, 3, 4, 6]) allowed.add(Number(v.toFixed(d))); };\nconst walk = (v) => {\n if (typeof v === 'number') { add(v); add(v * 100); }\n else if (typeof v === 'string') { for (const m of v.match(/\\d+(?:\\.\\d+)?/g) ?? []) add(Number(m)); }\n else if (v && typeof v === 'object') { for (const [k, x] of Object.entries(v)) { walk(k); walk(x); } }\n};\nwalk(r.facts);\nconst versions = JSON.stringify(r.facts).match(/\\d+\\.\\d+\\.\\d+/g) ?? [];\nlet text = answer;\nfor (const v of versions) text = text.split(v).join(' ');\nconst unknown = (text.match(/\\d+(?:\\.\\d+)?/g) ?? []).filter((m) => !allowed.has(Number(m)));\nreturn [{ json: { question: r.question, area: r.area, router: r.router, refused: false, reason: null, answer,\n numbers_ok: unknown.length === 0, unknown_numbers: [...new Set(unknown)] } }];"
},
"position": [
1540,
300
],
"id": "1f4d46b1-ab0c-521c-babb-3cbd460c2ea0"
},
{
"name": "Send answer",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1760,
300
],
"id": "f18b4f0d-663b-59cd-a45b-44523b719d9b"
}
],
"connections": {
"Ask webhook": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Action?",
"type": "main",
"index": 0
}
]
]
},
"Action?": {
"main": [
[
{
"node": "Refuse action",
"type": "main",
"index": 0
}
],
[
{
"node": "Known area?",
"type": "main",
"index": 0
}
]
]
},
"Known area?": {
"main": [
[
{
"node": "Get facts",
"type": "main",
"index": 0
}
],
[
{
"node": "Out of scope",
"type": "main",
"index": 0
}
]
]
},
"Get facts": {
"main": [
[
{
"node": "Pick facts",
"type": "main",
"index": 0
}
]
]
},
"Pick facts": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Check numbers",
"type": "main",
"index": 0
}
]
]
},
"Check numbers": {
"main": [
[
{
"node": "Send answer",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,132 @@
{
"name": "79 Ops brief (reference)",
"nodes": [
{
"name": "Brief webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-brief",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "d8018a49-2915-5dd1-adc6-aa234e759aa9",
"position": [
0,
300
],
"id": "f5d30f9a-d747-55da-969f-360018e9323a"
},
{
"name": "Get facts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-ops-facts",
"options": {
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $json.body?.from ?? '', to: $json.body?.to ?? '' }) }}"
},
"position": [
220,
300
],
"id": "f4e1e0cf-6d75-58c5-963f-ede1302344e8"
},
{
"name": "Make brief",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const f = $('Get facts').first().json;\nconst label = { runs: '실행', alerts: '알림', dlq: 'DLQ', quality: '데이터 품질', cost: '비용', release: '릴리스' };\nconst mark = { red: '🔴', yellow: '🟡', green: '🟢', unknown: '⚪' };\nconst actionsFor = {\"runs\": \"오류 묶음 1위 확인 · 고치기 (77일차)\", \"alerts\": \"높은 알림부터 확인하고 닫기 (76일차)\", \"dlq\": \"parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\", \"quality\": \"격리함을 콘텐츠 담당자에게 (78일차)\", \"cost\": \"비용 1위 사용자 · 한도 확인 (75일차)\", \"release\": \"카나리 끄기 · 되돌리기 검토 (74일차)\"};\nconst pct = (v) => (Math.round(v * 10000) / 100) + '%';\nconst g = (k, fn) => (f[k] === null ? '출처 응답 없음' : fn());\nconst key = {\n runs: g('runs', () => `오류율 ${pct(f.runs.error_rate)} · p95 ${f.runs.p95_ms}ms · 답 못 함 ${pct(f.runs.unanswered_rate)}`),\n alerts: g('alerts', () => `${f.alerts.count}건 (높음 ${f.alerts.high})`),\n dlq: g('dlq', () => `${f.dlq.size}건 (parked ${f.dlq.parked})`),\n quality: g('quality', () => `평균 ${f.quality.avg_score} · 마지막 ${f.quality.last_score} · 격리 ${f.quality.quarantine}`),\n cost: g('cost', () => `$${f.cost.cost_usd} / $${f.cost.daily_usd} (${pct(f.cost.used_ratio)})`),\n release: g('release', () => `${f.release.active}` + (f.release.canary_release ? ` · 카나리 ${f.release.canary_release} ${f.release.canary_percent}% (다시 묻기 ${f.release.canary_repeat_rate === null ? '-' : pct(f.release.canary_repeat_rate)})` : '')),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nconst focus = [...f.red, ...f.yellow].slice(0, 3);\nconst actions = focus.map((k) => ({ area: k, status: f.health[k], action: actionsFor[k] }));\nconst lines = [\n `# 운영 브리핑 (${f.period.from} ~ ${f.period.to} 실행 기록 + 지금 상태)`, '',\n `전체: ${mark[f.overall]} ${f.overall}`, '',\n '| 영역 | 상태 | 숫자 |', '| --- | --- | --- |',\n ...order.map((k) => `| ${label[k]} | ${mark[f.health[k]]} | ${key[k]} |`), '',\n '## 먼저 볼 것', '',\n ...actions.map((a, i) => `${i + 1}. ${mark[a.status]} ${label[a.area]}: ${a.action}`),\n];\nreturn [{ json: { overall: f.overall, red: f.red, yellow: f.yellow, actions, markdown: lines.join('\\n'), facts: f } }];"
},
"position": [
440,
300
],
"id": "7ac33296-1304-5a8b-a95a-1129228a81e4"
},
{
"name": "Drop facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const { facts, ...rest } = $json; return [{ json: rest }];"
},
"position": [
660,
300
],
"id": "8932dd61-126a-5bb6-bd78-c2e8f62e131e"
},
{
"name": "Send brief",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
880,
300
],
"id": "5f04ca50-933a-5d1c-82a6-b22f607ee3f4"
}
],
"connections": {
"Brief webhook": {
"main": [
[
{
"node": "Get facts",
"type": "main",
"index": 0
}
]
]
},
"Get facts": {
"main": [
[
{
"node": "Make brief",
"type": "main",
"index": 0
}
]
]
},
"Make brief": {
"main": [
[
{
"node": "Drop facts",
"type": "main",
"index": 0
}
]
]
},
"Drop facts": {
"main": [
[
{
"node": "Send brief",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,198 @@
{
"name": "79 Ops brief + summary (reference)",
"nodes": [
{
"name": "Brief webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-brief",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "d8018a49-2915-5dd1-adc6-aa234e759aa9",
"position": [
0,
300
],
"id": "4c8cf041-481f-5b3f-bda4-66b2e588a2ff"
},
{
"name": "Get facts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-ops-facts",
"options": {
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $json.body?.from ?? '', to: $json.body?.to ?? '' }) }}"
},
"position": [
220,
300
],
"id": "a841ddde-8f4b-56c1-ae8a-c81c8d9d1690"
},
{
"name": "Make brief",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const f = $('Get facts').first().json;\nconst label = { runs: '실행', alerts: '알림', dlq: 'DLQ', quality: '데이터 품질', cost: '비용', release: '릴리스' };\nconst mark = { red: '🔴', yellow: '🟡', green: '🟢', unknown: '⚪' };\nconst actionsFor = {\"runs\": \"오류 묶음 1위 확인 · 고치기 (77일차)\", \"alerts\": \"높은 알림부터 확인하고 닫기 (76일차)\", \"dlq\": \"parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\", \"quality\": \"격리함을 콘텐츠 담당자에게 (78일차)\", \"cost\": \"비용 1위 사용자 · 한도 확인 (75일차)\", \"release\": \"카나리 끄기 · 되돌리기 검토 (74일차)\"};\nconst pct = (v) => (Math.round(v * 10000) / 100) + '%';\nconst g = (k, fn) => (f[k] === null ? '출처 응답 없음' : fn());\nconst key = {\n runs: g('runs', () => `오류율 ${pct(f.runs.error_rate)} · p95 ${f.runs.p95_ms}ms · 답 못 함 ${pct(f.runs.unanswered_rate)}`),\n alerts: g('alerts', () => `${f.alerts.count}건 (높음 ${f.alerts.high})`),\n dlq: g('dlq', () => `${f.dlq.size}건 (parked ${f.dlq.parked})`),\n quality: g('quality', () => `평균 ${f.quality.avg_score} · 마지막 ${f.quality.last_score} · 격리 ${f.quality.quarantine}`),\n cost: g('cost', () => `$${f.cost.cost_usd} / $${f.cost.daily_usd} (${pct(f.cost.used_ratio)})`),\n release: g('release', () => `${f.release.active}` + (f.release.canary_release ? ` · 카나리 ${f.release.canary_release} ${f.release.canary_percent}% (다시 묻기 ${f.release.canary_repeat_rate === null ? '-' : pct(f.release.canary_repeat_rate)})` : '')),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nconst focus = [...f.red, ...f.yellow].slice(0, 3);\nconst actions = focus.map((k) => ({ area: k, status: f.health[k], action: actionsFor[k] }));\nconst lines = [\n `# 운영 브리핑 (${f.period.from} ~ ${f.period.to} 실행 기록 + 지금 상태)`, '',\n `전체: ${mark[f.overall]} ${f.overall}`, '',\n '| 영역 | 상태 | 숫자 |', '| --- | --- | --- |',\n ...order.map((k) => `| ${label[k]} | ${mark[f.health[k]]} | ${key[k]} |`), '',\n '## 먼저 볼 것', '',\n ...actions.map((a, i) => `${i + 1}. ${mark[a.status]} ${label[a.area]}: ${a.action}`),\n];\nreturn [{ json: { overall: f.overall, red: f.red, yellow: f.yellow, actions, markdown: lines.join('\\n'), facts: f } }];"
},
"position": [
440,
300
],
"id": "d8d05c5a-6a5d-5108-8983-3ee982fc0663"
},
{
"name": "Summarize",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=사실(JSON): {{ JSON.stringify($json.facts) }}",
"options": {
"systemMessage": "You write the morning operations summary for an IT helpdesk team lead. Korean, exactly 3 sentences: overall state, the most urgent area, what to do first. Use only numbers from the facts JSON (a ratio like 0.0588 may be written as 5.88%). Do not guess causes.",
"maxIterations": 1
}
},
"position": [
660,
300
],
"id": "eb2cf7af-1b3a-5ef8-a3c0-7fbd0c5fd155"
},
{
"name": "Summary model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
660,
520
],
"id": "a16dab74-014f-5d6e-9da6-ccc337283b08"
},
{
"name": "Check summary",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const r = $('Make brief').first().json;\nconst answer = String($json.output ?? '').trim();\nconst allowed = new Set();\nconst add = (v) => { for (const d of [0, 1, 2, 3, 4, 6]) allowed.add(Number(v.toFixed(d))); };\nconst walk = (v) => {\n if (typeof v === 'number') { add(v); add(v * 100); }\n else if (typeof v === 'string') { for (const m of v.match(/\\d+(?:\\.\\d+)?/g) ?? []) add(Number(m)); }\n else if (v && typeof v === 'object') { for (const [k, x] of Object.entries(v)) { walk(k); walk(x); } }\n};\nwalk(r.facts);\nconst versions = JSON.stringify(r.facts).match(/\\d+\\.\\d+\\.\\d+/g) ?? [];\nlet text = answer;\nfor (const v of versions) text = text.split(v).join(' ');\nconst unknown = (text.match(/\\d+(?:\\.\\d+)?/g) ?? []).filter((m) => !allowed.has(Number(m)));\nconst { facts, ...rest } = r;\nreturn [{ json: { ...rest, summary: answer, markdown: r.markdown + '\\n\\n## 요약\\n\\n' + answer, numbers_ok: unknown.length === 0, unknown_numbers: [...new Set(unknown)] } }];"
},
"position": [
880,
300
],
"id": "eaf526e2-064c-5d5c-ad13-67d237d99c4c"
},
{
"name": "Send brief",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1100,
300
],
"id": "9c60667c-d76b-585b-b79d-462504312f77"
}
],
"connections": {
"Brief webhook": {
"main": [
[
{
"node": "Get facts",
"type": "main",
"index": 0
}
]
]
},
"Get facts": {
"main": [
[
{
"node": "Make brief",
"type": "main",
"index": 0
}
]
]
},
"Make brief": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
]
]
},
"Summary model": {
"ai_languageModel": [
[
{
"node": "Summarize",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Check summary",
"type": "main",
"index": 0
}
]
]
},
"Check summary": {
"main": [
[
{
"node": "Send brief",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+281
View File
@@ -0,0 +1,281 @@
{
"name": "79 Ops facts + health (provided for Lab 02-03)",
"nodes": [
{
"name": "Facts webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-ops-facts",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "663cb969-bc5a-52e7-929e-7e0161c2806d",
"position": [
0,
300
],
"id": "fcc947d9-3c20-5ca9-a9f1-966a2ef67e09"
},
{
"name": "Get logs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/logs/query",
"options": {},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ from: $('Facts webhook').first().json.body?.from || '2026-10-26', to: $('Facts webhook').first().json.body?.to || '2026-10-30' }) }}"
},
"position": [
220,
300
],
"id": "2a61508c-26f2-511f-b5d0-4f2dee19a446"
},
{
"name": "Get alerts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {}
},
"position": [
440,
300
],
"id": "720fe6aa-a30d-5d21-a1ad-fb66166923c2"
},
{
"name": "Get DLQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/dlq",
"options": {}
},
"position": [
660,
300
],
"id": "05f59149-ec34-5045-97d1-238457326818"
},
{
"name": "Get quality",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {}
},
"position": [
880,
300
],
"id": "1e25a884-a558-5e19-8776-978b058878cb"
},
{
"name": "Get quarantine",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quarantine",
"options": {}
},
"position": [
1100,
300
],
"id": "31851fd7-0d3f-5000-9ebc-349ffae0174b"
},
{
"name": "Get usage",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/usage/summary",
"options": {}
},
"position": [
1320,
300
],
"id": "05b55404-3a20-5921-ae5f-83d3adfede60"
},
{
"name": "Get budget",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/budget",
"options": {}
},
"position": [
1540,
300
],
"id": "a2a75df9-ba29-5a1a-8b7f-1c04a62a8fa6"
},
{
"name": "Get release",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/releases/helpdesk",
"options": {}
},
"position": [
1760,
300
],
"id": "1097609c-e4f6-5332-8ffc-b31da541e5c6"
},
{
"name": "Make facts",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Facts webhook').first().json.body ?? {};\nconst from = body.from || '2026-10-26', to = body.to || '2026-10-30';\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst logs = $('Get logs').first().json.logs ?? [];\nconst total = logs.length, errors = logs.filter((l) => l.status === 'error').length;\nconst d = logs.map((l) => l.duration_ms).sort((a, b) => a - b);\nconst p95 = d.length ? d[Math.max(0, Math.ceil(0.95 * d.length) - 1)] : 0;\nconst groups = {};\nfor (const l of logs.filter((x) => x.status === 'error')) { const k = l.error_node + '\\u0000' + l.error_message; groups[k] = (groups[k] ?? 0) + 1; }\nconst top = Object.entries(groups).sort((a, b) => b[1] - a[1])[0];\nconst rel = {};\nfor (const l of logs.filter((x) => x.route === '자동 답변')) { const x = (rel[l.release] ??= { answers: 0, repeats: 0 }); x.answers += 1; if (l.repeat) x.repeats += 1; }\nconst rate = (name) => rel[name] ? r4(rel[name].repeats / rel[name].answers) : null;\nconst alerts = $('Get alerts').first().json.alerts ?? [];\nconst byReason = {};\nfor (const a of alerts) byReason[a.reason ?? 'unknown'] = (byReason[a.reason ?? 'unknown'] ?? 0) + 1;\nconst dlq = $('Get DLQ').first().json.items ?? [];\nconst q = $('Get quality').first().json.items ?? [];\nconst quarantine = $('Get quarantine').first().json.size ?? 0;\nconst usage = $('Get usage').first().json, budget = $('Get budget').first().json;\nconst users = Object.entries(usage.by_user ?? {}).sort((a, b) => b[1].cost_usd - a[1].cost_usd);\nconst release = $('Get release').first().json;\nconst facts = {\n period: { from, to },\n runs: { total, errors, error_rate: total ? r4(errors / total) : 0, p95_ms: p95,\n unanswered_rate: total ? r4(logs.filter((l) => l.route === '담당자 연결').length / total) : 0,\n top_failure: top ? { node: top[0].split('\\u0000')[0], message: top[0].split('\\u0000')[1], count: top[1] } : null },\n alerts: { count: alerts.length, high: alerts.filter((a) => a.severity === 'high').length, by_reason: byReason },\n dlq: { size: dlq.length, parked: dlq.filter((x) => x.parked).length, waiting: dlq.filter((x) => !x.parked).length },\n quality: { batches: q.length, avg_score: q.length ? r4(q.reduce((s, x) => s + x.score, 0) / q.length) : 0,\n last_score: q.length ? q.at(-1).score : null, rejected_batches: q.filter((x) => x.rejected).length, quarantine },\n cost: { requests: usage.requests ?? 0, calls: usage.calls ?? 0, cost_usd: Math.round((usage.cost_usd ?? 0) * 1e6) / 1e6,\n daily_usd: budget.daily_usd, used_ratio: budget.daily_usd ? r4(budget.used_usd / budget.daily_usd) : 0,\n top_user: users.length ? { user: users[0][0], cost_usd: Math.round(users[0][1].cost_usd * 1e6) / 1e6, share: r4(users[0][1].cost_usd / usage.cost_usd) } : null },\n release: { active: release.active, canary_release: release.canary?.release ?? null, canary_percent: release.canary?.percent ?? 0,\n active_repeat_rate: rate(release.active), canary_repeat_rate: release.canary ? rate(release.canary.release) : null },\n};\nconst s = (red, yellow) => (red ? 'red' : yellow ? 'yellow' : 'green');\nconst f = facts;\nconst canaryBad = f.release.canary_release && f.release.canary_repeat_rate !== null && f.release.active_repeat_rate !== null\n && f.release.canary_repeat_rate > 2 * f.release.active_repeat_rate;\nconst health = {\n runs: s(f.runs.error_rate > 0.01, f.runs.unanswered_rate > 0.02),\n alerts: s(f.alerts.high >= 5, f.alerts.count >= 1),\n dlq: s(f.dlq.parked > 0, f.dlq.size > 0),\n quality: s(f.quality.last_score !== null && f.quality.last_score < 0.8, (f.quality.last_score !== null && f.quality.last_score < 0.9) || f.quality.quarantine > 0),\n cost: s(f.cost.used_ratio >= 1, f.cost.used_ratio >= 0.8),\n release: s(canaryBad, Boolean(f.release.canary_release)),\n};\nconst order = ['runs', 'alerts', 'dlq', 'quality', 'cost', 'release'];\nfacts.health = health;\nfacts.red = order.filter((k) => health[k] === 'red');\nfacts.yellow = order.filter((k) => health[k] === 'yellow');\nfacts.overall = facts.red.length ? 'red' : facts.yellow.length ? 'yellow' : 'green';\nreturn [{ json: facts }];"
},
"position": [
1980,
300
],
"id": "f2c672e6-06db-5af2-9559-e0dd5ff7fa67"
},
{
"name": "Send facts",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
2200,
300
],
"id": "5fc1c214-e990-5522-9897-4644f07837a3"
}
],
"connections": {
"Facts webhook": {
"main": [
[
{
"node": "Get logs",
"type": "main",
"index": 0
}
]
]
},
"Get logs": {
"main": [
[
{
"node": "Get alerts",
"type": "main",
"index": 0
}
]
]
},
"Get alerts": {
"main": [
[
{
"node": "Get DLQ",
"type": "main",
"index": 0
}
]
]
},
"Get DLQ": {
"main": [
[
{
"node": "Get quality",
"type": "main",
"index": 0
}
]
]
},
"Get quality": {
"main": [
[
{
"node": "Get quarantine",
"type": "main",
"index": 0
}
]
]
},
"Get quarantine": {
"main": [
[
{
"node": "Get usage",
"type": "main",
"index": 0
}
]
]
},
"Get usage": {
"main": [
[
{
"node": "Get budget",
"type": "main",
"index": 0
}
]
]
},
"Get budget": {
"main": [
[
{
"node": "Get release",
"type": "main",
"index": 0
}
]
]
},
"Get release": {
"main": [
[
{
"node": "Make facts",
"type": "main",
"index": 0
}
]
]
},
"Make facts": {
"main": [
[
{
"node": "Send facts",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}