74일차 정답 공개 (LAB-A · VIBE-A · answers · tver), Lab 03 예제 3 이해하기 보강

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-28 20:43:38 +09:00
1 parent 2ae33432a9
commit d663d045e3
86 files changed
+16144

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import json
import shutil
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
@pytest.fixture
def v1():
return json.loads((DATA / "helpdesk-v1.json").read_text(encoding="utf-8"))
@pytest.fixture
def v2():
return json.loads((DATA / "helpdesk-v2.json").read_text(encoding="utf-8"))
@pytest.fixture
def work(tmp_path):
"""A my-work folder with v1 as helpdesk.json, a note and a cases file (not workflows)."""
folder = tmp_path / "my-work"
folder.mkdir()
shutil.copy(DATA / "helpdesk-v1.json", folder / "helpdesk.json")
(folder / "PROMPTS.md").write_text("# prompts\n", encoding="utf-8")
(folder / "cases.json").write_text('{"webhook": "x", "cases": []}', encoding="utf-8")
return folder
@pytest.fixture
def store_dir(tmp_path):
return tmp_path / "store"
@@ -0,0 +1,286 @@
{
"name": "74 IT helpdesk v1 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v1",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "0709e90d-e8ea-509c-8c91-1721c012d260",
"position": [
0,
300
],
"id": "d087d97a-46d1-544f-b742-78984643db81"
},
{
"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": "0cafcebc-1ba4-546e-aa2e-22f24643ca5a"
},
{
"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": "f60c3d44-3793-50e4-89c1-96c15c0075b0"
},
{
"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": "7bf6486f-83e4-56c6-9052-ee94305aa025"
},
{
"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": "9f1e31b4-c9b1-5848-8bc7-4f9f5c5b905b"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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 합니다체.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "28d25947-3225-58fb-adec-112be9f61a60"
},
{
"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": [
880,
520
],
"id": "008c5ad6-673c-5e09-ae15-bbcf04777148"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1100,
300
],
"id": "5eabccbd-9c75-59f6-ac72-9070ef5f3a89"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1320,
300
],
"id": "95e15847-a230-567c-a1f5-827a415a52a4"
}
],
"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": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,310 @@
{
"name": "74 IT helpdesk v2 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v2",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "4dd9ca0d-6e35-58d9-afb9-36689beca2c7",
"position": [
0,
300
],
"id": "edf3da10-d647-5671-a906-292126b204df"
},
{
"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": "cf8a9038-ff3e-56a3-a17f-9c062bdd0391"
},
{
"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": "3e81f84e-0040-52a4-80a2-e093464dea75"
},
{
"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": "7e858b12-8c3b-5b72-aa02-09276f0d63e0"
},
{
"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": "9265daab-0313-5c12-aa77-7a596f23eae5"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "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 합니다체.\nWhen the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. Keep answers short.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "027a9c55-9b22-5122-85e5-9f8e347fe189"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-5.4-nano"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
880,
520
],
"id": "bfe53f6e-ecec-5876-8943-03d0b6dad9a5"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($json.output ?? '').trim();\nif (answer.length > 400) answer = answer.slice(0, 400);\nreturn [{ json: { output: answer } }];"
},
"position": [
1100,
300
],
"id": "0d0c0486-e241-521a-83ca-66b725fd05d0"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1320,
300
],
"id": "27c64881-2145-56c1-ab88-8f0a7515dbd2"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
300
],
"id": "5a7d65a6-3b4e-562d-bf7e-1be3756a8cde"
}
],
"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": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+34
View File
@@ -0,0 +1,34 @@
"""R8: versions list, change tree, detail of the chosen change, [s] saves."""
import asyncio
import json
from textual.widgets import ListView, Tree
from tver.app import VersionApp
from tver.store import Store
async def wait_for(pilot, check):
for _ in range(60):
await asyncio.sleep(0.05)
await pilot.pause()
if check():
return True
return False
async def test_versions_tree_and_save(work, store_dir, v2):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
app = VersionApp(str(work / "helpdesk.json"), store)
async with app.run_test() as pilot:
assert len(app.query_one(ListView).children) == 1
await pilot.press("s")
assert await wait_for(pilot, lambda: len(app.query_one(ListView).children) == 2)
assert await wait_for(pilot, lambda: "model 1 · prompt 1 · flow 5" in str(app.query_one(Tree).root.label))
tree = app.query_one(Tree)
tree.focus()
await pilot.press("down", "down")
await pilot.pause()
assert str(app.query_one("#detail").render()).strip() != ""
+60
View File
@@ -0,0 +1,60 @@
"""R1 · R2 · R3 · R4 · R11 · R12 with the day 74 v1 → v2 files."""
import copy
import json
from tver import diff
from tver.cli import main
from tver.workflow import clean, dumps
def test_v1_to_v2_is_seven_changes_like_flow_py(v1, v2):
changes = diff.compare(v1, v2)
titles = [c.title() for c in changes]
assert "+ node 'Review check' (code)" in titles
assert any(t.startswith("~ 'Answer' parameters.options.systemMessage: changed") for t in titles)
assert "~ 'Answer model' parameters.model.value: gpt-4.1-mini → gpt-5.4-nano" in titles
assert "~ 'Question webhook' parameters.path: rel-v1 → rel-v2" in titles
s = diff.summary(changes)
assert (s["total"], s["model"], s["prompt"], s["flow"]) == (7, 1, 1, 5)
assert s["links"] == {"+": 2, "-": 1}
def test_noise_is_not_a_change(v1):
moved = copy.deepcopy(v1)
for node in moved["nodes"]:
node["position"] = [node["position"][0] + 100, 0]
node["id"] = "other-" + node["name"]
moved["nodes"].reverse()
assert diff.compare(v1, moved) == []
assert dumps(v1) == dumps(moved)
assert all("position" not in n and "id" not in n for n in clean(v1)["nodes"])
def test_long_prompt_gets_line_diff(v1, v2):
change = next(c for c in diff.compare(v1, v2) if c.key.endswith("systemMessage"))
assert change.category == "prompt"
assert any(row.startswith("+") and "[FAQ-003]" in row for row in change.lines)
def test_renamed_node_is_paired(v1):
renamed = copy.deepcopy(v1)
for node in renamed["nodes"]:
if node["name"] == "Search FAQ":
node["name"] = "Find FAQ"
conn = json.dumps(renamed["connections"]).replace('"Search FAQ"', '"Find FAQ"')
renamed["connections"] = json.loads(conn)
changes = diff.compare(v1, renamed)
assert [c.op for c in changes] == ["renamed"]
assert (changes[0].before, changes[0].after) == ("Search FAQ", "Find FAQ")
def test_cli_diff_exit_codes_and_json(tmp_path, v1, v2, capsys):
a, b = tmp_path / "a.json", tmp_path / "b.json"
a.write_text(json.dumps(v1), encoding="utf-8")
b.write_text(json.dumps(v2), encoding="utf-8")
assert main(["diff", str(a), str(a)]) == 0
capsys.readouterr()
assert main(["diff", str(a), str(b), "--json"]) == 1
out = json.loads(capsys.readouterr().out)
assert out["summary"]["total"] == 7
assert main(["diff", str(a), str(tmp_path / "missing.json")]) == 2
+57
View File
@@ -0,0 +1,57 @@
"""R5 save · R6 log/diff of versions · R7 no secrets · R10 restore · R13 folder."""
import json
import pytest
from tver.cli import main
from tver.store import Store, StoreError
FAKE_KEY = "sk-" + "test-" + "a" * 24 # made here, never stored in a file of this repository
def test_save_twice_makes_one_commit(work, store_dir):
store = Store(store_dir)
assert store.save(work / "helpdesk.json")[0][1] == "saved"
assert store.save(work / "helpdesk.json")[0][1] == "no change"
assert len(store.log("helpdesk.json")) == 1
def test_versions_and_diff_between_them(work, store_dir, v2, capsys):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
name, status, message = store.save(work / "helpdesk.json")[0]
assert status == "saved" and "model 1 · prompt 1 · flow 5" in message
versions = store.log("helpdesk.json")
assert [v.message.split(": ")[0] for v in versions] == ["helpdesk.json", "helpdesk.json"]
assert main(["--repo", str(store_dir), "diff", versions[1].rev, versions[0].rev, "helpdesk.json", "--short"]) == 1
assert "7 change(s) · model 1 · prompt 1 · flow 5" in capsys.readouterr().out
def test_secret_is_not_saved(work, store_dir):
data = json.loads((work / "helpdesk.json").read_text(encoding="utf-8"))
data["nodes"][0]["parameters"]["note"] = FAKE_KEY
(work / "helpdesk.json").write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(StoreError) as error:
Store(store_dir).save(work / "helpdesk.json")
assert FAKE_KEY not in str(error.value)
assert not (store_dir / "helpdesk.json").exists()
def test_restore_writes_a_file_and_keeps_the_store(work, store_dir, v2, tmp_path):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
store.save(work / "helpdesk.json")
oldest = store.log("helpdesk.json")[-1].rev
out = store.restore(oldest, "helpdesk.json", tmp_path / "back.json")
restored = json.loads(out.read_text(encoding="utf-8"))
model = next(n for n in restored["nodes"] if n["name"] == "Answer model")
assert model["parameters"]["model"]["value"] == "gpt-4.1-mini"
assert len(store.log("helpdesk.json")) == 2
def test_folder_save_skips_files_that_are_not_workflows(work, store_dir):
rows = Store(store_dir).save(work)
assert ("cases.json", "skipped", "not a workflow") in rows
assert [r[0] for r in rows if r[1] == "saved"] == ["helpdesk.json"]