75일차 자료 추가 (답 제외): 실습 15개 · 실행 결과, gpt-5.4-nano · 단가, 교안 개요 30장 · 4단계 정리, 바이브 코딩 tcost 요청서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-28 22:38:01 +09:00
1 parent d663d045e3
commit 01dc892303
70 files changed
+10890

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Sample company data API for day 75 (day 74 API + prices + usage + budget), 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
+1
View File
@@ -0,0 +1 @@
{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500}
@@ -0,0 +1,17 @@
# 업무용 장비 지급·반납 규정
## 1. 지급과 교체
- 업무용 노트북은 입사일에 지급한다.
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
## 2. 분실과 도난
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
## 3. 반납
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
## 4. 개인 장비 사용
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
@@ -0,0 +1,14 @@
# 메일·문서 보안 규정
## 1. 메일함
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
## 2. 첨부 파일
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
## 3. 문서 등급과 보관
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
@@ -0,0 +1,12 @@
# 회의실 이용 규정
## 1. 예약
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
## 2. 장비
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
- 프로젝터는 B회의실과 대회의실에 있다.
## 3. 사용 후
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
@@ -0,0 +1,18 @@
# 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다.
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
## 3. 로그인 실패와 잠금
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
+13
View File
@@ -0,0 +1,13 @@
# 개인정보 처리 지침
## 1. 수집
- 업무에 꼭 필요한 개인정보만 수집한다.
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
## 2. 이용과 보관
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
## 3. 유출 대응
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
@@ -0,0 +1,15 @@
# 원격근무·VPN 이용 규정
## 1. 접속 원칙
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
## 2. VPN 인증서
- VPN 인증서의 유효 기간은 1년이다.
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
- VPN 접속 기록은 1년 동안 보관된다.
@@ -0,0 +1,11 @@
# 보안 사고 대응 절차
## 1. 피싱 메일
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
## 2. 악성코드 감염 의심
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
@@ -0,0 +1,14 @@
# 소프트웨어 사용 규정
## 1. 설치와 승인
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
## 2. 금지 사항
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
## 3. 생성형 AI 서비스
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
+26
View File
@@ -0,0 +1,26 @@
[
{
"name": "이준호",
"dept": "영업팀"
},
{
"name": "박서연",
"dept": "재무팀"
},
{
"name": "최유진",
"dept": "인사팀"
},
{
"name": "한지훈",
"dept": "개발팀"
},
{
"name": "김민지",
"dept": "IT팀"
},
{
"name": "정우성",
"dept": "IT팀"
}
]
+14
View File
@@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+9
View File
@@ -0,0 +1,9 @@
{
"currency": "USD",
"unit": "per 1M tokens",
"note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.",
"models": {
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
"gpt-5.4-nano": {"input": 0.20, "output": 1.25}
}
}
@@ -0,0 +1 @@
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 합니다체.
@@ -0,0 +1,2 @@
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 합니다체.
When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003].
@@ -0,0 +1 @@
You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead.
+12
View File
@@ -0,0 +1,12 @@
{
"helpdesk": {
"active": "1.0.0",
"releases": {
"1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"},
"1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"},
"1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"},
"2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"},
"2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}},
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}},
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}},
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}}
}
+406
View File
@@ -0,0 +1,406 @@
"""Sample company data API for the day 75 labs (day 74 API + prices + usage records + budget;
Python standard library only).
GET /health -> {"status": "ok"} (never affected by the fault switch)
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
GET /employees -> {"employees": [{"name", "dept"}]}
GET /employees/<name> -> {"name", "dept"}, or 404 {"error": "unknown employee"}
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
GET /docs/<name> -> {"name", "title", "text"}, or 404 {"error": "doc not found"}
Day 72 additions (state lives in memory; restarting the container resets it):
GET /admin/fault -> {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8}
POST /admin/fault body {"faq": "ok"|"slow"|"error", "tickets": ..., "prompts": ..., "usage": ..., "slow_seconds": 8}
"slow" waits slow_seconds before answering, "error" answers HTTP 500.
GET /cache/<key> -> {"key", "value", "hits"} or 404 {"error": "not cached"}
POST /cache/<key> body {"value": <anything>, "ttl": 600} (PUT works too)
POST /cache-clear -> {"cleared": <count>}
GET /cache-stats -> {"entries", "hits", "misses"}
Day 73 addition (in memory, newest 1000 kept):
POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"}
GET /audit -> {"events": [{"t", ...the body}]}
POST /audit-clear -> {"cleared": <count>}
Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory):
GET /prompts/<name> -> {"name", "versions": ["p1", ...]}
GET /prompts/<name>/<ver> -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts")
GET /releases/<name> -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}}
POST /releases/<name> body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null}
When require_gate is on, a release can become active or canary only if its latest gate
record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}.
POST /releases/<name>/policy body {"require_gate": true|false}
POST /releases/<name>/reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay)
POST /gates/<name> body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...}
-> {"stored": true, "count"}
GET /gates/<name> -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept)
POST /gates/<name>/clear -> {"cleared": <count>}
Day 75 additions (usage and budget in memory):
GET /prices -> {"currency", "unit", "models": {"gpt-4.1-mini": {"input", "output"}, ...}} (USD per 1M tokens)
POST /usage body {"items": [{"request_id", "user", "step", "model", "prompt_tokens",
"completion_tokens", "cost_usd", "route"}]} (one item per LLM call, or one item
without "items") -> {"stored": n, "total"} (fault switch "usage": error -> 500,
slow -> waits slow_seconds, then 503 and nothing is stored)
GET /usage/summary -> {"requests" (distinct request_id), "calls", "prompt_tokens", "completion_tokens",
"cost_usd", "by_model", "by_user", "by_route"} (POST works too)
GET /usage/user/<name>?window=60 -> {"user", "window", "count"} distinct requests of that user in the last N seconds
POST /usage-clear -> {"cleared": n}
GET /budget -> {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"}
POST /budget body {"daily_usd": 0.5} and/or {"user_limit_per_min": 3} and/or {"max_question_chars": 500}
POST /budget/reset -> back to data/budget.json
"""
import json
import os
import threading
import time
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"))
LOCK = threading.Lock()
FAULT = {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "slow_seconds": 8}
CACHE: dict[str, dict] = {}
STATS = {"hits": 0, "misses": 0}
AUDIT: list[dict] = []
RELEASES: dict = {}
GATES: dict[str, list[dict]] = {}
USAGE: list[dict] = []
BUDGET: dict = {}
BUDGET_KEYS = {"daily_usd": float, "user_limit_per_min": int, "max_question_chars": int}
def budget():
if not BUDGET:
BUDGET.update(load("budget.json"))
used = round(sum(float(item.get("cost_usd") or 0) for item in USAGE), 8)
return {**BUDGET, "used_usd": used, "remaining_usd": round(BUDGET["daily_usd"] - used, 8), "over": used >= BUDGET["daily_usd"]}
def summary():
def add(table, key, item):
row = table.setdefault(str(key), {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0})
row["calls"] += 1
row["prompt_tokens"] += int(item.get("prompt_tokens") or 0)
row["completion_tokens"] += int(item.get("completion_tokens") or 0)
row["cost_usd"] = round(row["cost_usd"] + float(item.get("cost_usd") or 0), 8)
total = {"requests": 0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}
by_model, by_user, by_route = {}, {}, {}
seen = set()
for index, item in enumerate(USAGE):
key = item.get("request_id") or f"item-{index}"
if key not in seen:
seen.add(key)
total["requests"] += 1
total["calls"] += 1
total["prompt_tokens"] += int(item.get("prompt_tokens") or 0)
total["completion_tokens"] += int(item.get("completion_tokens") or 0)
total["cost_usd"] = round(total["cost_usd"] + float(item.get("cost_usd") or 0), 8)
add(by_model, item.get("model", "?"), item)
add(by_user, item.get("user", "?"), item)
add(by_route, item.get("route", "?"), item)
return {**total, "by_model": by_model, "by_user": by_user, "by_route": by_route}
def load(name):
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
def releases():
if not RELEASES:
for name, item in load("releases.json").items():
RELEASES[name] = {"active": item["active"], "canary": None, "require_gate": False, "releases": item["releases"]}
return RELEASES
def last_gate(name, release):
for record in reversed(GATES.get(name, [])):
if record.get("release") == release:
return record
return None
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 url(self):
# Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged.
return urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace"))
def body(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
def faulty(self, target):
"""Apply the fault switch to one endpoint group. Returns True when the response was already sent."""
mode = FAULT.get(target, "ok")
if mode == "slow":
time.sleep(float(FAULT.get("slow_seconds", 8)))
elif mode == "error":
self.send_json(500, {"error": f"{target} service is failing (fault switch)"})
return True
return False
def do_GET(self):
url = self.url()
if url.path == "/health":
return self.send_json(200, {"status": "ok"})
if url.path == "/admin/fault":
return self.send_json(200, FAULT)
if url.path == "/audit":
with LOCK:
return self.send_json(200, {"events": list(AUDIT)})
if url.path == "/prices":
return self.send_json(200, load("prices.json"))
if url.path == "/usage/summary":
with LOCK:
return self.send_json(200, summary())
if url.path.startswith("/usage/user/"):
name = unquote(url.path.removeprefix("/usage/user/"))
window = float(parse_qs(url.query).get("window", ["60"])[0])
now = time.time()
with LOCK:
count = len({item.get("request_id") or f"item-{i}" for i, item in enumerate(USAGE)
if item.get("user") == name and now - item["at"] <= window})
return self.send_json(200, {"user": name, "window": window, "count": count})
if url.path == "/budget":
with LOCK:
return self.send_json(200, budget())
if url.path == "/cache-stats":
with LOCK:
return self.send_json(200, {"entries": len(CACHE), **STATS})
if url.path.startswith("/cache/"):
key = unquote(url.path.removeprefix("/cache/"))
with LOCK:
entry = CACHE.get(key)
if entry and entry["expires"] > time.time():
entry["hits"] += 1
STATS["hits"] += 1
return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]})
CACHE.pop(key, None)
STATS["misses"] += 1
return self.send_json(404, {"error": "not cached", "key": key})
if url.path == "/faq":
if self.faulty("faq"):
return None
query = parse_qs(url.query).get("q", [""])[0]
return self.send_json(200, {"query": query, "results": search_faq(query)})
if url.path == "/employees":
return self.send_json(200, {"employees": load("employees.json")})
if url.path.startswith("/employees/"):
name = unquote(url.path.removeprefix("/employees/")).strip()
for person in load("employees.json"):
if person["name"] == name:
return self.send_json(200, person)
return self.send_json(404, {"error": "unknown employee", "name": name})
if url.path == "/docs":
docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()}
for f in sorted((DATA_DIR / "docs").glob("*.md"))]
return self.send_json(200, {"docs": docs})
if url.path.startswith("/docs/"):
name = unquote(url.path.removeprefix("/docs/")).strip()
path = DATA_DIR / "docs" / f"{name}.md"
if "/" in name or not path.exists():
return self.send_json(404, {"error": "doc not found", "name": name})
text = path.read_text(encoding="utf-8")
return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text})
if url.path.startswith("/tickets/"):
if self.faulty("tickets"):
return None
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})
if url.path.startswith("/prompts/"):
if self.faulty("prompts"):
return None
parts = [unquote(p) for p in url.path.removeprefix("/prompts/").split("/") if p]
folder = DATA_DIR / "prompts"
if len(parts) == 1:
versions = sorted(f.stem.removeprefix(parts[0] + "-") for f in folder.glob(f"{parts[0]}-*.md"))
return self.send_json(200 if versions else 404, {"name": parts[0], "versions": versions})
if len(parts) == 2 and "/" not in parts[1] and (folder / f"{parts[0]}-{parts[1]}.md").exists():
text = (folder / f"{parts[0]}-{parts[1]}.md").read_text(encoding="utf-8").strip()
return self.send_json(200, {"name": parts[0], "version": parts[1], "text": text})
return self.send_json(404, {"error": "prompt not found", "path": url.path})
if url.path.startswith("/releases/"):
name = unquote(url.path.removeprefix("/releases/")).strip("/")
with LOCK:
item = releases().get(name)
if item is None:
return self.send_json(404, {"error": "unknown release name", "name": name})
return self.send_json(200, {"name": name, **item})
if url.path.startswith("/gates/"):
name = unquote(url.path.removeprefix("/gates/")).strip("/")
with LOCK:
return self.send_json(200, {"name": name, "gates": list(GATES.get(name, []))})
return self.send_json(404, {"error": "unknown path"})
def do_POST(self):
url = self.url()
body = self.body()
if body is None or not isinstance(body, dict):
return self.send_json(400, {"error": "body must be a JSON object"})
if url.path == "/admin/fault":
for target in ("faq", "tickets", "prompts", "usage"):
if target in body:
if body[target] not in ("ok", "slow", "error"):
return self.send_json(400, {"error": f"{target} must be ok, slow or error"})
FAULT[target] = body[target]
if "slow_seconds" in body:
FAULT["slow_seconds"] = max(0.0, min(float(body["slow_seconds"]), 60.0))
return self.send_json(200, FAULT)
if url.path == "/audit":
with LOCK:
AUDIT.append({"t": round(time.time(), 3), **body})
del AUDIT[:-1000]
count = len(AUDIT)
return self.send_json(200, {"stored": True, "count": count})
if url.path == "/usage":
if FAULT.get("usage") == "slow":
# a hung record service: answers late and keeps nothing (a caller that gave up must not be counted later)
time.sleep(float(FAULT.get("slow_seconds", 8)))
return self.send_json(503, {"error": "usage service timed out (fault switch)", "stored": 0})
if self.faulty("usage"):
return None
items = body.get("items") if isinstance(body.get("items"), list) else [body]
with LOCK:
for item in items:
USAGE.append({**item, "at": time.time()})
return self.send_json(200, {"stored": len(items), "total": len(USAGE)})
if url.path == "/usage/summary":
with LOCK:
return self.send_json(200, summary())
if url.path == "/usage-clear":
with LOCK:
count = len(USAGE)
USAGE.clear()
return self.send_json(200, {"cleared": count})
if url.path in ("/budget", "/budget/reset"):
with LOCK:
if url.path == "/budget/reset":
BUDGET.clear()
budget()
for key, kind in BUDGET_KEYS.items():
if key in body:
BUDGET[key] = kind(body[key])
return self.send_json(200, budget())
if url.path == "/audit-clear":
with LOCK:
count = len(AUDIT)
AUDIT.clear()
return self.send_json(200, {"cleared": count})
if url.path == "/cache-clear":
with LOCK:
count = len(CACHE)
CACHE.clear()
STATS.update(hits=0, misses=0)
return self.send_json(200, {"cleared": count})
if url.path.startswith("/cache/"):
key = unquote(url.path.removeprefix("/cache/"))
if not key or "value" not in body:
return self.send_json(400, {"error": "key in the path and value in the body are required"})
ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0))
with LOCK:
CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0}
return self.send_json(200, {"key": key, "stored": True, "ttl": ttl})
if url.path.startswith("/gates/"):
name = unquote(url.path.removeprefix("/gates/")).strip("/")
clear = name.endswith("/clear")
name = name.removesuffix("/clear")
with LOCK:
if clear:
count = len(GATES.pop(name, []))
return self.send_json(200, {"cleared": count})
if not body.get("release") or body.get("verdict") not in ("promote", "rollback"):
return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"})
records = GATES.setdefault(name, [])
records.append({"t": round(time.time(), 3), **body})
del records[:-200]
return self.send_json(200, {"stored": True, "count": len(records)})
if url.path.startswith("/releases/"):
name = unquote(url.path.removeprefix("/releases/")).strip("/")
action = ""
for suffix in ("/reset", "/policy"):
if name.endswith(suffix):
action, name = suffix[1:], name.removesuffix(suffix)
with LOCK:
if action == "reset":
RELEASES.pop(name, None)
item = releases().get(name)
if item is None:
return self.send_json(404, {"error": "unknown release name", "name": name})
if action == "reset":
return self.send_json(200, {"name": name, **item})
if action == "policy":
if not isinstance(body.get("require_gate"), bool):
return self.send_json(400, {"error": "require_gate must be true or false"})
item["require_gate"] = body["require_gate"]
return self.send_json(200, {"name": name, **item})
wanted = []
if "active" in body:
if body["active"] not in item["releases"]:
return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])})
wanted.append(body["active"])
canary = body.get("canary")
if "canary" in body and canary is not None:
if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \
or not 0 <= int(canary.get("percent", -1)) <= 100:
return self.send_json(400, {"error": "canary needs a known release and percent 0..100"})
canary = {"release": canary["release"], "percent": int(canary["percent"])}
wanted.append(canary["release"])
if item["require_gate"]:
for release in wanted:
gate = last_gate(name, release)
if not gate or gate.get("verdict") != "promote":
return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate})
if "active" in body:
item["active"] = body["active"]
if "canary" in body:
item["canary"] = canary
return self.send_json(200, {"name": name, **item})
return self.send_json(404, {"error": "unknown path"})
do_PUT = do_POST
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()