66일차 새 방식(CLI 코딩 에이전트로 n8n)으로 교체, 67일차 교안·실습 추가
This commit is contained in:
1 parent
d7dc9dd68a
commit
bf55eaf772
120 files changed
+7398
-5988
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Sample company data API for day 67, attached to the day 65 n8n network
|
||||
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
|
||||
# docker compose up -d (run from this folder, after day 65 n8n is running)
|
||||
# from n8n : http://data-api:8800
|
||||
# from WSL : http://localhost:8800
|
||||
name: day67-data
|
||||
|
||||
services:
|
||||
data-api:
|
||||
image: python:3.12-slim
|
||||
command: python /app/server.py
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- PYTHONUNBUFFERED=1
|
||||
ports:
|
||||
- "8800:8800"
|
||||
volumes:
|
||||
- ./server.py:/app/server.py:ro
|
||||
- ./data:/data:ro
|
||||
networks:
|
||||
- n8n
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
n8n:
|
||||
external: true
|
||||
name: day65-n8n_default
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
|
||||
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
|
||||
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
|
||||
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
|
||||
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
|
||||
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
|
||||
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
|
||||
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
|
||||
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
|
||||
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
|
||||
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
|
||||
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다."},
|
||||
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다."},
|
||||
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다."},
|
||||
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Sample company data API for the day 67 labs (Python standard library only).
|
||||
|
||||
GET /health -> {"status": "ok"}
|
||||
GET /faq?q=<question> -> {"query": ..., "results": [FAQ entries]}
|
||||
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
|
||||
GET /tickets/<id>?requester=<name>
|
||||
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data"))
|
||||
PORT = int(os.getenv("PORT", "8800"))
|
||||
|
||||
|
||||
def load(name):
|
||||
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def search_faq(query, top_k=2):
|
||||
words = {word for word in query.lower().split() if len(word) > 1}
|
||||
scored = []
|
||||
for item in load("faq.json"):
|
||||
keywords = {keyword.lower() for keyword in item["keywords"]}
|
||||
score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords))
|
||||
if score:
|
||||
scored.append({"score": score, **item})
|
||||
scored.sort(key=lambda item: item["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, status, body):
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
# Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged.
|
||||
url = urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace"))
|
||||
if url.path == "/health":
|
||||
return self.send_json(200, {"status": "ok"})
|
||||
if url.path == "/faq":
|
||||
query = parse_qs(url.query).get("q", [""])[0]
|
||||
return self.send_json(200, {"query": query, "results": search_faq(query)})
|
||||
if url.path.startswith("/tickets/"):
|
||||
ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper()
|
||||
ticket = load("tickets.json").get(ticket_id)
|
||||
if ticket is None:
|
||||
return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id})
|
||||
requester = parse_qs(url.query).get("requester", [None])[0]
|
||||
if requester is not None and requester.strip() != ticket["requester"]:
|
||||
return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id})
|
||||
return self.send_json(200, {"ticket_id": ticket_id, **ticket})
|
||||
return self.send_json(404, {"error": "unknown path"})
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"{self.address_string()} {fmt % args}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True)
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
Reference in new issue
Block a user