79일차 운영 데이터 기반 Ops Assistant: 실습 15개 · 강의(개요 30장 · 단계 문서 · 4단계 정리 · 05 바이브), 정답본 LAB-A · answers · VIBE-A · tops 포함
This commit is contained in:
1 parent
9ce9aceba6
commit
6f0ecd59a9
156 files changed
+31640
No files matched your search
@@ -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"
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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일차)"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)"
|
||||
Reference in new issue
Block a user