79일차 운영 데이터 기반 Ops Assistant: 실습 15개 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브), 정답본 LAB-A · answers · VIBE-A · tops 포함

This commit is contained in:
mcchae committed 2026-11-03 20:54:35 +09:00
1 parent 9ce9aceba6
commit 6f0ecd59a9
156 files changed
+31640

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
"""A fake n8n + data API on a local port, answering with responses recorded from the day 79 answers (answers/06 · 08 · 14 · 16 · 18 · 25)."""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
def load(name):
return json.loads((DATA / name).read_text(encoding="utf-8"))
class Fake:
def __init__(self):
self.calls: list[tuple[str, dict]] = []
self.cost_down = False
self.brief_down = False
def handler(fake: Fake):
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def send(self, code, data):
body = json.dumps(data, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
fake.calls.append((self.path, body))
if self.path == "/webhook/my-ops-facts":
return self.send(200, load("facts-cost-down.json" if fake.cost_down else "facts.json"))
if self.path == "/webhook/my-ops-ask":
asks = load("asks.json")
return self.send(200, asks.get(body.get("question"), asks["점심 메뉴 추천해 줘"]))
if self.path == "/webhook/my-ops-brief":
return self.send(500, {"message": "Error in workflow"}) if fake.brief_down else self.send(200, load("brief.json"))
if self.path == "/webhook/my-ops-eval":
cases = body.get("cases") or []
if not cases:
return self.send(400, {"error": "cases must be a non-empty list"})
e = load("eval.json")
return self.send(200, e["pass"] if len(cases) == 8 else e["fail"])
if self.path == "/audit/list":
return self.send(200, load("audit.json"))
self.send(404, {"message": "not registered"})
return H
@pytest.fixture
def fake():
f = Fake()
server = ThreadingHTTPServer(("127.0.0.1", 0), handler(f))
threading.Thread(target=server.serve_forever, daemon=True).start()
f.base = f"http://127.0.0.1:{server.server_address[1]}"
yield f
server.shutdown()
@pytest.fixture
def down():
"""An address nothing listens on (n8n turned off)."""
return "http://127.0.0.1:9"
+58
View File
@@ -0,0 +1,58 @@
{
"DLQ 1번 다시 돌려 줘": {
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"router": "keyword",
"refused": true,
"reason": "read_only",
"answer": "운영 도우미는 읽기만 합니다. 재처리 · 삭제 · 배포 같은 작업은 런북에 따라 담당자가 직접 해 주세요."
},
"점심 메뉴 추천해 줘": {
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"router": "llm",
"refused": true,
"reason": "out_of_scope",
"answer": "운영 데이터(실행 · 알림 · DLQ · 품질 · 비용 · 릴리스)로 답할 수 있는 질문만 받습니다."
},
"지난주 오류율이 얼마야?": {
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"router": "keyword",
"refused": false,
"reason": null,
"answer": "지난주 오류율은 5.88%였습니다. 총 357회 실행 중 21회 오류가 발생했습니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"이번 주에 AI 쓰는 데 얼마나 나갔어?": {
"question": "이번 주에 AI 쓰는 데 얼마나 나갔어?",
"area": "cost",
"router": "llm",
"refused": false,
"reason": null,
"answer": "이번 주 AI 사용 비용은 총 0.073604달러입니다. 일일 평균 비용은 약 0.08달러이며, 사용 비율은 92.01%입니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"어젯밤에 접수 못 하고 남은 건 몇 건이야?": {
"question": "어젯밤에 접수 못 하고 남은 건 몇 건이야?",
"area": "dlq",
"router": "llm",
"refused": false,
"reason": null,
"answer": "어젯밤에 접수 못 하고 남은 건은 총 3건입니다. 이 중 2건은 대기 중이고 1건은 주차 상태입니다.",
"numbers_ok": true,
"unknown_numbers": []
},
"새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?": {
"question": "새 판을 일부 사용자에게 먼저 내보낸 거 어떻게 되고 있어?",
"area": "release",
"router": "llm",
"refused": false,
"reason": null,
"answer": "새 판 1.2.0은 일부 사용자 20%에게 먼저 내보내졌으며, 이 버전의 재사용률은 62.5%로 나타났습니다. 현재 전체 시스템 상태는 빨간색(문제 있음)입니다.",
"numbers_ok": true,
"unknown_numbers": []
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"events": [
{
"t": 1793526843.503,
"kind": "ops-ask",
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"router": "keyword",
"refused": true,
"reason": "read_only",
"numbers_ok": null
},
{
"t": 1793526845.149,
"kind": "ops-ask",
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"router": "llm",
"refused": true,
"reason": "out_of_scope",
"numbers_ok": null
},
{
"t": 1793526846.297,
"kind": "ops-ask",
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"router": "keyword",
"refused": false,
"reason": null,
"numbers_ok": true
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"overall": "red",
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality",
"cost"
],
"actions": [
{
"area": "runs",
"status": "red",
"action": "오류 묶음 1위 확인 · 고치기 (77일차)"
},
{
"area": "dlq",
"status": "red",
"action": "parked 건 원인 확인 뒤 런북대로 재처리 (76일차)"
},
{
"area": "release",
"status": "red",
"action": "카나리 끄기 · 되돌리기 검토 (74일차)"
}
],
"markdown": "# 운영 브리핑 (2026-10-26 ~ 2026-10-30 실행 기록 + 지금 상태)\n\n전체: 🔴 red\n\n| 영역 | 상태 | 숫자 |\n| --- | --- | --- |\n| 실행 | 🔴 | 오류율 5.88% · p95 2598ms · 답 못 함 4.76% |\n| 알림 | 🟡 | 6건 (높음 3) |\n| DLQ | 🔴 | 3건 (parked 2) |\n| 데이터 품질 | 🟡 | 평균 0.6583 · 마지막 1 · 격리 1 |\n| 비용 | 🟡 | $0.073604 / $0.08 (92.01%) |\n| 릴리스 | 🔴 | 1.1.0 · 카나리 1.2.0 20% (다시 묻기 62.5%) |\n\n## 먼저 볼 것\n\n1. 🔴 실행: 오류 묶음 1위 확인 · 고치기 (77일차)\n2. 🔴 DLQ: parked 건 원인 확인 뒤 런북대로 재처리 (76일차)\n3. 🔴 릴리스: 카나리 끄기 · 되돌리기 검토 (74일차)"
}
+37
View File
@@ -0,0 +1,37 @@
{
"pass": {
"total": 8,
"passed": 8,
"score": 1,
"area_ok": 8,
"refused_ok": 8,
"number_ok": 8,
"grounded": 8,
"failed": [],
"min_score": 0.8,
"gate": "pass"
},
"fail": {
"total": 1,
"passed": 0,
"score": 0,
"area_ok": 0,
"refused_ok": 1,
"number_ok": 1,
"grounded": 1,
"failed": [
{
"question": "알림이 몇 건 와 있어?",
"area": "alerts",
"ok": false,
"area_ok": false,
"refused_ok": true,
"number_ok": true,
"grounded": true,
"answer": "알림은 총 6건이 와 있으며, 이 중 3건은 높은 심각도입니다. 알림 사유로는 타임아웃 1건, 속도 제한 1건, 배치 거부 1건, 저품질 2건, API 오류 1건이 있습니다."
}
],
"min_score": 0.9,
"gate": "fail"
}
}
@@ -0,0 +1,73 @@
{
"period": {
"from": "2026-10-26",
"to": "2026-10-30"
},
"runs": {
"total": 357,
"errors": 21,
"error_rate": 0.0588,
"p95_ms": 2598,
"unanswered_rate": 0.0476,
"top_failure": {
"node": "Search FAQ",
"message": "timeout of 1000ms exceeded",
"count": 16
}
},
"alerts": {
"count": 6,
"high": 3,
"by_reason": {
"timeout": 1,
"rate_limit": 1,
"batch_rejected": 1,
"low_quality": 2,
"api_error": 1
}
},
"dlq": {
"size": 3,
"parked": 2,
"waiting": 1
},
"quality": {
"batches": 4,
"avg_score": 0.6583,
"last_score": 1,
"rejected_batches": 1,
"quarantine": 1
},
"cost": null,
"release": {
"active": "1.1.0",
"canary_release": "1.2.0",
"canary_percent": 20,
"active_repeat_rate": 0.0773,
"canary_repeat_rate": 0.625
},
"sources_down": [
"cost"
],
"health": {
"runs": "red",
"alerts": "yellow",
"dlq": "red",
"quality": "yellow",
"cost": "unknown",
"release": "red"
},
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality"
],
"unknown": [
"cost"
],
"overall": "red"
}
+79
View File
@@ -0,0 +1,79 @@
{
"period": {
"from": "2026-10-26",
"to": "2026-10-30"
},
"runs": {
"total": 357,
"errors": 21,
"error_rate": 0.0588,
"p95_ms": 2598,
"unanswered_rate": 0.0476,
"top_failure": {
"node": "Search FAQ",
"message": "timeout of 1000ms exceeded",
"count": 16
}
},
"alerts": {
"count": 6,
"high": 3,
"by_reason": {
"timeout": 1,
"rate_limit": 1,
"batch_rejected": 1,
"low_quality": 2,
"api_error": 1
}
},
"dlq": {
"size": 3,
"parked": 2,
"waiting": 1
},
"quality": {
"batches": 4,
"avg_score": 0.6583,
"last_score": 1,
"rejected_batches": 1,
"quarantine": 1
},
"cost": {
"requests": 156,
"calls": 192,
"cost_usd": 0.073604,
"daily_usd": 0.08,
"used_ratio": 0.9201,
"top_user": {
"user": "test-bot",
"cost_usd": 0.027228,
"share": 0.3699
}
},
"release": {
"active": "1.1.0",
"canary_release": "1.2.0",
"canary_percent": 20,
"active_repeat_rate": 0.0773,
"canary_repeat_rate": 0.625
},
"health": {
"runs": "red",
"alerts": "yellow",
"dlq": "red",
"quality": "yellow",
"cost": "yellow",
"release": "red"
},
"red": [
"runs",
"dlq",
"release"
],
"yellow": [
"alerts",
"quality",
"cost"
],
"overall": "red"
}
@@ -0,0 +1,58 @@
{
"cases": [
{
"question": "지난주 오류율이 얼마야?",
"area": "runs",
"numbers": [
"5.88"
]
},
{
"question": "알림이 몇 건 와 있어?",
"area": "alerts",
"numbers": [
"6"
]
},
{
"question": "DLQ에 parked 된 건 몇 건이야?",
"area": "dlq",
"numbers": [
"2"
]
},
{
"question": "FAQ 배치 품질 점수 평균은?",
"area": "quality",
"numbers": [
"0.6583",
"65.83",
"0.66"
]
},
{
"question": "오늘 LLM 비용이 예산의 몇 퍼센트야?",
"area": "cost",
"numbers": [
"92"
]
},
{
"question": "카나리 버전 문제 있어?",
"area": "release",
"numbers": [
"62.5"
]
},
{
"question": "DLQ 1번 다시 돌려 줘",
"area": "action",
"refused": true
},
{
"question": "점심 메뉴 추천해 줘",
"area": "unknown",
"refused": true
}
]
}
+34
View File
@@ -0,0 +1,34 @@
"""R11 · R12: the TUI shows the areas, a tab that cannot read says so, and asking stays read only."""
from argparse import Namespace
from textual.widgets import DataTable, Input, Static
from conftest import DATA
from tops.app import OpsApp
async def test_areas_and_ask(fake):
app = OpsApp(Namespace(n8n=fake.base, file=None, api=fake.base))
async with app.run_test() as pilot:
assert app.query_one("#area-table", DataTable).row_count == 6
assert "overall 🔴 red" in str(app.query_one("#top", Static).render())
await pilot.press("question_mark")
app.query_one("#question", Input).value = "DLQ 1번 다시 돌려 줘"
await pilot.press("enter")
assert "refused (read_only)" in str(app.query_one("#answer", Static).render())
assert app.query_one("#audit-table", DataTable).row_count == 3
# tlog on the Tools tab reads the run logs too; nothing that changes state is ever called
assert not {path for path, _ in fake.calls} & {"/dlq/update", "/dlq-clear", "/ops-reset", "/budget", "/admin/fault", "/release"}
async def test_file_mode_other_tabs_say_not_available(down):
app = OpsApp(Namespace(n8n=None, file=str(DATA / "facts-cost-down.json"), api=down))
async with app.run_test() as pilot:
assert "sources down: cost" in str(app.query_one("#top", Static).render())
assert app.query_one("#area-table", DataTable).row_count == 6
await pilot.press("question_mark")
app.query_one("#question", Input).value = "지난주 오류율이 얼마야?"
await pilot.press("enter")
assert "not available" in str(app.query_one("#answer", Static).render())
assert "not available" in str(app.query_one("#audit-table", DataTable).get_row_at(0)[2])
+79
View File
@@ -0,0 +1,79 @@
"""R2 · R3 · R4 · R6 · R9 · R10 · R14: commands, --json unchanged, exit codes 0 · 1 · 2 · 4."""
import json
from conftest import DATA, load
from tops.cli import main
def test_status_red_exits_4(fake, capsys):
assert main(["status", "--n8n", fake.base]) == 4
assert "🔴 DLQ 3 (parked 2)" in capsys.readouterr().out
def test_status_json_is_the_n8n_answer(fake, capsys):
main(["status", "--n8n", fake.base, "--json"])
assert json.loads(capsys.readouterr().out) == load("facts.json")
def test_status_from_env(fake, monkeypatch, capsys):
monkeypatch.setenv("TOPS_N8N", fake.base)
assert main(["status", "--from", "2026-10-19", "--to", "2026-10-23"]) == 4
assert fake.calls[0][1] == {"from": "2026-10-19", "to": "2026-10-23"}
def test_status_without_source_is_usage(monkeypatch, capsys):
monkeypatch.delenv("TOPS_N8N", raising=False)
assert main(["status"]) == 2
assert "--n8n" in capsys.readouterr().err
def test_n8n_off_exits_1(down, capsys):
assert main(["status", "--n8n", down]) == 1
assert "cannot read" in capsys.readouterr().err
def test_bad_option_exits_2(capsys):
assert main(["status", "--nope"]) == 2
def test_ask_refused_action(fake, capsys):
assert main(["ask", "--n8n", fake.base, "DLQ 1번 다시 돌려 줘"]) == 0
out = capsys.readouterr().out
assert "refused (read_only)" in out and "읽기만" in out
def test_ask_llm_router_answer(fake, capsys):
main(["ask", "--n8n", fake.base, "이번 주에 AI 쓰는 데 얼마나 나갔어?"])
assert capsys.readouterr().out.startswith("[cost · llm] numbers ✔")
def test_brief_prints_markdown(fake, capsys):
main(["brief", "--n8n", fake.base])
assert "| DLQ | 🔴 | 3건 (parked 2) |" in capsys.readouterr().out
def test_eval_gate_pass_and_fail(fake, capsys, tmp_path):
assert main(["eval", "--n8n", fake.base, str(DATA / "ops-golden.json"), "--min-score", "0.8"]) == 0
assert "8/8 · score 1 · gate pass (min 0.8)" in capsys.readouterr().out
one = tmp_path / "one.json"
one.write_text(json.dumps({"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]}, ensure_ascii=False))
assert main(["eval", "--n8n", fake.base, str(one)]) == 4
assert "✘ 알림이 몇 건 와 있어? · area alerts · area_ok" in capsys.readouterr().out
assert fake.calls[-1][1] == {"cases": [{"question": "알림이 몇 건 와 있어?", "area": "cost"}]}
def test_eval_empty_is_usage(fake, tmp_path, capsys):
empty = tmp_path / "empty.json"
empty.write_text('{"cases": []}')
assert main(["eval", "--n8n", fake.base, str(empty)]) == 2
def test_audit_lists_questions(fake, capsys):
main(["audit", "--api", fake.base])
out = capsys.readouterr().out
assert out.startswith("3 question(s)") and "refused read_only" in out and "answered" in out
def test_tools_never_fail(capsys):
assert main(["tools"]) == 0
+38
View File
@@ -0,0 +1,38 @@
"""R5 · R13: tops calls only the read addresses; n8n off is a read error, not a crash."""
import pytest
from tops import client
def test_only_read_addresses(fake):
with pytest.raises(ValueError):
client.post(fake.base, "/dlq/update", {"id": 1})
with pytest.raises(ValueError):
client.post(fake.base, "/ops-reset", {})
assert fake.calls == []
def test_facts_sends_the_period(fake):
client.facts(fake.base, date_from="2026-10-19", date_to="2026-10-23")
assert fake.calls == [("/webhook/my-ops-facts", {"from": "2026-10-19", "to": "2026-10-23"})]
def test_file_wins_over_n8n(fake, tmp_path):
f = client.facts(fake.base, file=str(__import__("pathlib").Path(__file__).parent / "data" / "facts.json"))
assert f["overall"] == "red" and fake.calls == []
def test_facts_without_health_is_refused(tmp_path):
p = tmp_path / "old.json"
p.write_text('{"runs": {}}')
with pytest.raises(client.ReadError, match="health"):
client.facts(file=str(p))
def test_n8n_off_is_read_error(down):
with pytest.raises(client.ReadError):
client.ask(down, "지난주 오류율이 얼마야?")
def test_http_error_body_is_returned(fake):
assert client.evaluate(fake.base, []) == {"error": "cases must be a non-empty list"}
+41
View File
@@ -0,0 +1,41 @@
"""R1 · R7: the colors and numbers come from the facts unchanged."""
from conftest import load
from tops import view
def test_status_shows_every_area_with_the_facts_colors():
lines = view.status_lines(load("facts.json"))
assert lines[0].startswith("overall 🔴 red · runs 2026-10-26 ~ 2026-10-30")
assert " 🔴 runs error rate 5.88% · p95 2598ms · unanswered 4.76%" in lines
assert " 🔴 DLQ 3 (parked 2)" in lines
assert " 🟡 cost $0.073604 / $0.08 (92.01%)" in lines
assert " 🔴 release 1.1.0 · canary 1.2.0 20% (repeat 62.5%)" in lines
def test_source_down_is_unknown_not_zero():
lines = view.status_lines(load("facts-cost-down.json"))
assert " ⚪ cost source not answering" in lines
assert lines[-1] == " sources down: cost"
def test_colors_are_not_recounted():
f = load("facts.json")
f["health"]["cost"] = "green" # n8n says green: tops shows green even at 92%
assert any(line.startswith(" 🟢 cost") for line in view.status_lines(f))
def test_answer_lines_refused_and_checked():
asks = load("asks.json")
assert view.answer_lines(asks["DLQ 1번 다시 돌려 줘"])[0] == "[action · keyword] refused (read_only)"
assert view.answer_lines(asks["지난주 오류율이 얼마야?"])[0] == "[runs · keyword] numbers ✔"
def test_missing_tool_is_reported_not_raised():
part = view.tool("no-such-course-tool", ["--json"])
assert part == {"tool": "no-such-course-tool", "ok": False, "why": "not installed (uv tool install)"}
def test_tool_line_reads_tlog_slo():
part = {"tool": "tlog", "ok": True, "code": 4, "result": {"ok": False, "breaches": ["error_rate", "unanswered_rate"], "total": 357}}
assert view.tool_line(part) == " ✔ tlog slo: breaches error_rate, unanswered_rate (357 runs)"