75일차 답 추가: 실습 정답 문서 · VIBE-A · 정답 워크플로우와 25단계 실행 기록 · tcost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01dc892303
commit
ebff58c72e
79 files changed
+18753
No files matched your search
@@ -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"}}
|
||||
]}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user