78일차 정답: LAB-A · VIBE-A-TCHECK · answers(정답 워크플로우 · 실행 기록) · 강사가 만든 tcheck

This commit is contained in:
mcchae committed 2026-11-02 20:54:06 +09:00
1 parent acf3a93041
commit c7df2e5344
85 files changed
+52637

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
@pytest.fixture(scope="session")
def n8n():
"""What n8n my-check answered on 2026-11-01 for the same three batches (Last3Mon/78/answers/18 · 21)."""
return json.loads((DATA / "n8n-my-check.json").read_text(encoding="utf-8"))
@pytest.fixture
def api():
"""A local stand-in for the day 78 data API: GET /schemas/<name> from tests/data/schemas, POST /quality-log kept in memory."""
log: list[dict] = []
class Handler(BaseHTTPRequestHandler):
def send(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = DATA / "schemas" / (self.path.removeprefix("/schemas/") + ".json")
if self.path.startswith("/schemas/") and path.exists():
return self.send(200, json.loads(path.read_text(encoding="utf-8")))
return self.send(404, {"error": "schema not found"})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
if self.path == "/quality-log":
log.append(body)
return self.send(200, {"stored": 1, "count": len(log)})
return self.send(404, {})
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
server.log = log
yield server
server.shutdown()
@pytest.fixture
def url(api):
return f"http://127.0.0.1:{api.server_address[1]}"
@@ -0,0 +1,10 @@
name,dept,email,phone,joined
오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01
윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05
강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06
서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07
임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01
,IT팀,noname@example.com,,2026-10-09
배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12
배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12
정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14
1 name dept email phone joined
2 오세훈 영업팀 sehoon.oh@example.com 010-2222-3333 2026-10-01
3 윤하늘 재무팀 haneul.yoon@example.com 2026-10-05
4 강민수 개발팀 minsu.kang@example 010-4444-5555 2026-10-06
5 서지아 마케팅팀 jia.seo@example.com 010-6666-7777 2026-10-07
6 임도윤 인사팀 doyun.lim@example.com 010-8888-9999 2026-13-01
7 IT팀 noname@example.com 2026-10-09
8 배수아 총무팀 sua.bae@example.com 02-123-4567 2026-10-12
9 배수아 총무팀 sua.bae@example.com 02-123-4567 2026-10-12
10 정다은 IT팀 daeun.jung@example.com 010-1111-2222 2026-10-14
@@ -0,0 +1,113 @@
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
@@ -0,0 +1,54 @@
{
"batch": "B-3",
"items": [
{
"id": "FAQ-301",
"title": "택배 수령",
"keywords": [
"택배"
],
"answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다."
},
{
"id": "FAQ-302",
"title": "주차 등록",
"keywords": [
"주차"
],
"answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다."
},
{
"id": "FAQ-303",
"title": "명함 신청",
"keywords": [
"명함"
],
"answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다."
},
{
"id": "FAQ-304",
"title": "사내 식당 메뉴",
"keywords": [
"식당",
"메뉴"
],
"answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다."
},
{
"id": "FAQ-305",
"title": "교육 신청",
"keywords": [
"교육"
],
"answer": "짧"
},
{
"id": "FAQ-306",
"title": "건강검진",
"keywords": [
"건강검진"
],
"answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다."
}
]
}
@@ -0,0 +1,218 @@
{
"employee": {
"schema": "employee",
"batch": "HR-1",
"total": 9,
"valid": 4,
"invalid": 5,
"rows": [
{
"row": 1,
"key": "sehoon.oh@example.com",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "haneul.yoon@example.com",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "minsu.kang@example",
"ok": false,
"errors": [
"bad_format:email"
]
},
{
"row": 4,
"key": "jia.seo@example.com",
"ok": false,
"errors": [
"not_allowed:dept"
]
},
{
"row": 5,
"key": "doyun.lim@example.com",
"ok": false,
"errors": [
"bad_format:joined"
]
},
{
"row": 6,
"key": "noname@example.com",
"ok": false,
"errors": [
"missing:name"
]
},
{
"row": 7,
"key": "sua.bae@example.com",
"ok": true,
"errors": []
},
{
"row": 8,
"key": "sua.bae@example.com",
"ok": false,
"errors": [
"duplicate_in_batch"
]
},
{
"row": 9,
"key": "daeun.jung@example.com",
"ok": true,
"errors": []
}
],
"codes": {
"bad_format:email": 1,
"not_allowed:dept": 1,
"bad_format:joined": 1,
"missing:name": 1,
"duplicate_in_batch": 1
}
},
"faq": {
"schema": "faq",
"batch": "B-3",
"total": 6,
"valid": 5,
"invalid": 1,
"rows": [
{
"row": 1,
"key": "FAQ-301",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "FAQ-302",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "FAQ-303",
"ok": true,
"errors": []
},
{
"row": 4,
"key": "FAQ-304",
"ok": true,
"errors": []
},
{
"row": 5,
"key": "FAQ-305",
"ok": false,
"errors": [
"too_short:answer"
]
},
{
"row": 6,
"key": "FAQ-306",
"ok": true,
"errors": []
}
],
"codes": {
"too_short:answer": 1
}
},
"ticket": {
"schema": "ticket",
"batch": "TK-1",
"total": 9,
"valid": 2,
"invalid": 7,
"rows": [
{
"row": 1,
"key": "T-2001",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "T-12",
"ok": false,
"errors": [
"bad_format:ticket_id"
]
},
{
"row": 3,
"key": "T-2003",
"ok": false,
"errors": [
"not_allowed:priority"
]
},
{
"row": 4,
"key": "T-2004",
"ok": false,
"errors": [
"bad_format:opened"
]
},
{
"row": 5,
"key": "T-2005",
"ok": false,
"errors": [
"too_short:title"
]
},
{
"row": 6,
"key": "T-2006",
"ok": false,
"errors": [
"bad_format:contact"
]
},
{
"row": 7,
"key": "T-2001",
"ok": false,
"errors": [
"duplicate_in_batch"
]
},
{
"row": 8,
"key": "T-2008",
"ok": false,
"errors": [
"missing:requester"
]
},
{
"row": 9,
"key": "T-2009",
"ok": true,
"errors": []
}
],
"codes": {
"bad_format:ticket_id": 1,
"not_allowed:priority": 1,
"bad_format:opened": 1,
"too_short:title": 1,
"bad_format:contact": 1,
"duplicate_in_batch": 1,
"missing:requester": 1
}
}
}
@@ -0,0 +1,11 @@
{
"name": "employee",
"key": "email",
"fields": {
"name": {"type": "string", "required": true, "max": 20},
"dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]},
"email": {"type": "email", "required": true},
"phone": {"type": "phone", "required": false},
"joined": {"type": "date", "required": true}
}
}
@@ -0,0 +1,10 @@
{
"name": "faq",
"key": "id",
"fields": {
"id": {"type": "pattern", "required": true, "pattern": "^FAQ-\\d{3}$"},
"title": {"type": "string", "required": true, "max": 40},
"keywords": {"type": "list", "required": true, "min_items": 1, "max_items": 8},
"answer": {"type": "string", "required": true, "min": 10, "max": 300}
}
}
@@ -0,0 +1,12 @@
{
"name": "ticket",
"key": "ticket_id",
"fields": {
"ticket_id": {"type": "pattern", "required": true, "pattern": "^T-\\d{4}$"},
"title": {"type": "string", "required": true, "min": 4, "max": 40},
"requester": {"type": "string", "required": true, "max": 20},
"priority": {"type": "enum", "required": true, "values": ["low", "normal", "high"]},
"opened": {"type": "date", "required": true},
"contact": {"type": "phone", "required": false}
}
}
@@ -0,0 +1,71 @@
{
"schema": "ticket",
"batch": "TK-1",
"items": [
{
"ticket_id": "T-2001",
"title": "모니터가 켜지지 않음",
"requester": "박서연",
"priority": "normal",
"opened": "2026-10-28",
"contact": "010-2345-6789"
},
{
"ticket_id": "T-12",
"title": "메일 첨부가 안 열림",
"requester": "이준호",
"priority": "low",
"opened": "2026-10-28"
},
{
"ticket_id": "T-2003",
"title": "VPN이 계속 끊김",
"requester": "최유진",
"priority": "urgent",
"opened": "2026-10-29"
},
{
"ticket_id": "T-2004",
"title": "키보드 교체 요청",
"requester": "한지훈",
"priority": "low",
"opened": "2026-02-30"
},
{
"ticket_id": "T-2005",
"title": "프린터",
"requester": "김민지",
"priority": "normal",
"opened": "2026-10-29"
},
{
"ticket_id": "T-2006",
"title": "공유 드라이브 권한",
"requester": "박서연",
"priority": "normal",
"opened": "2026-10-30",
"contact": "010-12-3456"
},
{
"ticket_id": "T-2001",
"title": "모니터가 또 꺼짐",
"requester": "박서연",
"priority": "high",
"opened": "2026-10-30"
},
{
"ticket_id": "T-2008",
"title": "화상회의 카메라 대여",
"requester": "",
"priority": "low",
"opened": "2026-10-30"
},
{
"ticket_id": "T-2009",
"title": "새 직원 계정 만들기",
"requester": "이준호",
"priority": "high",
"opened": "2026-10-30"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
import json
from types import SimpleNamespace
from tcheck import checks, load
from tcheck.app import CheckApp
from conftest import DATA
def make(tmp_path):
rows, _ = load.read_rows(str(DATA / "employees.csv"))
schema = json.loads((DATA / "schemas" / "employee.json").read_text())
args = SimpleNamespace(file=str(DATA / "employees.csv"), out=str(tmp_path / "clean.csv"))
return CheckApp(args, rows, schema, checks.check(rows, schema, "HR-1"))
async def test_tabs_filter_and_export(tmp_path):
app = make(tmp_path)
async with app.run_test() as pilot:
assert "9 rows · valid 4" in str(app.query_one("#top").render())
assert app.query_one("#problem-table").row_count == 5
assert app.query_one("#code-table").row_count == 5
assert app.query_one("#clean-table").row_count == 4
await pilot.press("slash")
await pilot.pause()
for key in "email":
await pilot.press(key)
await pilot.press("enter")
assert app.query_one("#problem-table").row_count == 1
await pilot.press("escape")
assert app.query_one("#problem-table").row_count == 5
await pilot.press("e")
assert app.exported.endswith("clean.csv") and (tmp_path / "clean.csv").read_text().count("\n") == 5
+78
View File
@@ -0,0 +1,78 @@
import json
import pytest
from tcheck import checks, load
from conftest import DATA
FILES = {"employee": "employees.csv", "ticket": "tickets-new.json", "faq": "faq-batch-3.json"}
def schema(name):
return json.loads((DATA / "schemas" / f"{name}.json").read_text(encoding="utf-8"))
def run(name, batch="X", content=()):
rows, _ = load.read_rows(str(DATA / FILES[name]))
return checks.check(rows, schema(name), batch, content)
@pytest.mark.parametrize("name", ["employee", "ticket", "faq"])
def test_same_rows_and_codes_as_n8n(name, n8n):
"""R4: the same file gives the same rows · codes · valid as n8n my-check."""
r = run(name, n8n[name]["batch"])
for key in ("schema", "batch", "total", "valid", "invalid", "rows", "codes"):
assert r[key] == n8n[name][key], key
def test_employee_reasons():
r = run("employee")
assert [row["errors"] for row in r["rows"]] == [[], [], ["bad_format:email"], ["not_allowed:dept"], ["bad_format:joined"],
["missing:name"], [], ["duplicate_in_batch"], []]
def test_ticket_optional_field_and_impossible_date():
r = run("ticket")
assert r["rows"][3]["errors"] == ["bad_format:opened"] # 2026-02-30
assert r["rows"][5]["errors"] == ["bad_format:contact"] # present but wrong
assert r["rows"][8]["ok"] # contact missing is fine
def test_string_list_and_blank_edges():
s = {"name": "t", "key": "id", "fields": {"id": {"type": "string", "required": True, "min": 2, "max": 3},
"tags": {"type": "list", "required": False, "min_items": 1, "max_items": 2}}}
r = checks.check([{"id": "ab"}, {"id": "abcd"}, {"id": " "}, {"id": "abc", "tags": "x"}, {"id": "ab2", "tags": [1, 2, 3]}], s)
assert [row["errors"] for row in r["rows"]] == [[], ["too_long:id"], ["missing:id"], ["bad_format:tags"], ["too_many:tags"]]
def test_content_rules_on_a_field():
"""R8: Lab 01 rules — phone without dashes, exact intra host."""
s = {"name": "faq", "key": "id", "fields": {}}
rows = [{"id": "1", "answer": "당직 01012345678"}, {"id": "2", "answer": "02-123-4567"},
{"id": "3", "answer": "https://intra.example.com/help"}, {"id": "4", "answer": "http://intra.example.com.evil.io/x"},
{"id": "5", "answer": "a@b.com"}]
r = checks.check(rows, s, content=("answer",))
assert [row["errors"] for row in r["rows"]] == [["pii"], [], [], ["external_link"], ["pii"]]
def test_gate_equal_passes():
r = run("employee") # 5 / 9 = 0.5556
assert checks.gate(r, 0.3) == {"accepted": False, "invalid_ratio": 0.5556, "max_invalid_ratio": 0.3}
assert checks.gate({"invalid": 1, "total": 10}, 0.1)["accepted"] is True
assert checks.gate({"invalid": 0, "total": 0}, 0.3)["accepted"] is True
def test_fix_list_and_message():
r = run("ticket", "TK-1")
f = checks.fix_list(r, schema("ticket"))
assert f["message"] == "TK-1: 9줄 중 2줄 통과, 7줄 고칠 것"
assert [i["row"] for i in f["fix_list"]] == [2, 3, 4, 5, 6, 7, 8]
assert f["fix_list"][1]["fix"] == ["priority은(는) low · normal · high 중 하나로"]
assert "ticket_id" in f["fix_list"][5]["fix"][0]
def test_quality_entry_and_round():
e = checks.quality_entry(run("employee", "HR-1"))
assert e == {"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False,
"score": 0.4444, "codes": e["codes"]}
assert checks.js_round(0.00005) == 0.0001 and checks.js_round(2 / 3) == 0.6667
+49
View File
@@ -0,0 +1,49 @@
import csv
import json
from tcheck import cli
from conftest import DATA
EMP = str(DATA / "employees.csv")
EMP_SCHEMA = str(DATA / "schemas" / "employee.json")
def test_check_plain(capsys):
assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA]) == 0
out = capsys.readouterr().out
assert "9 rows · valid 4 · invalid 5 · score 0.4444" in out and "bad_format:email" in out
def test_check_json_like_my_check(capsys, url, n8n):
assert cli.main(["check", str(DATA / "tickets-new.json"), "--url", url, "--json"]) == 0 # schema from the file
out = json.loads(capsys.readouterr().out)
assert {k: out[k] for k in ("schema", "batch", "valid", "rows", "codes")} == {k: n8n["ticket"][k] for k in ("schema", "batch", "valid", "rows", "codes")}
def test_out_and_rejects_files(tmp_path):
clean, rejects = tmp_path / "clean.csv", tmp_path / "rejects.csv"
assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA, "--out", str(clean), "--rejects", str(rejects)]) == 0
assert len(list(csv.DictReader(clean.open()))) == 4
bad = list(csv.DictReader(rejects.open()))
assert len(bad) == 5 and bad[0]["errors"] == "bad_format:email"
def test_gate_exit_codes(capsys):
assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA]) == 4
assert "rejected" in capsys.readouterr().out
assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA, "--max-invalid", "0.6"]) == 0
def test_fix_and_log(capsys, url, api):
assert cli.main(["check", EMP, "--schema", "employee", "--url", url, "--batch", "HR-1", "--fix", "--log"]) == 0
out = capsys.readouterr().out
assert "HR-1: 9줄 중 4줄 통과, 5줄 고칠 것" in out and "email의 형식을 확인해 주세요" in out
assert api.log == [{"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False,
"score": 0.4444, "codes": api.log[0]["codes"]}]
def test_exit_codes_read_and_usage(tmp_path, url, capsys):
assert cli.main(["check", str(tmp_path / "none.csv"), "--schema-file", EMP_SCHEMA]) == 1
assert cli.main(["check", EMP, "--schema", "nothing", "--url", url]) == 1
assert cli.main(["check", EMP]) == 2 # which schema?
assert cli.main(["nonsense"]) == 2
+44
View File
@@ -0,0 +1,44 @@
import json
import pytest
from tcheck import load
from conftest import DATA
def test_csv_first_line_is_columns():
rows, meta = load.read_rows(str(DATA / "employees.csv"))
assert len(rows) == 9 and rows[2]["email"] == "minsu.kang@example" and rows[1]["phone"] == ""
assert meta["batch"] == "employees"
def test_request_file_with_csv_or_items(tmp_path):
rows, meta = load.read_rows(str(DATA / "tickets-new.json"))
assert len(rows) == 9 and meta == {"schema": "ticket", "batch": "TK-1"}
f = tmp_path / "hr.json"
f.write_text(json.dumps({"schema": "employee", "batch": "HR-1", "csv": (DATA / "employees.csv").read_text()}))
assert len(load.read_rows(str(f))[0]) == 9
def test_plain_list(tmp_path):
f = tmp_path / "rows.json"
f.write_text("[{\"a\": 1}]")
assert load.read_rows(str(f)) == ([{"a": 1}], {"batch": "rows"})
def test_missing_or_bad_files(tmp_path):
with pytest.raises(load.LoadError):
load.read_rows(str(tmp_path / "nope.csv"))
bad = tmp_path / "bad.json"
bad.write_text("{\"x\": 1}")
with pytest.raises(load.LoadError):
load.read_rows(str(bad))
def test_schema_from_api_or_file(url):
assert load.read_schema("employee", url)["key"] == "email"
assert load.read_schema("", None, str(DATA / "schemas" / "ticket.json"))["name"] == "ticket"
with pytest.raises(load.LoadError):
load.read_schema("nothing", url)
with pytest.raises(load.LoadError):
load.read_schema("employee", None)