75일차 답 추가: 실습 정답 문서 · VIBE-A · 정답 워크플로우와 25단계 실행 기록 · tcost

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-29 09:57:32 +09:00
1 parent 01dc892303
commit ebff58c72e
79 files changed
+18753

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import json
import shutil
from pathlib import Path
import pytest
from tcost import prices
DATA = Path(__file__).parent / "data"
@pytest.fixture
def table():
return prices.load(prices.BUNDLED)
@pytest.fixture
def folder(tmp_path):
"""A day folder: two prompt files, two workflows, one test report, and a test case file (skipped)."""
(tmp_path / "prompts").mkdir()
(tmp_path / "prompts" / "short.txt").write_text("Answer in Korean in at most 3 sentences.", encoding="utf-8")
(tmp_path / "prompts" / "long.prompt").write_text("규정 " * 400, encoding="utf-8")
(tmp_path / "reference").mkdir()
for name in ("baseline-cost.json", "lab02-example1-lean.json"):
shutil.copy(DATA / name, tmp_path / "reference" / name)
(tmp_path / "runs").mkdir()
shutil.copy(DATA / "report-20261028-153000.json", tmp_path / "runs")
(tmp_path / "cases").mkdir()
(tmp_path / "cases" / "lab01.json").write_text(json.dumps({"webhook": "my-cost", "cases": []}), encoding="utf-8")
return tmp_path
@@ -0,0 +1,337 @@
{
"name": "75 IT helpdesk baseline (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "base-cost",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "ed2b30c2-2c16-5aaa-8ef1-30e5db0ed1b8",
"position": [
0,
300
],
"id": "40660866-5455-5c0b-9b7d-738dd69e81b6"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "bc6dc9fb-3a9a-598e-87b5-84c56b87bf41"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "9f9c9beb-967b-5ef1-b638-79897016df4a"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
440,
300
],
"id": "e42438af-4520-5622-994e-6a726a8c787d"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
660,
300
],
"id": "be57699b-a3ee-587d-991b-a7266e49e31a"
},
{
"name": "Classify",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
"options": {
"timeout": 60000
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
880,
300
],
"id": "f6ffca0c-45a6-526a-9ede-878a9e9090e0"
},
{
"name": "Answer",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\\n\\n참고 자료 (사내 규정 전체):\\n# 업무용 장비 지급·반납 규정\\n\\n## 1. 지급과 교체\\n- 업무용 노트북은 입사일에 지급한다.\\n- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.\\n\\n## 2. 분실과 도난\\n- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.\\n- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.\\n\\n## 3. 반납\\n- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.\\n- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.\\n\\n## 4. 개인 장비 사용\\n- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.\\n- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.\\n\\n# 메일·문서 보안 규정\\n\\n## 1. 메일함\\n- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.\\n- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.\\n\\n## 2. 첨부 파일\\n- 메일 첨부 파일은 25MB까지 보낼 수 있다.\\n- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.\\n- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.\\n\\n## 3. 문서 등급과 보관\\n- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.\\n- 업무 문서는 3년 동안 보관한 뒤 정리한다.\\n\\n# 회의실 이용 규정\\n\\n## 1. 예약\\n- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.\\n- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.\\n\\n## 2. 장비\\n- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.\\n- 프로젝터는 B회의실과 대회의실에 있다.\\n\\n## 3. 사용 후\\n- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.\\n\\n# 비밀번호·계정 보안 규정\\n\\n## 1. 비밀번호 만들기\\n- 비밀번호는 12자 이상으로 만든다.\\n- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.\\n- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.\\n\\n## 2. 변경 주기\\n- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.\\n- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.\\n\\n## 3. 로그인 실패와 잠금\\n- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.\\n- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.\\n\\n## 4. 다단계 인증\\n- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.\\n- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.\\n\\n# 개인정보 처리 지침\\n\\n## 1. 수집\\n- 업무에 꼭 필요한 개인정보만 수집한다.\\n- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.\\n\\n## 2. 이용과 보관\\n- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.\\n- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.\\n\\n## 3. 유출 대응\\n- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.\\n- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.\\n\\n# 원격근무·VPN 이용 규정\\n\\n## 1. 접속 원칙\\n- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.\\n- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.\\n\\n## 2. VPN 인증서\\n- VPN 인증서의 유효 기간은 1년이다.\\n- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.\\n- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.\\n\\n## 3. 재택근무 중 보안\\n- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠Line truncated
"options": {
"timeout": 60000
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1100,
300
],
"id": "f343fe5b-4ba5-58d6-9880-2f88e6e56b37"
},
{
"name": "Review",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
"options": {
"timeout": 60000
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
300
],
"id": "f0ccdaf6-2e82-5b98-9b1d-61479d53fc9a"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step } };\nreturn [{ json: result }];"
},
"position": [
1540,
300
],
"id": "e888a357-ae1a-5a73-bad7-aab2e26e2f93"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1760,
300
],
"id": "01f85767-942d-5931-bd59-036b1567805e"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,423 @@
{
"name": "75 IT helpdesk + lean prompt (reference)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-cost",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "797db9f9-e9ff-5363-9d0a-58d23a2047f0",
"position": [
0,
300
],
"id": "3ccc3834-a583-51bd-a80f-b20124e32b64"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "7e27dce9-fa3b-5c88-98b3-1dce956d16d2"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "f3591be9-5676-5bb7-ab46-6984719af080"
},
{
"name": "Get prices",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/prices",
"options": {}
},
"position": [
1100,
300
],
"id": "baa4796d-564b-5cc0-949a-d79738b12ba7"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
1540,
300
],
"id": "e2005518-7a82-579a-acda-cc74b33968a9"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
1760,
300
],
"id": "9d6c9b87-54bc-5433-bad3-6e33744e10ca"
},
{
"name": "Classify",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You sort one company IT help-desk question. Categories: faq (how to fix or use something), ticket (asks about the status of a ticket like T-1002), other. Return only JSON, no code fence: {\\\"category\\\": \\\"faq\\\"}\" }, { role: 'user', content: $('Question webhook').first().json.body.question }] }) }}",
"options": {
"timeout": 60000
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2420,
300
],
"id": "07e553a6-f8eb-5513-a566-db99f5b11d29"
},
{
"name": "Answer",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question + '\\nFAQ: ' + JSON.stringify(($('Search FAQ').first().json.results ?? []).map((f) => ({ id: f.id, title: f.title, answer: f.answer }))) + '\\n접수 건: ' + JSON.stringify((({ ticket_id, title, status, note, error }) => ({ ticket_id, title, status, note, error }))($('Get ticket').first().json)) }] }) }}",
"options": {
"timeout": 60000
}
},
"position": [
2640,
300
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"id": "fd329729-e718-56ff-805c-aa298d26c0b2"
},
{
"name": "Review",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: \"You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.\" }, { role: 'user', content: '초안: ' + $('Answer').first().json.choices[0].message.content }] }) }}",
"options": {
"timeout": 60000
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2860,
300
],
"id": "f289c264-5e1f-523d-8e18-67ba4cbb166c"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst steps = [{\"step\": \"classify\", \"node\": \"Classify\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"answer\", \"node\": \"Answer\", \"model\": \"gpt-4.1-mini\"}, {\"step\": \"review\", \"node\": \"Review\", \"model\": \"gpt-4.1-mini\"}];\nconst prices = $('Get prices').first().json.models;\nconst by_step = {};\nlet prompt_tokens = 0, completion_tokens = 0, llm_calls = 0, cost_usd = 0;\nfor (const s of steps) {\n if (!ran(s.node)) continue;\n const u = $(s.node).first().json.usage ?? {};\n const row = { model: s.model, prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0 };\n const p = prices[s.model] ?? { input: 0, output: 0 };\n row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;\n by_step[s.step] = row;\n prompt_tokens += row.prompt_tokens; completion_tokens += row.completion_tokens; cost_usd += row.cost_usd; llm_calls += 1;\n}\nconst results = $('Search FAQ').first().json.results ?? [];\nconst answer = String($('Review').first().json.choices[0].message.content ?? '').trim();\nconst result = { route: '자동 답변', answer, sources: results.map((r) => r.id), llm_calls,\n usage: { prompt_tokens, completion_tokens, by_step }, cost_usd: Math.round(cost_usd * 1e8) / 1e8 };\nreturn [{ json: result }];"
},
"position": [
3520,
300
],
"id": "d85f4d2b-0957-5a81-a54b-35ba55509871"
},
{
"name": "Record usage",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"method": "POST",
"url": "http://data-api:8800/usage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 3000
}
},
"position": [
3740,
300
],
"onError": "continueRegularOutput",
"id": "a46b3503-5039-5cb6-890e-69149fe23f55"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$('Build result').first().json, recorded: Number($json.stored ?? 0) > 0 } }];"
},
"position": [
3960,
300
],
"id": "1b8127ae-a43b-5096-8f94-c96892a085db"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4180,
300
],
"id": "6cbcbfe3-9306-5287-b0c6-79f3fe0f9a5e"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Get prices",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Get prices": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Record usage",
"type": "main",
"index": 0
}
]
]
},
"Record usage": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,9 @@
{"cases": "lab02-challenge.json", "webhook": "my-cost", "passed": 2, "total": 3, "results": [
{"case": "vpn", "request": {"question": "VPN", "user": "이준호"}, "status": 200,
"response": {"llm_calls": 1, "cost_usd": 0.00056, "usage": {"by_step": {"answer": {"model": "gpt-4.1-mini", "prompt_tokens": 1000, "completion_tokens": 100, "cost_usd": 0.00056}}}}},
{"case": "other", "request": {"question": "회의실", "user": "박서연"}, "status": 200,
"response": {"llm_calls": 2, "cost_usd": 0.0001845, "usage": {"by_step": {
"classify": {"model": "gpt-5.4-nano", "prompt_tokens": 100, "completion_tokens": 20, "cost_usd": 0.000045},
"answer": {"model": "gpt-4.1-mini", "prompt_tokens": 200, "completion_tokens": 37, "cost_usd": 0.0001392}}}}},
{"case": "empty", "request": {"question": ""}, "status": 400, "response": {"error": "question is required"}}
]}
+17
View File
@@ -0,0 +1,17 @@
"""R8: the three tabs fill from one folder, without calling any API."""
from textual.widgets import DataTable, TabbedContent
from tcost.app import CostApp
async def test_tabs(folder, table):
app = CostApp(str(folder), table)
async with app.run_test() as pilot:
await pilot.pause()
prompts = app.query_one("#prompt-table", DataTable)
assert prompts.row_count == 2 + 6 # two prompt files + six LLM steps of two workflows
app.query_one(TabbedContent).active = "usage"
await pilot.pause()
assert "2 request(s)" in str(app.query_one("#usage-head").render())
assert "15h" in str(app.query_one("#hours").render())
assert app.query_one("#compare-table", DataTable).row_count == 8
+46
View File
@@ -0,0 +1,46 @@
"""R7 · R9 · R10 · R12: commands, --json and exit codes (0 fine · 1 over --max-usd · 2 error)."""
import json
import pytest
from tcost.cli import main
def run(capsys, *argv):
with pytest.raises(SystemExit) as end:
main(list(argv))
out = capsys.readouterr()
return end.value.code, out.out, out.err
def test_count_and_gate(capsys, folder):
code, out, _ = run(capsys, "count", str(folder / "prompts"), "--out", "100")
assert code == 0 and "long.prompt" in out and "gpt-5.4-nano /call" in out
code, _, err = run(capsys, "count", str(folder / "prompts" / "long.prompt"), "--out", "200", "--max-usd", "0.0002")
assert code == 1 and "more than --max-usd" in err
code, out, _ = run(capsys, "count", str(folder / "prompts" / "short.txt"), "--json", "--model", "gpt-4.1-mini")
assert code == 0 and json.loads(out)["rows"][0]["tokens"] > 0
def test_unknown_model_and_missing_file(capsys, folder):
code, _, err = run(capsys, "count", str(folder / "prompts"), "--model", "gpt-9")
assert code == 2 and "unknown model 'gpt-9'" in err
code, _, err = run(capsys, "count", str(folder / "nothing.txt"))
assert code == 2 and "no such file" in err
def test_compare_and_budget(capsys, folder):
code, out, _ = run(capsys, "compare", str(folder / "prompts" / "long.prompt"), "--model", "gpt-4.1-mini",
"--model", "gpt-5.4-nano", "--out", "100")
assert code == 0 and "cheaper than gpt-4.1-mini" in out
code, out, _ = run(capsys, "budget", "1.0", str(folder / "prompts" / "short.txt"), "--out", "100", "--json")
rows = json.loads(out)
assert code == 0 and rows["gpt-5.4-nano"]["calls"] > rows["gpt-4.1-mini"]["calls"]
def test_usage_and_workflow(capsys, folder):
code, out, _ = run(capsys, "usage", str(folder / "runs"), "--per-day", "1000")
assert code == 0 and "2 request(s)" in out and "skipped 1" in out and "month:" in out
code, out, _ = run(capsys, "workflow", str(folder / "reference"), "--json")
steps = json.loads(out)["steps"]
assert code == 0 and len(steps) == 6
+70
View File
@@ -0,0 +1,70 @@
"""R1 · R2 · R3 · R4 · R5 · R6 · R11 · R12 · R13 without a screen and without any API call."""
import datetime as dt
import json
import pytest
from tcost import tokens, usage, workflow
from tcost.prices import UnknownModel, load
from tcost.scan import scan
from conftest import DATA
def test_cost_formula(table):
assert table.cost("gpt-4.1-mini", 1000) == 0.0004 # 1,000 × 0.40 / 1M
assert table.cost("gpt-4.1-mini", 1000, 100) == 0.00056 # R2: + 100 × 1.60 / 1M
assert table.cost("gpt-5.4-nano", 100, 20) == 0.000045
def test_unknown_model_is_not_free(table):
with pytest.raises(UnknownModel) as error:
table.cost("gpt-9", 1000)
assert "gpt-5.4-nano" in str(error.value)
def test_price_table_date(table, tmp_path):
assert table.checked and table.source # R3: date and source with the prices
assert table.warning(dt.date.fromisoformat(table.checked)) == ""
assert "days ago" in table.warning(dt.date.fromisoformat(table.checked) + dt.timedelta(days=45))
old = tmp_path / "old.json"
old.write_text(json.dumps({"models": {"m": {"input": 1, "output": 2}}}), encoding="utf-8")
assert "no 'checked' date" in load(old).warning()
def test_count_offline():
assert tokens.count("") == 0
assert 0 < tokens.count("VPN 접속이 자꾸 끊겨요") < tokens.count("VPN 접속이 자꾸 끊겨요. " * 10)
def test_workflow_steps():
base = {s.node: s for s in workflow.llm_steps(workflow.load(DATA / "baseline-cost.json"))}
lean = {s.node: s for s in workflow.llm_steps(workflow.load(DATA / "lab02-example1-lean.json"))}
assert set(base) == {"Classify", "Answer", "Review"}
assert base["Answer"].model == "gpt-4.1-mini"
assert base["Answer"].system_tokens > 1000 > 200 > lean["Answer"].system_tokens # the rules: R4
assert lean["Answer"].max_out == 200 and base["Answer"].max_out is None
def test_not_a_workflow(tmp_path):
path = tmp_path / "cases.json"
path.write_text('{"webhook": "x", "cases": []}', encoding="utf-8")
with pytest.raises(workflow.NotWorkflow):
workflow.load(path)
def test_usage_from_reports(folder):
found = usage.collect([folder / "runs"])
assert (found.reports, found.requests, found.skipped) == (1, 2, 1) # the 400 answer has no usage
assert found.total["calls"] == 3 and found.total["cost_usd"] == 0.0007442
assert found.by_user["이준호"]["cost_usd"] == 0.00056
assert set(found.by_model) == {"gpt-4.1-mini", "gpt-5.4-nano"}
assert found.by_hour == {"15h": found.total}
assert found.month(1000, 22) == round(0.0007442 / 2 * 1000 * 22, 6) # R6
def test_scan_sorts_folder(folder):
found = scan(folder)
assert [p.name for p in found.texts] == ["long.prompt", "short.txt"]
assert len(found.workflows) == 2 and len(found.reports) == 1
assert [p.name for p in found.skipped] == ["lab01.json"] # R11: a test file is not a workflow